@rogieking/figui3 8.2.1 → 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
 
@@ -9841,6 +9852,30 @@ class FigInputGradient extends HTMLElement {
9841
9852
  );
9842
9853
  }
9843
9854
 
9855
+ #activeStopHandle() {
9856
+ if (!this.#track) return null;
9857
+ const selected = this.#track.querySelector(
9858
+ "fig-handle[selected]:not(.fig-input-gradient-ghost)",
9859
+ );
9860
+ if (selected) return selected;
9861
+ const active = document.activeElement;
9862
+ if (!(active instanceof Element) || !this.#track.contains(active)) {
9863
+ return null;
9864
+ }
9865
+ return active.closest("fig-handle:not(.fig-input-gradient-ghost)");
9866
+ }
9867
+
9868
+ #activateStopHandle(handle) {
9869
+ if (!handle || !this.#track) return;
9870
+ this.#track
9871
+ .querySelectorAll("fig-handle:not(.fig-input-gradient-ghost)")
9872
+ .forEach((other) => {
9873
+ if (other !== handle) other.deselect();
9874
+ });
9875
+ handle.select();
9876
+ handle.focus();
9877
+ }
9878
+
9844
9879
  #syncFocusTarget() {
9845
9880
  const disabled =
9846
9881
  this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
@@ -9910,7 +9945,15 @@ class FigInputGradient extends HTMLElement {
9910
9945
 
9911
9946
  #onKeyDown = (e) => {
9912
9947
  const active = document.activeElement;
9913
- if (!(active instanceof Node) || !this.contains(active)) return;
9948
+ const activeIsInside = active instanceof Node && this.contains(active);
9949
+ const selectedHandle = this.#track?.querySelector(
9950
+ "fig-handle[selected]:not(.fig-input-gradient-ghost)",
9951
+ );
9952
+ const focusLostToBody =
9953
+ !active ||
9954
+ active === document.body ||
9955
+ active === document.documentElement;
9956
+ if (!activeIsInside && !(selectedHandle && focusLostToBody)) return;
9914
9957
  const isTyping =
9915
9958
  active &&
