@rogieking/figui3 8.3.0 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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() {
@@ -8666,6 +8745,10 @@ class FigInputFill extends HTMLElement {
8666
8745
  }
8667
8746
  case "image":
8668
8747
  return this.#image.url ? `url(${this.#image.url})` : "#D9D9D9";
8748
+ case "webcam":
8749
+ return this.#webcam.snapshot
8750
+ ? `url(${this.#webcam.snapshot})`
8751
+ : "#D9D9D9";
8669
8752
  default:
8670
8753
  return "#D9D9D9";
8671
8754
  }
@@ -8733,81 +8816,97 @@ class FigInputFill extends HTMLElement {
8733
8816
  syncState(this.#opacityInput, `${name} opacity`);
8734
8817
  }
8735
8818
 
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
- : "";
8819
+ #createOpacityControl(value, disabled) {
8820
+ if (this.getAttribute("alpha") === "false") return null;
8821
+ return figCreateElement(
8822
+ "fig-tooltip",
8823
+ { text: "Opacity" },
8824
+ figCreateElement("fig-input-number", {
8825
+ className: "fig-input-fill-opacity",
8826
+ placeholder: "##",
8827
+ min: "0",
8828
+ max: "100",
8829
+ value,
8830
+ units: "%",
8831
+ disabled,
8832
+ }),
8833
+ );
8834
+ }
8756
8835
 
8757
- let controlsHtml = "";
8836
+ #createControls(disabled) {
8837
+ let label = null;
8838
+ let opacity = 1;
8758
8839
 