9916
9959
  (active.tagName === "INPUT" ||
@@ -9919,9 +9962,7 @@ class FigInputGradient extends HTMLElement {
9919
9962
  if (!this.#track) return;
9920
9963
 
9921
9964
  if (e.key === "Tab" && !isTyping) {
9922
- const selected = this.#track.querySelector(
9923
- "fig-handle[selected]:not(.fig-input-gradient-ghost)",
9924
- );
9965
+ const selected = this.#activeStopHandle();
9925
9966
  if (!selected) return;
9926
9967
  e.preventDefault();
9927
9968
  const handles = [
@@ -9934,14 +9975,12 @@ class FigInputGradient extends HTMLElement {
9934
9975
  ? (curIdx - 1 + handles.length) % handles.length
9935
9976
  : (curIdx + 1) % handles.length;
9936
9977
  selected.deselect();
9937
- handles[next].select();
9978
+ this.#activateStopHandle(handles[next]);
9938
9979
  return;
9939
9980
  }
9940
9981
 
9941
9982
  if ((e.key === "ArrowLeft" || e.key === "ArrowRight") && !isTyping) {
9942
- const selected = this.#track.querySelector(
9943
- "fig-handle[selected]:not(.fig-input-gradient-ghost)",
9944
- );
9983
+ const selected = this.#activeStopHandle();
9945
9984
  if (!selected) return;
9946
9985
  const idx = parseInt(selected.dataset.stopIndex, 10);
9947
9986
  if (isNaN(idx) || !this.#gradient.stops[idx]) return;
@@ -9973,19 +10012,21 @@ class FigInputGradient extends HTMLElement {
9973
10012
  if (e.key !== "Delete" && e.key !== "Backspace") return;
9974
10013
  if (isTyping) return;
9975
10014
  if (this.#gradient.stops.length <= 2) return;
9976
- const selected = this.#track.querySelector(
9977
- "fig-handle[selected]:not(.fig-input-gradient-ghost)",
9978
- );
10015
+ const selected = this.#activeStopHandle();
9979
10016
  if (!selected) return;
9980
10017
  const idx = parseInt(selected.dataset.stopIndex, 10);
9981
10018
  if (isNaN(idx) || !this.#gradient.stops[idx]) return;
9982
10019
  e.preventDefault();
9983
- selected.removeAttribute("selected");
9984
10020
  this.#gradient.stops.splice(idx, 1);
9985
10021
  this.#syncHandles();
9986
10022
  this.#syncSwatch();
9987
10023
  this.#emitInput();
9988
10024
  this.#emitChange();
10025
+ const nextIdx = Math.min(idx, this.#gradient.stops.length - 1);
10026
+ const next = this.#track.querySelectorAll(
10027
+ "fig-handle:not(.fig-input-gradient-ghost)",
10028
+ )[nextIdx];
10029
+ this.#activateStopHandle(next);
9989
10030
  };
9990
10031
 
9991
10032
  #onPickerKeyDown = (e) => {
@@ -10049,11 +10090,6 @@ class FigInputGradient extends HTMLElement {
10049
10090
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
10050
10091
  }
10051
10092
 
10052
- #swatchSizeAttr() {
10053
- const size = this.getAttribute("size");
10054
- return size ? ` size="${size}"` : "";
10055
- }
10056
-
10057
10093
  #syncSwatchSize() {
10058
10094
  if (!this.#swatch) return;
10059
10095
  const size = this.getAttribute("size");
@@ -10061,16 +10097,32 @@ class FigInputGradient extends HTMLElement {
10061
10097
  else this.#swatch.removeAttribute("size");
10062
10098
  }
10063
10099
 
10064
- #buildStopHandles() {
10100
+ #createStopHandles() {
10065
10101
  const disabled =
10066
10102
  this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
10067
- const tipAttr = this.#stopHandleMode === "tip" ? ' tip="color"' : "";
10068
10103
  return this.#gradient.stops
10069
10104
  .map(
10070
10105
  (stop, i) =>
10071
- `<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>`,
10072
- )
10073
- .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
+ );
10074
10126
  }
10075
10127
 
10076
10128
  #ghostHandle = null;
@@ -10081,27 +10133,45 @@ class FigInputGradient extends HTMLElement {
10081
10133
  this.#colorObserver = null;
10082
10134
  const disabled = figBooleanAttribute(this, "disabled");
10083
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
+ });
10084
10142
 
10085
10143
  if (mode === "picker" && hasFigFillPicker()) {
10086
10144
  const gradientValue = JSON.stringify(this.value);
10087
- this.innerHTML = `
10088
- <fig-fill-picker mode="gradient" value='${gradientValue}'${disabled ? " disabled" : ""}>
10089
- <fig-swatch background="${this.#buildGradientCSS()}"${this.#swatchSizeAttr()}${disabled ? " disabled" : ""}></fig-swatch>
10090
- </fig-fill-picker>`;
10091
- 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;
10092
10156
  this.#track = null;
10093
10157
  this.#setupPickerEvents();
10094
10158
  this.#syncFocusTarget();
10095
10159
  return;
10096
10160
  }
10097
10161
 
10098
- this.innerHTML = `
10099
- <fig-swatch background="${this.#buildGradientCSS()}"${this.#swatchSizeAttr()}${disabled ? " disabled" : ""}></fig-swatch>
10100
- ${mode === "true" || mode === "picker" ? `<div class="fig-input-gradient-track">${this.#buildStopHandles()}</div>` : ""}`;
10101
- this.#swatch = this.querySelector("fig-swatch");
10102
- 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;
10103
10173
 
10104
- if (mode === "true" || mode === "picker") {
10174
+ if (editable) {
10105
10175
  this.#setupGhostHandle();
10106
10176
  this.#setupEventListeners();
10107
10177
  }
@@ -10279,7 +10349,7 @@ class FigInputGradient extends HTMLElement {
10279
10349
  "fig-handle:not(.fig-input-gradient-ghost)",
10280
10350
  );
10281
10351
  const newHandle = handles[newIndex];
10282
- if (newHandle) newHandle.click();
10352
+ if (newHandle) this.#activateStopHandle(newHandle);
10283
10353
  });
10284
10354
  };
10285
10355
 
@@ -10315,7 +10385,7 @@ class FigInputGradient extends HTMLElement {
10315
10385
 
10316
10386
  if (handles.length !== stops.length) {
10317
10387
  const ghost = this.#ghostHandle;
10318
- this.#track.innerHTML = this.#buildStopHandles();
10388
+ this.#track.replaceChildren(...this.#createStopHandles());
10319
10389
  if (ghost) this.#track.appendChild(ghost);
10320
10390
  this.#syncHandleMode();
10321
10391
  this.#reobserveHandleColors();
@@ -10446,12 +10516,7 @@ class FigInputGradient extends HTMLElement {
10446
10516
  );
10447
10517
  const newHandle = handles[newIndex];
10448
10518
  if (newHandle) {
10449
- this.#track
10450
- .querySelectorAll("fig-handle:not(.fig-input-gradient-ghost)")
10451
- .forEach((h) => {
10452
- if (h !== newHandle) h.deselect();
10453
- });
10454
- newHandle.select();
10519
+ this.#activateStopHandle(newHandle);
10455
10520
  newHandle.dispatchEvent(
10456
10521
  new PointerEvent("pointerdown", {
10457
10522
  bubbles: true,
@@ -11071,22 +11136,54 @@ class FigComboInput extends HTMLElement {
11071
11136
  const currentValue = this.value;
11072
11137
  const dropdownLabel = this.#dropdownLabel();
11073
11138
 
11074
- const dropdownHTML = this.#usesCustomDropdown
11075
- ? ""
11076
- : `<fig-dropdown type="dropdown" label="${figEscapeAttribute(dropdownLabel)}">${options.map((o) => `<option>${figEscapeAttribute(o.trim())}</option>`).join("")}</fig-dropdown>`;
11077
-
11078
- this.innerHTML = `<div class="input-combo">
11079
- <fig-input-text placeholder="${figEscapeAttribute(placeholder)}" value="${figEscapeAttribute(currentValue)}"></fig-input-text>
11080
- <fig-button type="select" variant="input" icon>
11081
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
11082
- <path d="M5.87868 7.12132L8 9.24264L10.1213 7.12132" stroke="currentColor" stroke-opacity="0.9" stroke-linecap="round"/>
11083
- </svg>
11084
- ${dropdownHTML}
11085
- </fig-button>
11086
- </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
+ );
11087
11184
 
11088
- this.#input = this.querySelector("fig-input-text");
11089
- this.#button = this.querySelector("fig-button");
11185
+ this.#input = input;
11186
+ this.#button = button;
11090
11187
 
11091
11188
  if (this.#usesCustomDropdown && this.#customDropdown && this.#button) {
11092
11189
  if (!this.#customDropdown.hasAttribute("type")) {
@@ -11351,14 +11448,18 @@ class FigSwatch extends HTMLElement {
11351
11448
 
11352
11449
  if (this.#type === "color") {
11353
11450
  const hex = this.#toHex(bg);
11354
- this.innerHTML = `<div></div><input type="color" value="${hex}" />`;
11355
- 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;
11356
11457
  if (!isVar) {
11357
11458
  this.input.addEventListener("input", this.#boundHandleInput);
11358
11459
  }
11359
11460
  this.#syncA11y();
11360
11461
  } else {
11361
- this.innerHTML = "<div></div>";
11462
+ this.replaceChildren(document.createElement("div"));
11362
11463
  this.input = null;
11363
11464
  this.#syncA11y();
11364
11465
  }
@@ -13137,7 +13238,7 @@ class FigInputFile extends HTMLElement {
13137
13238
  !!this.getAttribute("url") ||
13138
13239
  !!this.getAttribute("filename");
13139
13240
 
13140
- this.innerHTML = "";
13241
+ this.replaceChildren();
13141
13242
 
13142
13243
  if (hasFile) {
13143
13244
  const tooltipText = accepts
@@ -13520,10 +13621,10 @@ class FigEasingCurve extends HTMLElement {
13520
13621
  const y = pad + (1 - (pts[i].value - minVal) / range) * s;
13521
13622
  d += (i === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1);
13522
13623
  }
13523
- 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);
13524
13625
  }
13525
13626
 
13526
- static curveIcon(cp1x, cp1y, cp2x, cp2y, size = 24) {
13627
+ static #curveIconPath(cp1x, cp1y, cp2x, cp2y, size = 24) {
13527
13628
  const draw = 12;
13528
13629
  const pad = (size - draw) / 2;
13529
13630
  const samples = 48;
@@ -13569,6 +13670,43 @@ class FigEasingCurve extends HTMLElement {
13569
13670
  const py = toY(points[i].y);
13570
13671
  d += `${i === 0 ? "M" : "L"}${px.toFixed(1)},${py.toFixed(1)}`;
13571
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
+ );
13572
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>`;
13573
13711
  }
13574
13712
 
@@ -13582,7 +13720,7 @@ class FigEasingCurve extends HTMLElement {
13582
13720
  this.classList.toggle("spring-mode", this.#mode === "spring");
13583
13721
  this.classList.toggle("bezier-mode", this.#mode !== "spring");
13584
13722
  this.#syncMetricsFromCSS();
13585
- this.innerHTML = this.#getInnerHTML();
13723
+ this.replaceChildren(...this.#createContent());
13586
13724
  this.#cacheRefs();
13587
13725
  if (this.#svg) {
13588
13726
  this.#syncHandleSizes();
@@ -13593,14 +13731,6 @@ class FigEasingCurve extends HTMLElement {
13593
13731
  this.#setupEvents();
13594
13732
  }
13595
13733
 
13596
- static #escapeAttribute(value) {
13597
- return String(value)
13598
- .replace(/&/g, "&amp;")
13599
- .replace(/"/g, "&quot;")
13600
- .replace(/</g, "&lt;")
13601
- .replace(/>/g, "&gt;");
13602
- }
13603
-
13604
13734
  #canUseFigSelect() {
13605
13735
  return ["fig-select", "fig-select-options", "fig-select-option"].every(
13606
13736
  (tag) => {
@@ -13613,38 +13743,43 @@ class FigEasingCurve extends HTMLElement {
13613
13743
  );
13614
13744
  }
13615
13745
 
13616
- #getDropdownHTML() {
13617
- 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
+ });
13618
13753
  let currentGroup = undefined;
13619
- let groupOpen = false;
13754
+ let parent = dropdown;
13620
13755
  for (const preset of FigEasingCurve.PRESETS) {
13621
13756
  if (!this.#isEditEnabled() && !preset.value && !preset.spring) continue;
13622
13757
  if (preset.group !== currentGroup) {
13623
- if (groupOpen) optionsHTML += "</optgroup>";
13624
- groupOpen = Boolean(preset.group);
13625
- if (groupOpen) {
13626
- optionsHTML += `<optgroup label="${FigEasingCurve.#escapeAttribute(preset.group)}">`;
13627
- }
13628
13758
  currentGroup = preset.group;
13759
+ parent = currentGroup
13760
+ ? figCreateElement("optgroup", { label: currentGroup })
13761
+ : dropdown;
13762
+ if (parent !== dropdown) dropdown.appendChild(parent);
13629
13763
  }
13630
- const name = FigEasingCurve.#escapeAttribute(preset.name);
13631
- optionsHTML += `<option value="${name}">${name}</option>`;
13764
+ parent.appendChild(
13765
+ figCreateElement("option", { value: preset.name }, preset.name),
13766
+ );
13632
13767
  }
13633
- if (groupOpen) optionsHTML += "</optgroup>";
13634
- const value = FigEasingCurve.#escapeAttribute(this.#presetName);
13635
- return `<fig-dropdown class="fig-easing-curve-select" label="Easing preset" value="${value}" full>${optionsHTML}</fig-dropdown>`;
13768
+ return dropdown;
13636
13769
  }
13637
13770
 
13638
- #getSelectHTML() {
13639
- if (!this.#canUseFigSelect()) return this.#getDropdownHTML();
13771
+ #createSelect() {
13772
+ if (!this.#canUseFigSelect()) return this.#createDropdown();
13640
13773
 
13641
- let optionsHTML = "";
13774
+ const options = document.createElement("fig-select-options");
13642
13775
  let currentGroup = undefined;
13643
13776
  for (const p of FigEasingCurve.PRESETS) {
13644
13777
  if (!this.#isEditEnabled() && !p.value && !p.spring) continue;
13645
13778
  if (p.group !== currentGroup) {
13646
13779
  if (p.group) {
13647
- optionsHTML += `<fig-separator label="${FigEasingCurve.#escapeAttribute(p.group)}"></fig-separator>`;
13780
+ options.appendChild(
13781
+ figCreateElement("fig-separator", { label: p.group }),
13782
+ );
13648
13783
  }
13649
13784
  currentGroup = p.group;
13650
13785
  }
@@ -13659,42 +13794,152 @@ class FigEasingCurve extends HTMLElement {
13659
13794
  this.#cp2.x,
13660
13795
  this.#cp2.y,
13661
13796
  ];
13662
- icon = FigEasingCurve.curveIcon(...v);
13663
- }
13664
- const name = FigEasingCurve.#escapeAttribute(p.name);
13665
- 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
+ );
13666
13812
  }
13667
- const value = FigEasingCurve.#escapeAttribute(this.#presetName);
13668
- 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
+ );
13669
13839
  }
13670
13840
 
13671
- #getInnerHTML() {
13841
+ #createContent() {
13672
13842
  const size = 200;
13673
- const select = this.#getSelectHTML();
13674
- if (!this.#isEditEnabled()) return select;
13675
- 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
+ ];
13676
13859
 
13677
13860
  if (this.#mode === "spring") {
13678
13861
  const targetY = 40;
13679
- return `${select}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
13680
- <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
13681
- <line class="fig-easing-curve-target" x1="0" y1="${targetY}" x2="${size}" y2="${targetY}"/>
13682
- <path class="fig-easing-curve-path"/>
13683
- <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>
13684
- <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>
13685
- </svg></div>${valueInput}`;
13686
- }
13687
-
13688
- return `${select}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
13689
- <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
13690
- <line class="fig-easing-curve-boundary" data-boundary="top" x1="0" y1="0" x2="${size}" y2="0"/>
13691
- <line class="fig-easing-curve-boundary" data-boundary="bottom" x1="0" y1="${size}" x2="${size}" y2="${size}"/>
13692
- <path class="fig-easing-curve-path"/>
13693
- <line class="fig-easing-curve-arm" data-arm="1"/>
13694
- <line class="fig-easing-curve-arm" data-arm="2"/>
13695
- <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>
13696
- <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>
13697
- </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
+ ];
13698
13943
  }
13699
13944
 
13700
13945
  #readCssNumber(name, fallback) {
@@ -14029,7 +14274,7 @@ class FigEasingCurve extends HTMLElement {
14029
14274
  for (const option of this.#select.querySelectorAll("fig-select-option")) {
14030
14275
  if (option.value === optionValue) {
14031
14276
  const prepend = option.querySelector(':scope > [slot="prepend"]');
14032
- if (prepend) prepend.innerHTML = icon;
14277
+ if (prepend) prepend.replaceChildren(icon.cloneNode(true));
14033
14278
  }
14034
14279
  }
14035
14280
  }
@@ -14037,7 +14282,7 @@ class FigEasingCurve extends HTMLElement {
14037
14282
  #refreshCustomPresetIcons() {
14038
14283
  if (!this.#select) return;
14039
14284
  if (!this.#isEditEnabled()) return;
14040
- const bezierIcon = FigEasingCurve.curveIcon(
14285
+ const bezierIcon = FigEasingCurve.#createCurveIcon(
14041
14286
  this.#cp1.x,
14042
14287
  this.#cp1.y,
14043
14288
  this.#cp2.x,
@@ -14627,34 +14872,44 @@ class Fig3DRotate extends HTMLElement {
14627
14872
  rotateY: this.#ry,
14628
14873
  rotateZ: this.#rz,
14629
14874
  };
14630
- const fieldsHTML = this.#fields
14631
- .map(
14632
- (axis) =>
14633
- `<fig-input-number
14634
- name="${axis}"
14635
- step="1"
14636
- precision="1"
14637
- value="${axisValues[axis]}"
14638
- units="°">
14639
- <span slot="prepend">${axisLabels[axis]}</span>
14640
- </fig-input-number>`,
14641
- )
14642
- .join("");
14643
-
14644
- this.innerHTML = `<div class="fig-3d-rotate-container" tabindex="0">
14645
- <div class="fig-3d-rotate-scene">
14646
- <div class="fig-3d-rotate-cube">
14647
- <div class="fig-3d-rotate-face front"></div>
14648
- <div class="fig-3d-rotate-face back"></div>
14649
- <div class="fig-3d-rotate-face right"></div>
14650
- <div class="fig-3d-rotate-face left"></div>
14651
- <div class="fig-3d-rotate-face top"></div>
14652
- <div class="fig-3d-rotate-face bottom"></div>
14653
- </div>
14654
- </div>
14655
- </div>${fieldsHTML}`;
14656
- this.#container = this.querySelector(".fig-3d-rotate-container");
14657
- 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;
14658
14913
  this.#wireFieldInputs();
14659
14914
  this.#updateCube();
14660
14915
  this.#setupEvents();
@@ -14957,31 +15212,65 @@ class FigOriginGrid extends HTMLElement {
14957
15212
  const cells = Array.from({ length: 9 }, (_, index) => {
14958
15213
  const col = index % 3;
14959
15214
  const row = Math.floor(index / 3);
14960
- return `<span class="origin-grid-cell" data-col="${col}" data-row="${row}">
14961
- <span class="origin-grid-dot"></span>
14962
- </span>`;
14963
- }).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
+ });
14964
15225
 
14965
15226
  const xValue = this.#x.toFixed(this.#precision);
14966
15227
  const yValue = this.#y.toFixed(this.#precision);
14967
- const fieldsMarkup = this.#fieldsEnabled
14968
- ? `<div class="origin-values">
14969
- <fig-input-number name="x" value="${xValue}" step="1" units="%"><span slot="prepend">X</span></fig-input-number>
14970
- <fig-input-number name="y" value="${yValue}" step="1" units="%"><span slot="prepend">Y</span></fig-input-number>
14971
- </div>`
14972
- : "";
14973
-
14974
- this.innerHTML = `<div class="fig-origin-grid-surface">
14975
- <div class="origin-grid" aria-label="Transform origin grid">
14976
- <div class="origin-grid-cells">${cells}</div>
14977
- <fig-handle></fig-handle>
14978
- </div>
14979
- </div>
14980
- ${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
+ }
14981
15269
 
14982
- this.#grid = this.querySelector(".origin-grid");
14983
- this.#cells = Array.from(this.querySelectorAll(".origin-grid-cell"));
14984
- this.#handle = this.querySelector("fig-handle");
15270
+ this.replaceChildren(...rendered);
15271
+ this.#grid = grid;
15272
+ this.#cells = cells;
15273
+ this.#handle = handle;
14985
15274
  this.#xInput = this.querySelector('fig-input-number[name="x"]');
14986
15275
  this.#yInput = this.querySelector('fig-input-number[name="y"]');
14987
15276
  this.#syncHandlePosition();
@@ -15438,7 +15727,100 @@ class FigInputJoystick extends HTMLElement {
15438
15727
  }
15439
15728
 
15440
15729
  #render() {
15441
- 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);
15442
15824
  }
15443
15825
 
15444
15826
  #getAxisLabels() {
@@ -15467,63 +15849,6 @@ class FigInputJoystick extends HTMLElement {
15467
15849
  return { left: "", right: "", top: "", bottom: "", leftNoRotate: false };
15468
15850
  }
15469
15851
 
15470
- #getInnerHTML() {
15471
- const axisLabels = this.#getAxisLabels();
15472
- const labelsMarkup = [
15473
- axisLabels.left
15474
- ? `<label class="fig-joystick-axis-label left${axisLabels.leftNoRotate ? " no-rotate" : ""}" aria-hidden="true">${axisLabels.left}</label>`
15475
- : "",
15476
- axisLabels.right
15477
- ? `<label class="fig-joystick-axis-label right" aria-hidden="true">${axisLabels.right}</label>`
15478
- : "",
15479
- axisLabels.top
15480
- ? `<label class="fig-joystick-axis-label top" aria-hidden="true">${axisLabels.top}</label>`
15481
- : "",
15482
- axisLabels.bottom
15483
- ? `<label class="fig-joystick-axis-label bottom" aria-hidden="true">${axisLabels.bottom}</label>`
15484
- : "",
15485
- ].join("");
15486
-
15487
- return `
15488
- <div class="fig-input-joystick-plane-container">
15489
- ${labelsMarkup}
15490
- <div class="fig-input-joystick-plane">
15491
- <div class="fig-input-joystick-guides"></div>
15492
- <fig-handle drag drag-surface=".fig-input-joystick-plane" drag-axes="x,y" drag-snapping="modifier"></fig-handle>
15493
- </div>
15494
- <fig-tooltip text="Reset">
15495
- <fig-button variant="ghost" icon="true" class="fig-joystick-reset" aria-label="Reset to default">
15496
- <fig-icon name="reset" size="small"></fig-icon>
15497
- </fig-button>
15498
- </fig-tooltip>
15499
- </div>
15500
- ${
15501
- this.#fieldsEnabled
15502
- ? `<div class="joystick-values">
15503
- <fig-input-number
15504
- name="x"
15505
- step="1"
15506
- value="${(this.position.x * 100).toFixed(this.precision)}"
15507
- min="0"
15508
- max="100"
15509
- units="%">
15510
- <span slot="prepend">X</span>
15511
- </fig-input-number>
15512
- <fig-input-number
15513
- name="y"
15514
- step="1"
15515
- min="0"
15516
- max="100"
15517
- value="${(this.position.y * 100).toFixed(this.precision)}"
15518
- units="%">
15519
- <span slot="prepend">Y</span>
15520
- </fig-input-number>
15521
- </div>`
15522
- : ""
15523
- }
15524
- `;
15525
- }
15526
-
15527
15852
  #setupListeners() {
15528
15853
  this.plane = this.querySelector(".fig-input-joystick-plane");
15529
15854
  this.cursor = this.querySelector("fig-handle");
@@ -16053,314 +16378,6 @@ class FigPreview extends HTMLElement {
16053
16378
  }
16054
16379
  figDefineElement("fig-preview", FigPreview);
16055
16380
 
16056
- /**
16057
- * Compact swatch previewing gradient color-space interpolation.
16058
- * Polar: CSS conic-gradient masked (SVG data-URL, round linecaps) to an arc.
16059
- * Non-polar: CSS linear-gradient masked to a horizontal round-capped stroke.
16060
- * Accepts the same `value` shape as fig-input-gradient / fig-fill-picker.
16061
- *
16062
- * @element fig-interpolation-swatch
16063
- * @attr {string} value - JSON `{ type: "gradient", gradient: { … } }` (or a bare gradient object)
16064
- * @attr {string} size - `small` (default, 24px) or `large` (32px)
16065
- */
16066
- class FigInterpolationSwatch extends HTMLElement {
16067
- static #HUE_SPACES = new Set(["oklch", "hsl"]);
16068
- static #CX = 10;
16069
- static #CY = 10;
16070
- static #R = 8;
16071
- static #STROKE = 3;
16072
- // Polar endpoints ≈ 10 o'clock → 2 o'clock (SVG deg: 0 = east, CW).
16073
- // CSS conic `from` is 0 = north; convert with +90.
16074
- static #START_DEG = 210;
16075
- static #DEFAULT_GRADIENT = {
16076
- type: "linear",
16077
- angle: 135,
16078
- interpolationSpace: "srgb",
16079
- hueInterpolation: "shorter",
16080
- stops: [
16081
- { color: "#FF0000", position: 0, opacity: 100 },
16082
- { color: "#4F9EFF", position: 100, opacity: 100 },
16083
- ],
16084
- };
16085
-
16086
- #rendered = false;
16087
- #svgEl = null;
16088
- #fillEl = null;
16089
- #gradient = { ...FigInterpolationSwatch.#DEFAULT_GRADIENT };
16090
-
16091
- static get observedAttributes() {
16092
- return ["value"];
16093
- }
16094
-
16095
- get value() {
16096
- return {
16097
- type: "gradient",
16098
- gradient: { ...this.#gradient },
16099
- };
16100
- }
16101
-
16102
- set value(val) {
16103
- if (val == null || val === "") {
16104
- this.removeAttribute("value");
16105
- return;
16106
- }
16107
- if (typeof val === "string") {
16108
- this.setAttribute("value", val);
16109
- return;
16110
- }
16111
- this.setAttribute("value", JSON.stringify(val));
16112
- }
16113
-
16114
- connectedCallback() {
16115
- this.#ensureA11y();
16116
- this.#parseValue();
16117
- this.#render();
16118
- this.#updatePreview();
16119
- }
16120
-
16121
- attributeChangedCallback(name, oldValue, newValue) {
16122
- if (oldValue === newValue) return;
16123
- if (name !== "value") return;
16124
- this.#parseValue();
16125
- if (this.#rendered) this.#updatePreview();
16126
- }
16127
-
16128
- #ensureA11y() {
16129
- const named =
16130
- this.hasAttribute("aria-label") || this.hasAttribute("aria-labelledby");
16131
- if (!named && !this.hasAttribute("aria-hidden")) {
16132
- this.setAttribute("aria-hidden", "true");
16133
- }
16134
- }
16135
-
16136
- #parseValue() {
16137
- const valueAttr = this.getAttribute("value");
16138
- if (!valueAttr) {
16139
- this.#gradient = {
16140
- ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
16141
- stops: FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({
16142
- ...s,
16143
- })),
16144
- };
16145
- return;
16146
- }
16147
- try {
16148
- const parsed = JSON.parse(valueAttr);
16149
- const gradient = parsed?.type === "gradient" && parsed.gradient
16150
- ? parsed.gradient
16151
- : parsed?.gradient
16152
- ? parsed.gradient
16153
- : parsed;
16154
- if (!gradient || typeof gradient !== "object") return;
16155
- this.#gradient = this.#normalizeGradient({
16156
- ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
16157
- ...gradient,
16158
- });
16159
- } catch {
16160
- // Keep current/default gradient on invalid JSON.
16161
- }
16162
- }
16163
-
16164
- #normalizeGradient(gradient) {
16165
- const next = { ...(gradient ?? {}) };
16166
- const interpolationSpace = String(
16167
- next.interpolationSpace ?? "srgb",
16168
- ).toLowerCase();
16169
- const hueInterpolation = String(
16170
- next.hueInterpolation ?? "shorter",
16171
- ).toLowerCase();
16172
- const stops = Array.isArray(next.stops)
16173
- ? next.stops.map((stop) => ({
16174
- color: String(stop?.color || "#D9D9D9").replace(
16175
- /^(#(?:[0-9a-f]{6})).*/i,
16176
- "$1",
16177
- ),
16178
- position: stop?.position ?? 0,
16179
- opacity: stop?.opacity ?? 100,
16180
- }))
16181
- : FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({ ...s }));
16182
- if (stops.length < 2) {
16183
- return {
16184
- ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
16185
- stops: FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({
16186
- ...s,
16187
- })),
16188
- };
16189
- }
16190
- return {
16191
- type: ["linear", "radial", "angular"].includes(next.type)
16192
- ? next.type
16193
- : "linear",
16194
- angle: Number.isFinite(Number(next.angle)) ? Number(next.angle) : 135,
16195
- interpolationSpace,
16196
- hueInterpolation,
16197
- stops,
16198
- };
16199
- }
16200
-
16201
- #isPolar() {
16202
- return FigInterpolationSwatch.#HUE_SPACES.has(
16203
- this.#gradient.interpolationSpace || "srgb",
16204
- );
16205
- }
16206
-
16207
- #hueForColor(color) {
16208
- const { r, g, b } = figHexToRGB(color);
16209
- if (this.#gradient.interpolationSpace === "hsl") {
16210
- return figRgbToHsl(r, g, b).h;
16211
- }
16212
- const lab = figRGBToOklab(r, g, b);
16213
- const hue = figOklabToOklch(lab.l, lab.a, lab.b).h;
16214
- return ((hue % 360) + 360) % 360;
16215
- }
16216
-
16217
- #polarArcGeometry() {
16218
- const stops = this.#sortedStops();
16219
- const startHue = this.#hueForColor(stops[0]?.color || "#FF0000");
16220
- const endHue = this.#hueForColor(
16221
- stops[stops.length - 1]?.color || "#4F9EFF",
16222
- );
16223
- const startDeg = FigInterpolationSwatch.#START_DEG - startHue;
16224
- const endDeg = FigInterpolationSwatch.#START_DEG - endHue;
16225
- const clockwiseSweep = ((endDeg - startDeg) % 360 + 360) % 360;
16226
- const counterclockwiseSweep =
16227
- clockwiseSweep === 0 ? 0 : clockwiseSweep - 360;
16228
- const method = this.#gradient.hueInterpolation || "shorter";
16229
-
16230
- let sweepDeg;
16231
- if (method === "increasing") {
16232
- sweepDeg = counterclockwiseSweep;
16233
- } else if (method === "decreasing") {
16234
- sweepDeg = clockwiseSweep;
16235
- } else if (method === "longer") {
16236
- sweepDeg =
16237
- clockwiseSweep < 180 ? counterclockwiseSweep : clockwiseSweep;
16238
- } else {
16239
- sweepDeg =
16240
- clockwiseSweep <= 180 ? clockwiseSweep : counterclockwiseSweep;
16241
- }
16242
-
16243
- // A round cap extends beyond the path endpoint. Inset the centerline so
16244
- // the visible cap edges, rather than their centers, land on the hues.
16245
- const direction = Math.sign(sweepDeg);
16246
- const capAngle =
16247
- (Math.asin(FigInterpolationSwatch.#STROKE / 2 / FigInterpolationSwatch.#R) *
16248
- 180) /
16249
- Math.PI;
16250
- const inset = Math.min(
16251
- capAngle,
16252
- Math.max(0, (Math.abs(sweepDeg) - 0.01) / 2),
16253
- );
16254
- return {
16255
- startDeg: startDeg + direction * inset,
16256
- sweepDeg: sweepDeg - direction * inset * 2,
16257
- };
16258
- }
16259
-
16260
- #pointOnCircle(deg) {
16261
- const rad = (deg * Math.PI) / 180;
16262
- return {
16263
- x: FigInterpolationSwatch.#CX + FigInterpolationSwatch.#R * Math.cos(rad),
16264
- y: FigInterpolationSwatch.#CY + FigInterpolationSwatch.#R * Math.sin(rad),
16265
- };
16266
- }
16267
-
16268
- #arcMaskPath(startDeg, sweepDeg) {
16269
- const endDeg = startDeg + sweepDeg;
16270
- const start = this.#pointOnCircle(startDeg);
16271
- const end = this.#pointOnCircle(endDeg);
16272
- const largeArc = Math.abs(sweepDeg) > 180 ? 1 : 0;
16273
- const sweepFlag = sweepDeg >= 0 ? 1 : 0;
16274
- return `M ${start.x} ${start.y} A ${FigInterpolationSwatch.#R} ${FigInterpolationSwatch.#R} 0 ${largeArc} ${sweepFlag} ${end.x} ${end.y}`;
16275
- }
16276
-
16277
- #lineMaskPath() {
16278
- const start = this.#pointOnCircle(180);
16279
- const end = this.#pointOnCircle(0);
16280
- return `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
16281
- }
16282
-
16283
- #maskImageForPath(d) {
16284
- const stroke = FigInterpolationSwatch.#STROKE;
16285
- const svg =
16286
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">` +
16287
- `<path d="${d}" fill="none" stroke="white" stroke-width="${stroke}" ` +
16288
- `stroke-linecap="round" stroke-linejoin="round"/>` +
16289
- `</svg>`;
16290
- return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
16291
- }
16292
-
16293
- #sortedStops() {
16294
- return [...this.#gradient.stops].sort(
16295
- (a, b) => (a.position ?? 0) - (b.position ?? 0),
16296
- );
16297
- }
16298
-
16299
- #cssInterpolationClause() {
16300
- const space = this.#gradient.interpolationSpace || "srgb";
16301
- if (space === "srgb") return "";
16302
- if (FigInterpolationSwatch.#HUE_SPACES.has(space)) {
16303
- return ` in ${space} ${this.#gradient.hueInterpolation || "shorter"} hue`;
16304
- }
16305
- return ` in ${space}`;
16306
- }
16307
-
16308
- #previewBackground() {
16309
- const stops = this.#sortedStops();
16310
- const clause = this.#cssInterpolationClause();
16311
- if (this.#isPolar()) {
16312
- // Fixed hue wheel. The mask maps gradient endpoint hues onto this wheel.
16313
- const cssFrom = (FigInterpolationSwatch.#START_DEG + 90) % 360;
16314
- const space = this.#gradient.interpolationSpace;
16315
- const wheelColor = (hue) =>
16316
- space === "oklch"
16317
- ? `oklch(65% 0.25 ${hue})`
16318
- : `hsl(${hue} 100% 50%)`;
16319
- const wheelStops = [0, 300, 240, 180, 120, 60, 0]
16320
- .map(wheelColor)
16321
- .join(", ");
16322
- return `conic-gradient(from ${cssFrom}deg in ${space} decreasing hue, ${wheelStops})`;
16323
- }
16324
- const stopList = stops
16325
- .map((s) => `${s.color} ${s.position ?? 0}%`)
16326
- .join(", ");
16327
- return `linear-gradient(90deg${clause}, ${stopList})`;
16328
- }
16329
-
16330
- #render() {
16331
- if (this.#rendered) return;
16332
- const stroke = FigInterpolationSwatch.#STROKE;
16333
- this.innerHTML = `
16334
- <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">
16335
- <circle class="fig-interpolation-swatch-rim" cx="10" cy="10" r="8" fill="none" stroke="currentColor" stroke-width="${stroke}"/>
16336
- </svg>
16337
- <div class="fig-interpolation-swatch-fill" aria-hidden="true"></div>
16338
- `;
16339
- this.#svgEl = this.querySelector(".fig-interpolation-swatch-svg");
16340
- this.#fillEl = this.querySelector(".fig-interpolation-swatch-fill");
16341
- this.#rendered = true;
16342
- }
16343
-
16344
- #updatePreview() {
16345
- if (!this.#fillEl) return;
16346
-
16347
- const polar = this.#isPolar();
16348
- if (this.#svgEl) this.#svgEl.style.display = polar ? "" : "none";
16349
-
16350
- const d = polar
16351
- ? (() => {
16352
- const { startDeg, sweepDeg } = this.#polarArcGeometry();
16353
- return this.#arcMaskPath(startDeg, sweepDeg);
16354
- })()
16355
- : this.#lineMaskPath();
16356
- const mask = this.#maskImageForPath(d);
16357
- this.#fillEl.style.setProperty("-webkit-mask-image", mask);
16358
- this.#fillEl.style.maskImage = mask;
16359
- this.#fillEl.style.background = this.#previewBackground();
16360
- }
16361
- }
16362
- figDefineElement("fig-interpolation-swatch", FigInterpolationSwatch);
16363
-
16364
16381
  /** @type {Record<string, string | { medium: string, small: string }>} */