8759
8840
  switch (this.#fillType) {
8760
8841
  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 =
8842
+ return [
8843
+ figCreateElement("fig-input-text", {
8844
+ type: "text",
8845
+ className: "fig-input-fill-hex",
8846
+ placeholder: "000000",
8847
+ value: this.#solid.color.slice(1).toUpperCase(),
8848
+ disabled,
8849
+ }),
8850
+ this.#createOpacityControl(
8851
+ Math.round(this.#solid.alpha * 100),
8852
+ disabled,
8853
+ ),
8854
+ ].filter(Boolean);
8855
+ case "gradient":
8856
+ label =
8774
8857
  this.#gradient.type.charAt(0).toUpperCase() +
8775
8858
  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))}`;
8859
+ opacity = this.#gradient.opacity ?? 1;
8779
8860
  break;
8780
- }
8781
-
8782
8861
  case "image":
8783
- controlsHtml = `
8784
- <label class="fig-input-fill-label">Image</label>
8785
- ${opacityHtml(Math.round((this.#image.opacity ?? 1) * 100))}`;
8862
+ label = "Image";
8863
+ opacity = this.#image.opacity ?? 1;
8786
8864
  break;
8787
-
8788
8865
  case "video":
8789
- controlsHtml = `
8790
- <label class="fig-input-fill-label">Video</label>
8791
- ${opacityHtml(Math.round((this.#video.opacity ?? 1) * 100))}`;
8866
+ label = "Video";
8867
+ opacity = this.#video.opacity ?? 1;
8792
8868
  break;
8793
-
8794
8869
  case "webcam":
8795
- controlsHtml = `
8796
- <label class="fig-input-fill-label">Webcam</label>
8797
- ${opacityHtml(Math.round((this.#webcam.opacity ?? 1) * 100))}`;
8870
+ label = "Webcam";
8871
+ opacity = this.#webcam.opacity ?? 1;
8798
8872
  break;
8799
8873
  }
8800
8874
 
8875
+ return [
8876
+ figCreateElement(
8877
+ "label",
8878
+ { className: "fig-input-fill-label" },
8879
+ label,
8880
+ ),
8881
+ this.#createOpacityControl(Math.round(opacity * 100), disabled),
8882
+ ].filter(Boolean);
8883
+ }
8884
+
8885
+ #render() {
8886
+ const disabled =
8887
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
8888
+ const fillPickerValue = JSON.stringify(this.value);
8801
8889
  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>`;
8890
+ const swatch = figCreateElement("fig-swatch", {
8891
+ background: this.#fillPickerSwatchBackground(),
8892
+ alpha: this.#fillPickerSwatchAlpha(),
8893
+ disabled,
8894
+ });
8895
+ const picker = figCreateElement(
8896
+ "fig-fill-picker",
8897
+ {
8898
+ value: fillPickerValue,
8899
+ disabled,
8900
+ ...fpAttrs,
8901
+ },
8902
+ swatch,
8903
+ );
8904
+ const combo = figCreateElement(
8905
+ "div",
8906
+ { className: "input-combo" },
8907
+ [picker, this.#createControls(disabled)],
8908
+ );
8909
+ this.replaceChildren(combo);
8811
8910
 
8812
8911
  this.#setupEventListeners();
8813
8912
  }
@@ -8870,6 +8969,9 @@ class FigInputFill extends HTMLElement {
8870
8969
  case "video":
8871
8970
  if (detail.video) this.#video = detail.video;
8872
8971
  break;
8972
+ case "webcam":
8973
+ if (detail.image?.url) this.#webcam.snapshot = detail.image.url;
8974
+ break;
8873
8975
  }
8874
8976
  // Update controls (don't re-render to keep dialog open)
8875
8977
  if (typeChanged) {
@@ -9002,7 +9104,6 @@ class FigInputFill extends HTMLElement {
9002
9104
  // Update only the controls (not the fill picker) when type changes
9003
9105
  const disabled =
9004
9106
  this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
9005
- const showAlpha = this.getAttribute("alpha") !== "false";
9006
9107
  const combo = this.querySelector(".input-combo");
9007
9108
  if (!combo) return;
9008
9109
 
@@ -9016,98 +9117,8 @@ class FigInputFill extends HTMLElement {
9016
9117
  oldHex?.remove();
9017
9118
  oldTooltips.forEach((t) => t.remove());
9018
9119
 
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
9120
  // Append new controls after the fill picker
9110
- combo.insertAdjacentHTML("beforeend", controlsHtml);
9121
+ combo.append(...this.#createControls(disabled));
9111
9122
 
9112
9123
  // Re-setup event listeners for the new controls
9113
9124
  this.#opacityInput = this.querySelector(".fig-input-fill-opacity");
@@ -9496,7 +9507,7 @@ class FigInputPalette extends HTMLElement {
9496
9507
  this.hasAttribute("disabled") &&
9497
9508
  this.getAttribute("disabled") !== "false";
9498
9509
 
9499
- this.innerHTML = "";
9510
+ this.replaceChildren();
9500
9511
  this.#inlinePickers = [];
9501
9512
  this.#expandedPickers = [];
9502
9513
 
@@ -10079,11 +10090,6 @@ class FigInputGradient extends HTMLElement {
10079
10090
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
10080
10091
  }
10081
10092
 
10082
- #swatchSizeAttr() {
10083
- const size = this.getAttribute("size");
10084
- return size ? ` size="${size}"` : "";
10085
- }
10086
-
10087
10093
  #syncSwatchSize() {
10088
10094
  if (!this.#swatch) return;
10089
10095
  const size = this.getAttribute("size");
@@ -10091,16 +10097,32 @@ class FigInputGradient extends HTMLElement {
10091
10097
  else this.#swatch.removeAttribute("size");
10092
10098
  }
10093
10099
 
10094
- #buildStopHandles() {
10100
+ #createStopHandles() {
10095
10101
  const disabled =
10096
10102
  this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
10097
- const tipAttr = this.#stopHandleMode === "tip" ? ' tip="color"' : "";
10098
10103
  return this.#gradient.stops
10099
10104
  .map(
10100
10105
  (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("");
10106
+ figCreateElement(
10107
+ "fig-tooltip",
10108
+ {
10109
+ action: "manual",
10110
+ text: `${Math.round(stop.position)}%`,
10111
+ },
10112
+ figCreateElement("fig-handle", {
10113
+ drag: true,
10114
+ "drag-axes": "x",
10115
+ "drag-surface": ".fig-input-gradient-track",
10116
+ type: "color",
10117
+ tip: this.#stopHandleMode === "tip" ? "color" : null,
10118
+ color: this.#stopColorCSS(stop),
10119
+ value: `${stop.position}% 50%`,
10120
+ "hit-area": "4",
10121
+ "data-stop-index": i,
10122
+ disabled,
10123
+ }),
10124
+ ),
10125
+ );
10104
10126
  }
10105
10127
 
10106
10128
  #ghostHandle = null;
@@ -10111,27 +10133,45 @@ class FigInputGradient extends HTMLElement {
10111
10133
  this.#colorObserver = null;
10112
10134
  const disabled = figBooleanAttribute(this, "disabled");
10113
10135
  const mode = this.#editMode;
10136
+ const size = this.getAttribute("size");
10137
+ const swatch = figCreateElement("fig-swatch", {
10138
+ background: this.#buildGradientCSS(),
10139
+ size: size || null,
10140
+ disabled,
10141
+ });
10114
10142
 
10115
10143
  if (mode === "picker" && hasFigFillPicker()) {
10116
10144
  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");
10145
+ const picker = figCreateElement(
10146
+ "fig-fill-picker",
10147
+ {
10148
+ mode: "gradient",
10149
+ value: gradientValue,
10150
+ disabled,
10151
+ },
10152
+ swatch,
10153
+ );
10154
+ this.replaceChildren(picker);
10155
+ this.#swatch = swatch;
10122
10156
  this.#track = null;
10123
10157
  this.#setupPickerEvents();
10124
10158
  this.#syncFocusTarget();
10125
10159
  return;
10126
10160
  }
10127
10161
 
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");
10162
+ const editable = mode === "true" || mode === "picker";
10163
+ const track = editable
10164
+ ? figCreateElement(
10165
+ "div",
10166
+ { className: "fig-input-gradient-track" },
10167
+ this.#createStopHandles(),
10168
+ )
10169
+ : null;
10170
+ this.replaceChildren(...(track ? [swatch, track] : [swatch]));
10171
+ this.#swatch = swatch;
10172
+ this.#track = track;
10133
10173
 
10134
- if (mode === "true" || mode === "picker") {
10174
+ if (editable) {
10135
10175
  this.#setupGhostHandle();
10136
10176
  this.#setupEventListeners();
10137
10177
  }
@@ -10345,7 +10385,7 @@ class FigInputGradient extends HTMLElement {
10345
10385
 
10346
10386
  if (handles.length !== stops.length) {
10347
10387
  const ghost = this.#ghostHandle;
10348
- this.#track.innerHTML = this.#buildStopHandles();
10388
+ this.#track.replaceChildren(...this.#createStopHandles());
10349
10389
  if (ghost) this.#track.appendChild(ghost);
10350
10390
  this.#syncHandleMode();
10351
10391
  this.#reobserveHandleColors();
@@ -11096,22 +11136,54 @@ class FigComboInput extends HTMLElement {
11096
11136
  const currentValue = this.value;
11097
11137
  const dropdownLabel = this.#dropdownLabel();
11098
11138
 
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>`;
11139
+ const input = figCreateElement("fig-input-text", {
11140
+ placeholder,
11141
+ value: currentValue,
11142
+ });
11143
+ const icon = figCreateSvgElement(
11144
+ "svg",
11145
+ {
11146
+ width: "16",
11147
+ height: "16",
11148
+ viewBox: "0 0 16 16",
11149
+ fill: "none",
11150
+ },
11151
+ figCreateSvgElement("path", {
11152
+ d: "M5.87868 7.12132L8 9.24264L10.1213 7.12132",
11153
+ stroke: "currentColor",
11154
+ "stroke-opacity": "0.9",
11155
+ "stroke-linecap": "round",
11156
+ }),
11157
+ );
11158
+ const button = figCreateElement(
11159
+ "fig-button",
11160
+ {
11161
+ type: "select",
11162
+ variant: "input",
11163
+ icon: true,
11164
+ },
11165
+ icon,
11166
+ );
11167
+ if (!this.#usesCustomDropdown) {
11168
+ button.appendChild(
11169
+ figCreateElement(
11170
+ "fig-dropdown",
11171
+ {
11172
+ type: "dropdown",
11173
+ label: dropdownLabel,
11174
+ },
11175
+ options.map((option) =>
11176
+ figCreateElement("option", {}, option.trim()),
11177
+ ),
11178
+ ),
11179
+ );
11180
+ }
11181
+ this.replaceChildren(
11182
+ figCreateElement("div", { className: "input-combo" }, [input, button]),
11183
+ );
11112
11184
 
11113
- this.#input = this.querySelector("fig-input-text");
11114
- this.#button = this.querySelector("fig-button");
11185
+ this.#input = input;
11186
+ this.#button = button;
11115
11187
 
11116
11188
  if (this.#usesCustomDropdown && this.#customDropdown && this.#button) {
11117
11189
  if (!this.#customDropdown.hasAttribute("type")) {
@@ -11376,14 +11448,18 @@ class FigSwatch extends HTMLElement {
11376
11448
 
11377
11449
  if (this.#type === "color") {
11378
11450
  const hex = this.#toHex(bg);
11379
- this.innerHTML = `<div></div><input type="color" value="${hex}" />`;
11380
- this.input = this.querySelector("input");
11451
+ const input = figCreateElement("input", {
11452
+ type: "color",
11453
+ value: hex,
11454
+ });
11455
+ this.replaceChildren(document.createElement("div"), input);
11456
+ this.input = input;
11381
11457
  if (!isVar) {
11382
11458
  this.input.addEventListener("input", this.#boundHandleInput);
11383
11459
  }
11384
11460
  this.#syncA11y();
11385
11461
  } else {
11386
- this.innerHTML = "<div></div>";
11462
+ this.replaceChildren(document.createElement("div"));
11387
11463
  this.input = null;
11388
11464
  this.#syncA11y();
11389
11465
  }
@@ -13162,7 +13238,7 @@ class FigInputFile extends HTMLElement {
13162
13238
  !!this.getAttribute("url") ||
13163
13239
  !!this.getAttribute("filename");
13164
13240
 
13165
- this.innerHTML = "";
13241
+ this.replaceChildren();
13166
13242
 
13167
13243
  if (hasFile) {
13168
13244
  const tooltipText = accepts
@@ -13545,10 +13621,10 @@ class FigEasingCurve extends HTMLElement {
13545
13621
  const y = pad + (1 - (pts[i].value - minVal) / range) * s;
13546
13622
  d += (i === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1);
13547
13623
  }
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>`;
13624
+ return FigEasingCurve.#createSvgIcon(d, size);
13549
13625
  }
13550
13626
 
13551
- static curveIcon(cp1x, cp1y, cp2x, cp2y, size = 24) {
13627
+ static #curveIconPath(cp1x, cp1y, cp2x, cp2y, size = 24) {
13552
13628
  const draw = 12;
13553
13629
  const pad = (size - draw) / 2;
13554
13630
  const samples = 48;
@@ -13594,6 +13670,43 @@ class FigEasingCurve extends HTMLElement {
13594
13670
  const py = toY(points[i].y);
13595
13671
  d += `${i === 0 ? "M" : "L"}${px.toFixed(1)},${py.toFixed(1)}`;
13596
13672
  }
13673
+ return d;
13674
+ }
13675
+
13676
+ static #createSvgIcon(d, size = 24) {
13677
+ return figCreateSvgElement(
13678
+ "svg",
13679
+ {
13680
+ width: size,
13681
+ height: size,
13682
+ viewBox: `0 0 ${size} ${size}`,
13683
+ fill: "none",
13684
+ },
13685
+ figCreateSvgElement("path", {
13686
+ d,
13687
+ stroke: "currentColor",
13688
+ "stroke-width": "1",
13689
+ "stroke-linecap": "round",
13690
+ fill: "none",
13691
+ }),
13692
+ );
13693
+ }
13694
+
13695
+ static #createCurveIcon(cp1x, cp1y, cp2x, cp2y, size = 24) {
13696
+ return FigEasingCurve.#createSvgIcon(
13697
+ FigEasingCurve.#curveIconPath(cp1x, cp1y, cp2x, cp2y, size),
13698
+ size,
13699
+ );
13700
+ }
13701
+
13702
+ static curveIcon(cp1x, cp1y, cp2x, cp2y, size = 24) {
13703
+ const d = FigEasingCurve.#curveIconPath(
13704
+ cp1x,
13705
+ cp1y,
13706
+ cp2x,
13707
+ cp2y,
13708
+ size,
13709
+ );
13597
13710
  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
13711
  }
13599
13712
 
@@ -13607,7 +13720,7 @@ class FigEasingCurve extends HTMLElement {
13607
13720
  this.classList.toggle("spring-mode", this.#mode === "spring");
13608
13721
  this.classList.toggle("bezier-mode", this.#mode !== "spring");
13609
13722
  this.#syncMetricsFromCSS();
13610
- this.innerHTML = this.#getInnerHTML();
13723
+ this.replaceChildren(...this.#createContent());
13611
13724
  this.#cacheRefs();
13612
13725
  if (this.#svg) {
13613
13726
  this.#syncHandleSizes();
@@ -13618,14 +13731,6 @@ class FigEasingCurve extends HTMLElement {
13618
13731
  this.#setupEvents();
13619
13732
  }
13620
13733
 
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
13734
  #canUseFigSelect() {
13630
13735
  return ["fig-select", "fig-select-options", "fig-select-option"].every(
13631
13736
  (tag) => {
@@ -13638,38 +13743,43 @@ class FigEasingCurve extends HTMLElement {
13638
13743
  );
13639
13744
  }
13640
13745
 
13641
- #getDropdownHTML() {
13642
- let optionsHTML = "";
13746
+ #createDropdown() {
13747
+ const dropdown = figCreateElement("fig-dropdown", {
13748
+ className: "fig-easing-curve-select",
13749
+ label: "Easing preset",
13750
+ value: this.#presetName,
13751
+ full: true,
13752
+ });
13643
13753
  let currentGroup = undefined;
13644
- let groupOpen = false;
13754
+ let parent = dropdown;
13645
13755
  for (const preset of FigEasingCurve.PRESETS) {
13646
13756
  if (!this.#isEditEnabled() && !preset.value && !preset.spring) continue;
13647
13757
  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
13758
  currentGroup = preset.group;
13759
+ parent = currentGroup
13760
+ ? figCreateElement("optgroup", { label: currentGroup })
13761
+ : dropdown;
13762
+ if (parent !== dropdown) dropdown.appendChild(parent);
13654
13763
  }
13655
- const name = FigEasingCurve.#escapeAttribute(preset.name);
13656
- optionsHTML += `<option value="${name}">${name}</option>`;
13764
+ parent.appendChild(
13765
+ figCreateElement("option", { value: preset.name }, preset.name),
13766
+ );
13657
13767
  }
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>`;
13768
+ return dropdown;
13661
13769
  }
13662
13770
 
13663
- #getSelectHTML() {
13664
- if (!this.#canUseFigSelect()) return this.#getDropdownHTML();
13771
+ #createSelect() {
13772
+ if (!this.#canUseFigSelect()) return this.#createDropdown();
13665
13773
 
13666
- let optionsHTML = "";
13774
+ const options = document.createElement("fig-select-options");
13667
13775
  let currentGroup = undefined;
13668
13776
  for (const p of FigEasingCurve.PRESETS) {
13669
13777
  if (!this.#isEditEnabled() && !p.value && !p.spring) continue;
13670
13778
  if (p.group !== currentGroup) {
13671
13779
  if (p.group) {
13672
- optionsHTML += `<fig-separator label="${FigEasingCurve.#escapeAttribute(p.group)}"></fig-separator>`;
13780
+ options.appendChild(
13781
+ figCreateElement("fig-separator", { label: p.group }),
13782
+ );
13673
13783
  }
13674
13784
  currentGroup = p.group;
13675
13785
  }
@@ -13684,42 +13794,152 @@ class FigEasingCurve extends HTMLElement {
13684
13794
  this.#cp2.x,
13685
13795
  this.#cp2.y,
13686
13796
  ];
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>`;
13797
+ icon = FigEasingCurve.#createCurveIcon(...v);
13798
+ }
13799
+ options.appendChild(
13800
+ figCreateElement(
13801
+ "fig-select-option",
13802
+ {
13803
+ value: p.name,
13804
+ label: p.name,
13805
+ },
13806
+ [
13807
+ figCreateElement("span", { slot: "prepend" }, icon),
13808
+ figCreateElement("span", {}, p.name),
13809
+ ],
13810
+ ),
13811
+ );
13691
13812
  }
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>`;
13813
+ return figCreateElement(
13814
+ "fig-select",
13815
+ {
13816
+ className: "fig-easing-curve-select",
13817
+ label: "Easing preset",
13818
+ value: this.#presetName,
13819
+ full: true,
13820
+ },
13821
+ options,
13822
+ );
13823
+ }
13824
+
13825
+ #createHandle(className, dataHandle, label) {
13826
+ return figCreateSvgElement(
13827
+ "foreignObject",
13828
+ {
13829
+ className,
13830
+ "data-handle": dataHandle,
13831
+ width: "20",
13832
+ height: "20",
13833
+ },
13834
+ figCreateElement("fig-handle", {
13835
+ size: "small",
13836
+ "aria-label": label,
13837
+ }),
13838
+ );
13694
13839
  }
13695
13840
 
13696
- #getInnerHTML() {
13841
+ #createContent() {
13697
13842
  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>`;
13843
+ const select = this.#createSelect();
13844
+ if (!this.#isEditEnabled()) return [select];
13845
+ const valueInput = figCreateElement("fig-input-text", {
13846
+ className: "fig-easing-curve-value-input",
13847
+ value: this.value,
13848
+ full: true,
13849
+ });
13850
+ const svgChildren = [
13851
+ figCreateSvgElement("rect", {
13852
+ className: "fig-easing-curve-bounds",
13853
+ x: "0",
13854
+ y: "0",
13855
+ width: size,
13856
+ height: size,
13857
+ }),
13858
+ ];
13701
13859
 
13702
13860
  if (this.#mode === "spring") {
13703
13861
  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}`;
13862
+ svgChildren.push(
13863
+ figCreateSvgElement("line", {
13864
+ className: "fig-easing-curve-target",
13865
+ x1: "0",
13866
+ y1: targetY,
13867
+ x2: size,
13868
+ y2: targetY,
13869
+ }),
13870
+ figCreateSvgElement("path", {
13871
+ className: "fig-easing-curve-path",
13872
+ }),
13873
+ this.#createHandle(
13874
+ "fig-easing-curve-handle",
13875
+ "bounce",
13876
+ "Spring bounce handle",
13877
+ ),
13878
+ this.#createHandle(
13879
+ "fig-easing-curve-handle fig-easing-curve-duration-bar",
13880
+ "duration",
13881
+ "Spring duration handle",
13882
+ ),
13883
+ );
13884
+ } else {
13885
+ svgChildren.push(
13886
+ figCreateSvgElement("line", {
13887
+ className: "fig-easing-curve-boundary",
13888
+ "data-boundary": "top",
13889
+ x1: "0",
13890
+ y1: "0",
13891
+ x2: size,
13892
+ y2: "0",
13893
+ }),
13894
+ figCreateSvgElement("line", {
13895
+ className: "fig-easing-curve-boundary",
13896
+ "data-boundary": "bottom",
13897
+ x1: "0",
13898
+ y1: size,
13899
+ x2: size,
13900
+ y2: size,
13901
+ }),
13902
+ figCreateSvgElement("path", {
13903
+ className: "fig-easing-curve-path",
13904
+ }),
13905
+ figCreateSvgElement("line", {
13906
+ className: "fig-easing-curve-arm",
13907
+ "data-arm": "1",
13908
+ }),
13909
+ figCreateSvgElement("line", {
13910
+ className: "fig-easing-curve-arm",
13911
+ "data-arm": "2",
13912
+ }),
13913
+ this.#createHandle(
13914
+ "fig-easing-curve-handle",
13915
+ "1",
13916
+ "First easing control point",
13917
+ ),
13918
+ this.#createHandle(
13919
+ "fig-easing-curve-handle",
13920
+ "2",
13921
+ "Second easing control point",
13922
+ ),
13923
+ );
13924
+ }
13925
+
13926
+ const svg = figCreateSvgElement(
13927
+ "svg",
13928
+ {
13929
+ viewBox: `0 0 ${size} ${size}`,
13930
+ className: "fig-easing-curve-svg",
13931
+ },
13932
+ svgChildren,
13933
+ );
13934
+ return [
13935
+ select,
13936
+ figCreateElement(
13937
+ "div",
13938
+ { className: "fig-easing-curve-svg-container" },
13939
+ svg,
13940
+ ),
13941
+ valueInput,
13942
+ ];
13723
13943
  }
13724
13944
 
13725
13945
  #readCssNumber(name, fallback) {
@@ -14054,7 +14274,7 @@ class FigEasingCurve extends HTMLElement {
14054
14274
  for (const option of this.#select.querySelectorAll("fig-select-option")) {
14055
14275
  if (option.value === optionValue) {
14056
14276
  const prepend = option.querySelector(':scope > [slot="prepend"]');
14057
- if (prepend) prepend.innerHTML = icon;
14277
+ if (prepend) prepend.replaceChildren(icon.cloneNode(true));
14058
14278
  }
14059
14279
  }
14060
14280
  }
@@ -14062,7 +14282,7 @@ class FigEasingCurve extends HTMLElement {
14062
14282
  #refreshCustomPresetIcons() {
14063
14283
  if (!this.#select) return;
14064
14284
  if (!this.#isEditEnabled()) return;
14065
- const bezierIcon = FigEasingCurve.curveIcon(
14285
+ const bezierIcon = FigEasingCurve.#createCurveIcon(
14066
14286
  this.#cp1.x,
14067
14287
  this.#cp1.y,
14068
14288
  this.#cp2.x,
@@ -14652,34 +14872,44 @@ class Fig3DRotate extends HTMLElement {
14652
14872
  rotateY: this.#ry,
14653
14873
  rotateZ: this.#rz,
14654
14874
  };
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");
14875
+ const cube = figCreateElement(
14876
+ "div",
14877
+ { className: "fig-3d-rotate-cube" },
14878
+ ["front", "back", "right", "left", "top", "bottom"].map((face) =>
14879
+ figCreateElement("div", {
14880
+ className: `fig-3d-rotate-face ${face}`,
14881
+ }),
14882
+ ),
14883
+ );
14884
+ const container = figCreateElement(
14885
+ "div",
14886
+ {
14887
+ className: "fig-3d-rotate-container",
14888
+ tabindex: "0",
14889
+ },
14890
+ figCreateElement(
14891
+ "div",
14892
+ { className: "fig-3d-rotate-scene" },
14893
+ cube,
14894
+ ),
14895
+ );
14896
+ const fields = this.#fields.map((axis) =>
14897
+ figCreateElement(
14898
+ "fig-input-number",
14899
+ {
14900
+ name: axis,
14901
+ step: "1",
14902
+ precision: "1",
14903
+ value: axisValues[axis],
14904
+ units: "°",
14905
+ },
14906
+ figCreateElement("span", { slot: "prepend" }, axisLabels[axis]),
14907
+ ),
14908
+ );
14909
+
14910
+ this.replaceChildren(container, ...fields);
14911
+ this.#container = container;
14912
+ this.#cube = cube;
14683
14913
  this.#wireFieldInputs();
14684
14914
  this.#updateCube();
14685
14915
  this.#setupEvents();
@@ -14982,31 +15212,65 @@ class FigOriginGrid extends HTMLElement {
14982
15212
  const cells = Array.from({ length: 9 }, (_, index) => {
14983
15213
  const col = index % 3;
14984
15214
  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("");
15215
+ return figCreateElement(
15216
+ "span",
15217
+ {
15218
+ className: "origin-grid-cell",
15219
+ "data-col": col,
15220
+ "data-row": row,
15221
+ },
15222
+ figCreateElement("span", { className: "origin-grid-dot" }),
15223
+ );
15224
+ });
14989
15225
 
14990
15226
  const xValue = this.#x.toFixed(this.#precision);
14991
15227
  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}`;
15228
+ const handle = document.createElement("fig-handle");
15229
+ const grid = figCreateElement(
15230
+ "div",
15231
+ {
15232
+ className: "origin-grid",
15233
+ "aria-label": "Transform origin grid",
15234
+ },
15235
+ [
15236
+ figCreateElement(
15237
+ "div",
15238
+ { className: "origin-grid-cells" },
15239
+ cells,
15240
+ ),
15241
+ handle,
15242
+ ],
15243
+ );
15244
+ const surface = figCreateElement(
15245
+ "div",
15246
+ { className: "fig-origin-grid-surface" },
15247
+ grid,
15248
+ );
15249
+ const rendered = [surface];
15250
+ if (this.#fieldsEnabled) {
15251
+ const createValueInput = (name, value) =>
15252
+ figCreateElement(
15253
+ "fig-input-number",
15254
+ {
15255
+ name,
15256
+ value,
15257
+ step: "1",
15258
+ units: "%",
15259
+ },
15260
+ figCreateElement("span", { slot: "prepend" }, name.toUpperCase()),
15261
+ );
15262
+ rendered.push(
15263
+ figCreateElement("div", { className: "origin-values" }, [
15264
+ createValueInput("x", xValue),
15265
+ createValueInput("y", yValue),
15266
+ ]),
15267
+ );
15268
+ }
15006
15269
 
15007
- this.#grid = this.querySelector(".origin-grid");
15008
- this.#cells = Array.from(this.querySelectorAll(".origin-grid-cell"));
15009
- this.#handle = this.querySelector("fig-handle");
15270
+ this.replaceChildren(...rendered);
15271
+ this.#grid = grid;
15272
+ this.#cells = cells;
15273
+ this.#handle = handle;
15010
15274
  this.#xInput = this.querySelector('fig-input-number[name="x"]');
15011
15275
  this.#yInput = this.querySelector('fig-input-number[name="y"]');
15012
15276
  this.#syncHandlePosition();
@@ -15463,7 +15727,100 @@ class FigInputJoystick extends HTMLElement {
15463
15727
  }
15464
15728
 
15465
15729
  #render() {
15466
- this.innerHTML = this.#getInnerHTML();
15730
+ const axisLabels = this.#getAxisLabels();
15731
+ const planeContainer = figCreateElement("div", {
15732
+ className: "fig-input-joystick-plane-container",
15733
+ });
15734
+ const createAxisLabel = (position, text, noRotate = false) =>
15735
+ text
15736
+ ? figCreateElement(
15737
+ "label",
15738
+ {
15739
+ className: [
15740
+ "fig-joystick-axis-label",
15741
+ position,
15742
+ noRotate ? "no-rotate" : "",
15743
+ ]
15744
+ .filter(Boolean)
15745
+ .join(" "),
15746
+ "aria-hidden": "true",
15747
+ },
15748
+ text,
15749
+ )
15750
+ : null;
15751
+ planeContainer.append(
15752
+ ...[
15753
+ createAxisLabel(
15754
+ "left",
15755
+ axisLabels.left,
15756
+ axisLabels.leftNoRotate,
15757
+ ),
15758
+ createAxisLabel("right", axisLabels.right),
15759
+ createAxisLabel("top", axisLabels.top),
15760
+ createAxisLabel("bottom", axisLabels.bottom),
15761
+ ].filter(Boolean),
15762
+ );
15763
+
15764
+ const plane = figCreateElement(
15765
+ "div",
15766
+ { className: "fig-input-joystick-plane" },
15767
+ [
15768
+ figCreateElement("div", {
15769
+ className: "fig-input-joystick-guides",
15770
+ }),
15771
+ figCreateElement("fig-handle", {
15772
+ drag: true,
15773
+ "drag-surface": ".fig-input-joystick-plane",
15774
+ "drag-axes": "x,y",
15775
+ "drag-snapping": "modifier",
15776
+ }),
15777
+ ],
15778
+ );
15779
+ const reset = figCreateElement(
15780
+ "fig-tooltip",
15781
+ { text: "Reset" },
15782
+ figCreateElement(
15783
+ "fig-button",
15784
+ {
15785
+ variant: "ghost",
15786
+ icon: "true",
15787
+ className: "fig-joystick-reset",
15788
+ "aria-label": "Reset to default",
15789
+ },
15790
+ createFigIcon("reset", { size: "small" }),
15791
+ ),
15792
+ );
15793
+ planeContainer.append(plane, reset);
15794
+
15795
+ const children = [planeContainer];
15796
+ if (this.#fieldsEnabled) {
15797
+ const createValueInput = (name, value) =>
15798
+ figCreateElement(
15799
+ "fig-input-number",
15800
+ {
15801
+ name,
15802
+ step: "1",
15803
+ value,
15804
+ min: "0",
15805
+ max: "100",
15806
+ units: "%",
15807
+ },
15808
+ figCreateElement("span", { slot: "prepend" }, name.toUpperCase()),
15809
+ );
15810
+ children.push(
15811
+ figCreateElement("div", { className: "joystick-values" }, [
15812
+ createValueInput(
15813
+ "x",
15814
+ (this.position.x * 100).toFixed(this.precision),
15815
+ ),
15816
+ createValueInput(
15817
+ "y",
15818
+ (this.position.y * 100).toFixed(this.precision),
15819
+ ),
15820
+ ]),
15821
+ );
15822
+ }
15823
+ this.replaceChildren(...children);
15467
15824
  }
15468
15825
 
15469
15826
  #getAxisLabels() {
@@ -15492,63 +15849,6 @@ class FigInputJoystick extends HTMLElement {
15492
15849
  return { left: "", right: "", top: "", bottom: "", leftNoRotate: false };
15493
15850
  }
15494
15851
 
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
15852
  #setupListeners() {
15553
15853
  this.plane = this.querySelector(".fig-input-joystick-plane");
15554
15854
  this.cursor = this.querySelector("fig-handle");
@@ -16078,314 +16378,6 @@ class FigPreview extends HTMLElement {
16078
16378
  }
16079
16379
  figDefineElement("fig-preview", FigPreview);
16080
16380
 
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
16381
  /** @type {Record<string, string | { medium: string, small: string }>} */
16390
16382
  const FIG_ICON_TOKENS = {
16391
16383
  chevron: { medium: "--icon-24-chevron", small: "--icon-16-chevron" },
@@ -16575,11 +16567,22 @@ class FigColorTip extends HTMLElement {
16575
16567
  }
16576
16568
 
16577
16569
  #render() {
16570
+ this.#teardownListeners();
16578
16571
  const mode = this.#controlMode;
16579
16572
  if (mode === "add" || mode === "remove") {
16580
16573
  const iconName = mode === "add" ? "add" : "minus";
16581
16574
  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>`;
16575
+ this.replaceChildren(
16576
+ figCreateElement(
16577
+ "fig-button",
16578
+ {
16579
+ icon: true,
16580
+ variant: "ghost",
16581
+ "aria-label": label,
16582
+ },
16583
+ createFigIcon(iconName),
16584
+ ),
16585
+ );
16583
16586
  this.#fillPicker = null;
16584
16587
  this.#swatch = null;
16585
16588
  this.addEventListener("click", this.#handleControlClick);
@@ -16591,7 +16594,6 @@ class FigColorTip extends HTMLElement {
16591
16594
  const rawValue = (this.getAttribute("value") || "").trim();
16592
16595
  const color = this.#normalizeColor(rawValue);
16593
16596
  const alpha = this.#extractAlpha(rawValue);
16594
- const alphaAttr = this.#alphaEnabled ? "" : 'alpha="false"';
16595
16597
  const pickerValue =
16596
16598
  alpha < 1
16597
16599
  ? JSON.stringify({
@@ -16600,16 +16602,28 @@ class FigColorTip extends HTMLElement {
16600
16602
  opacity: Math.round(alpha * 100),
16601
16603
  })
16602
16604
  : 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>`;
16605
+ const swatch = figCreateElement("fig-swatch", {
16606
+ background: color,
16607
+ alpha: alpha < 1 ? alpha : null,
16608
+ });
16609
+ let picker = null;
16610
+ if (hasFigFillPicker()) {
16611
+ picker = figCreateElement(
16612
+ "fig-fill-picker",
16613
+ {
16614
+ mode: "solid",
16615
+ alpha: this.#alphaEnabled ? null : "false",
16616
+ value: pickerValue,
16617
+ },
16618
+ swatch,
16619
+ );
16620
+ this.replaceChildren(picker);
16621
+ } else {
16622
+ this.replaceChildren(swatch);
16623
+ }
16609
16624
 
16610
- this.#fillPicker = this.querySelector("fig-fill-picker");
16611
- this.#swatch = this.querySelector("fig-swatch");
16612
- this.#teardownListeners();
16625
+ this.#fillPicker = picker;
16626
+ this.#swatch = swatch;
16613
16627
  this.#fillPicker?.addEventListener("input", this.#boundHandleInput);
16614
16628
  this.#fillPicker?.addEventListener("change", this.#boundHandleChange);
16615
16629
  if (!this.#fillPicker) {