16365
16382
  const FIG_ICON_TOKENS = {
16366
16383
  chevron: { medium: "--icon-24-chevron", small: "--icon-16-chevron" },
@@ -16550,11 +16567,22 @@ class FigColorTip extends HTMLElement {
16550
16567
  }
16551
16568
 
16552
16569
  #render() {
16570
+ this.#teardownListeners();
16553
16571
  const mode = this.#controlMode;
16554
16572
  if (mode === "add" || mode === "remove") {
16555
16573
  const iconName = mode === "add" ? "add" : "minus";
16556
16574
  const label = this.getAttribute("aria-label") || (mode === "add" ? "Add color stop" : "Remove color stop");
16557
- 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
+ );
16558
16586
  this.#fillPicker = null;
16559
16587
  this.#swatch = null;
16560
16588
  this.addEventListener("click", this.#handleControlClick);
@@ -16566,7 +16594,6 @@ class FigColorTip extends HTMLElement {
16566
16594
  const rawValue = (this.getAttribute("value") || "").trim();
16567
16595
  const color = this.#normalizeColor(rawValue);
16568
16596
  const alpha = this.#extractAlpha(rawValue);
16569
- const alphaAttr = this.#alphaEnabled ? "" : 'alpha="false"';
16570
16597
  const pickerValue =
16571
16598
  alpha < 1
16572
16599
  ? JSON.stringify({
@@ -16575,16 +16602,28 @@ class FigColorTip extends HTMLElement {
16575
16602
  opacity: Math.round(alpha * 100),
16576
16603
  })
16577
16604
  : JSON.stringify({ type: "solid", color });
16578
- const swatchAlphaAttr = alpha < 1 ? ` alpha="${alpha}"` : "";
16579
- this.innerHTML = hasFigFillPicker()
16580
- ? `<fig-fill-picker mode="solid" ${alphaAttr} value='${pickerValue}'>
16581
- <fig-swatch background="${color}"${swatchAlphaAttr}></fig-swatch>
16582
- </fig-fill-picker>`
16583
- : `<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
+ }
16584
16624
 
16585
- this.#fillPicker = this.querySelector("fig-fill-picker");
16586
- this.#swatch = this.querySelector("fig-swatch");
16587
- this.#teardownListeners();
16625
+ this.#fillPicker = picker;
16626
+ this.#swatch = swatch;
16588
16627
  this.#fillPicker?.addEventListener("input", this.#boundHandleInput);
16589
16628
  this.#fillPicker?.addEventListener("change", this.#boundHandleChange);
16590
16629
  if (!this.#fillPicker) {
@@ -17795,15 +17834,14 @@ class FigHandle extends HTMLElement {
17795
17834
  this.#didDrag = false;
17796
17835
  return;
17797
17836
  }
17837
+ this.select();
17798
17838
  if (
17799
17839
  this.getAttribute("type") === "color" &&
17800
17840
  this.#canOpenColorPicker &&
17801
17841
  !this.#tipMode
17802
17842
  ) {
17803
17843
  this.#openDirectColorPicker();
17804
- return;
17805
17844
  }
17806
- this.select();
17807
17845
  };
17808
17846
 
17809
17847
  #handleDeselect = (e) => {
@@ -17985,6 +18023,8 @@ class FigHandle extends HTMLElement {
17985
18023
  #onPointerDown(e) {
17986
18024
  if (!this.#dragEnabled || figBooleanAttribute(this, "disabled")) return;
17987
18025
  e.preventDefault();
18026
+ this.focus();
18027
+ this.select();
17988
18028
  const container = this.#getContainer();
17989
18029
  if (!container) return;
17990
18030
 
@@ -18384,17 +18424,19 @@ class FigHandle extends HTMLElement {
18384
18424
  e.stopPropagation();
18385
18425
  const detail = this.#detailFromNativeColor(e.target.value);
18386
18426
  this.setAttribute("color", this.#colorWithOpacity(detail.color, detail.opacity));
18387
- this.removeAttribute("selected");
18388
18427
  this.dispatchEvent(
18389
18428
  new CustomEvent("change", {
18390
18429
  bubbles: true,
18391
18430
  detail,
18392
18431
  }),
18393
18432
  );
18433
+ if (this.isConnected) this.focus();
18394
18434
  };
18395
18435
 
18396
18436
  #handleDirectColorPickerClose = () => {
18397
- this.removeAttribute("selected");
18437
+ if (!this.isConnected) return;
18438
+ this.setAttribute("selected", "");
18439
+ this.focus();
18398
18440
  };
18399
18441
 
18400
18442
  #handleColorTipInput = (e) => {
@@ -18558,6 +18600,19 @@ class FigSeparator extends HTMLElement {
18558
18600
  }
18559
18601
  figDefineElement("fig-separator", FigSeparator);
18560
18602
 
18603
+ // Backwards-compatible alias. Custom element constructors cannot be registered
18604
+ // under multiple tag names, so the legacy tag uses an otherwise empty subclass.
18605
+ class FigMenuSeparator extends FigSeparator {}
18606
+ figDefineElement("fig-menu-separator", FigMenuSeparator);
18607
+
18608
+ const FIG_MENU_CHILD_SELECTOR =
18609
+ ":scope > fig-menu-item, :scope > fig-separator, :scope > fig-menu-separator";
18610
+ const FIG_MENU_CHILD_TAGS = new Set([
18611
+ "FIG-MENU-ITEM",
18612
+ "FIG-SEPARATOR",
18613
+ "FIG-MENU-SEPARATOR",
18614
+ ]);
18615
+
18561
18616
  class FigMenu extends HTMLElement {
18562
18617
  #popup = null;
18563
18618
  #panel = null;
@@ -18634,9 +18689,7 @@ class FigMenu extends HTMLElement {
18634
18689
  if (this.#popup) {
18635
18690
  this.#popup.removeEventListener("close", this.#boundPopupClose);
18636
18691
  const items = Array.from(
18637
- this.#panel?.querySelectorAll(
18638
- ":scope > fig-menu-item, :scope > fig-separator",
18639
- ) ?? [],
18692
+ this.#panel?.querySelectorAll(FIG_MENU_CHILD_SELECTOR) ?? [],
18640
18693
  );
18641
18694
  for (const item of items) this.insertBefore(item, this.#popup);
18642
18695
  this.#popup.remove();
@@ -18678,7 +18731,9 @@ class FigMenu extends HTMLElement {
18678
18731
  #detectTrigger() {
18679
18732
  this.#trigger =
18680
18733
  this.querySelector("[fig-menu-trigger]") ||
18681
- this.querySelector(":scope > :not(fig-menu-item):not(fig-separator)");
18734
+ this.querySelector(
18735
+ ":scope > :not(fig-menu-item):not(fig-separator):not(fig-menu-separator)",
18736
+ );
18682
18737
  }
18683
18738
 
18684
18739
  #usesContextMenuTrigger() {
@@ -18717,9 +18772,7 @@ class FigMenu extends HTMLElement {
18717
18772
 
18718
18773
  #moveItemsToPopup() {
18719
18774
  if (!this.#panel) return;
18720
- const items = Array.from(this.querySelectorAll(
18721
- ":scope > fig-menu-item, :scope > fig-separator"
18722
- ));
18775
+ const items = Array.from(this.querySelectorAll(FIG_MENU_CHILD_SELECTOR));
18723
18776
  for (const item of items) {
18724
18777
  this.#panel.appendChild(item);
18725
18778
  }
@@ -18758,8 +18811,7 @@ class FigMenu extends HTMLElement {
18758
18811
  for (const node of mutation.addedNodes) {
18759
18812
  if (node.nodeType !== 1 || node === this.#popup) continue;
18760
18813
  if (
18761
- (node.tagName === "FIG-MENU-ITEM" ||
18762
- node.tagName === "FIG-SEPARATOR") &&
18814
+ FIG_MENU_CHILD_TAGS.has(node.tagName) &&
18763
18815
  node.parentElement === this
18764
18816
  ) {
18765
18817
  this.#panel?.appendChild(node);