@rogieking/figui3 3.9.3 → 3.13.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.
Files changed (5) hide show
  1. package/README.md +30 -1
  2. package/components.css +600 -299
  3. package/dist/fig.js +133 -76
  4. package/fig.js +1771 -276
  5. package/package.json +1 -1
package/fig.js CHANGED
@@ -609,6 +609,7 @@ class FigTooltip extends HTMLElement {
609
609
  }
610
610
 
611
611
  setupEventListeners() {
612
+ if (this.action === "manual") return;
612
613
  if (this.action === "hover") {
613
614
  if (!this.isTouchDevice()) {
614
615
  this.addEventListener("pointerenter", this.showDelayedPopup.bind(this));
@@ -687,26 +688,34 @@ class FigTooltip extends HTMLElement {
687
688
  const popupRect = this.popup.getBoundingClientRect();
688
689
  const offset = this.getOffset();
689
690
 
691
+ const container = this.popup.parentElement;
692
+ const containerRect =
693
+ container && container !== document.body
694
+ ? container.getBoundingClientRect()
695
+ : { left: 0, top: 0 };
696
+
690
697
  // Position the tooltip above the element
691
- let top = rect.top - popupRect.height - offset.top;
692
- let left = rect.left + (rect.width - popupRect.width) / 2;
698
+ let top = rect.top - popupRect.height - offset.top - containerRect.top;
699
+ let left =
700
+ rect.left + (rect.width - popupRect.width) / 2 - containerRect.left;
693
701
  this.popup.setAttribute("position", "top");
694
702
 
695
703
  // Adjust if tooltip would go off-screen
696
- if (top < 0) {
704
+ if (top + containerRect.top < 0) {
697
705
  this.popup.setAttribute("position", "bottom");
698
- top = rect.bottom + offset.bottom; // Position below instead
706
+ top = rect.bottom + offset.bottom - containerRect.top;
699
707
  }
700
- if (left < offset.left) {
701
- left = offset.left;
702
- } else if (left + popupRect.width > window.innerWidth - offset.right) {
703
- left = window.innerWidth - popupRect.width - offset.right;
708
+ const absLeft = left + containerRect.left;
709
+ if (absLeft < offset.left) {
710
+ left = offset.left - containerRect.left;
711
+ } else if (absLeft + popupRect.width > window.innerWidth - offset.right) {
712
+ left =
713
+ window.innerWidth - popupRect.width - offset.right - containerRect.left;
704
714
  }
705
715
 
706
716
  // Calculate the center of the target element relative to the tooltip
707
- const targetCenter = rect.left + rect.width / 2;
708
- const tooltipLeft = left;
709
- const beakOffset = targetCenter - tooltipLeft;
717
+ const targetCenter = rect.left - containerRect.left + rect.width / 2;
718
+ const beakOffset = targetCenter - left;
710
719
 
711
720
  // Set the beak offset as a CSS custom property
712
721
  this.popup.style.setProperty("--beak-offset", `${beakOffset}px`);
@@ -1438,6 +1447,13 @@ class FigPopup extends HTMLDialogElement {
1438
1447
  const val = this.getAttribute("autoresize");
1439
1448
  return val === null || val !== "false";
1440
1449
  }
1450
+ set autoresize(value) {
1451
+ if (value || value === "") {
1452
+ this.setAttribute("autoresize", value === true ? "" : value);
1453
+ } else {
1454
+ this.removeAttribute("autoresize");
1455
+ }
1456
+ }
1441
1457
 
1442
1458
  setupObservers() {
1443
1459
  this.teardownObservers();
@@ -2600,7 +2616,8 @@ class FigSegmentedControl extends HTMLElement {
2600
2616
 
2601
2617
  #syncIndicator({ forceInstant = false } = {}) {
2602
2618
  const isDisabled =
2603
- this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
2619
+ this.hasAttribute("disabled") &&
2620
+ this.getAttribute("disabled") !== "false";
2604
2621
  const isAnimated = this.#isAnimatedEnabled();
2605
2622
  const activeSegment =
2606
2623
  this.#selectedSegment && this.contains(this.#selectedSegment)
@@ -4248,7 +4265,7 @@ class FigFieldSlider extends HTMLElement {
4248
4265
  this.#label.remove();
4249
4266
  }
4250
4267
  } else {
4251
- this.#label.textContent = hasLabelAttr ? rawLabel ?? "" : "Label";
4268
+ this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
4252
4269
  if (this.#label.parentElement !== this.#field) {
4253
4270
  this.#field.prepend(this.#label);
4254
4271
  }
@@ -4401,7 +4418,7 @@ customElements.define("fig-field-slider", FigFieldSlider);
4401
4418
  class FigInputColor extends HTMLElement {
4402
4419
  rgba;
4403
4420
  hex;
4404
- alpha = 100;
4421
+ #alphaPercent = 100;
4405
4422
  #swatch;
4406
4423
  #fillPicker;
4407
4424
  #textInput;
@@ -4413,6 +4430,20 @@ class FigInputColor extends HTMLElement {
4413
4430
  get picker() {
4414
4431
  return this.getAttribute("picker") || "native";
4415
4432
  }
4433
+ set picker(value) {
4434
+ this.setAttribute("picker", value);
4435
+ }
4436
+
4437
+ get alpha() {
4438
+ return this.getAttribute("alpha");
4439
+ }
4440
+ set alpha(value) {
4441
+ if (value === null || value === undefined || value === false) {
4442
+ this.removeAttribute("alpha");
4443
+ } else {
4444
+ this.setAttribute("alpha", String(value));
4445
+ }
4446
+ }
4416
4447
 
4417
4448
  #buildFillPickerAttrs() {
4418
4449
  const attrs = {};
@@ -4431,6 +4462,16 @@ class FigInputColor extends HTMLElement {
4431
4462
  }
4432
4463
 
4433
4464
  connectedCallback() {
4465
+ if (this.#renderRAF) cancelAnimationFrame(this.#renderRAF);
4466
+ this.#renderRAF = requestAnimationFrame(() => {
4467
+ this.#renderRAF = null;
4468
+ this.#buildUI();
4469
+ });
4470
+ }
4471
+
4472
+ #renderRAF = null;
4473
+
4474
+ #buildUI() {
4434
4475
  this.#setValues(this.getAttribute("value"));
4435
4476
 
4436
4477
  const useFigmaPicker = this.picker === "figma";
@@ -4440,7 +4481,6 @@ class FigInputColor extends HTMLElement {
4440
4481
 
4441
4482
  let html = ``;
4442
4483
  if (this.getAttribute("text")) {
4443
- // Display without # prefix
4444
4484
  let label = `<fig-input-text
4445
4485
  type="text"
4446
4486
  placeholder="000000"
@@ -4452,7 +4492,7 @@ class FigInputColor extends HTMLElement {
4452
4492
  placeholder="##"
4453
4493
  min="0"
4454
4494
  max="100"
4455
- value="${this.alpha}"
4495
+ value="${this.#alphaPercent}"
4456
4496
  units="%">
4457
4497
  </fig-input-number>
4458
4498
  </fig-tooltip>`;
@@ -4464,7 +4504,7 @@ class FigInputColor extends HTMLElement {
4464
4504
  ? `<fig-fill-picker mode="solid" ${fpAttrs} ${
4465
4505
  showAlpha ? "" : 'alpha="false"'
4466
4506
  } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
4467
- this.alpha
4507
+ this.#alphaPercent
4468
4508
  }}'></fig-fill-picker>`
4469
4509
  : `<fig-chit background="${this.hexOpaque}" alpha="${this.rgba.a}"></fig-chit>`;
4470
4510
  }
@@ -4474,7 +4514,6 @@ class FigInputColor extends HTMLElement {
4474
4514
  ${label}
4475
4515
  </div>`;
4476
4516
  } else {
4477
- // Without text, if picker is hidden, show nothing
4478
4517
  if (hidePicker) {
4479
4518
  html = ``;
4480
4519
  } else {
@@ -4482,7 +4521,7 @@ class FigInputColor extends HTMLElement {
4482
4521
  ? `<fig-fill-picker mode="solid" ${fpAttrs} ${
4483
4522
  showAlpha ? "" : 'alpha="false"'
4484
4523
  } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
4485
- this.alpha
4524
+ this.#alphaPercent
4486
4525
  }}'></fig-fill-picker>`
4487
4526
  : `<fig-chit background="${this.hexOpaque}" alpha="${this.rgba.a}"></fig-chit>`;
4488
4527
  }
@@ -4565,7 +4604,7 @@ class FigInputColor extends HTMLElement {
4565
4604
  this.hexWithAlpha = this.value.toUpperCase();
4566
4605
  this.hexOpaque = this.hexWithAlpha.slice(0, 7);
4567
4606
  if (hexValue.length > 7) {
4568
- this.alpha = (this.rgba.a * 100).toFixed(0);
4607
+ this.#alphaPercent = (this.rgba.a * 100).toFixed(0);
4569
4608
  }
4570
4609
  this.style.setProperty("--alpha", this.rgba.a);
4571
4610
  }
@@ -4577,7 +4616,7 @@ class FigInputColor extends HTMLElement {
4577
4616
  let inputValue = event.target.value.replace("#", "");
4578
4617
  this.#setValues("#" + inputValue);
4579
4618
  if (this.#alphaInput) {
4580
- this.#alphaInput.setAttribute("value", this.alpha);
4619
+ this.#alphaInput.setAttribute("value", this.#alphaPercent);
4581
4620
  }
4582
4621
  if (this.#swatch) {
4583
4622
  this.#swatch.setAttribute("background", this.hexOpaque);
@@ -4602,7 +4641,7 @@ class FigInputColor extends HTMLElement {
4602
4641
  JSON.stringify({
4603
4642
  type: "solid",
4604
4643
  color: this.hexOpaque,
4605
- opacity: this.alpha,
4644
+ opacity: this.#alphaPercent,
4606
4645
  }),
4607
4646
  );
4608
4647
  }
@@ -4678,12 +4717,15 @@ class FigInputColor extends HTMLElement {
4678
4717
  }
4679
4718
 
4680
4719
  static get observedAttributes() {
4681
- return ["value", "style", "mode", "picker", "experimental"];
4720
+ return ["value", "style", "mode", "picker", "experimental", "alpha"];
4682
4721
  }
4683
4722
 
4684
4723
  get mode() {
4685
4724
  return this.getAttribute("mode");
4686
4725
  }
4726
+ set mode(value) {
4727
+ this.setAttribute("mode", value);
4728
+ }
4687
4729
 
4688
4730
  attributeChangedCallback(name, oldValue, newValue) {
4689
4731
  // Skip if value hasn't actually changed
@@ -4705,12 +4747,12 @@ class FigInputColor extends HTMLElement {
4705
4747
  JSON.stringify({
4706
4748
  type: "solid",
4707
4749
  color: this.hexOpaque,
4708
- opacity: this.alpha,
4750
+ opacity: this.#alphaPercent,
4709
4751
  }),
4710
4752
  );
4711
4753
  }
4712
4754
  if (this.#alphaInput) {
4713
- this.#alphaInput.setAttribute("value", this.alpha);
4755
+ this.#alphaInput.setAttribute("value", this.#alphaPercent);
4714
4756
  }
4715
4757
  // NOTE: Do NOT emit input events here!
4716
4758
  // Input events should only fire from user interactions, not programmatic changes.
@@ -4725,6 +4767,9 @@ class FigInputColor extends HTMLElement {
4725
4767
  case "picker":
4726
4768
  // Picker type change requires re-render
4727
4769
  break;
4770
+ case "alpha":
4771
+ if (this.isConnected) this.#buildUI();
4772
+ break;
4728
4773
  }
4729
4774
  }
4730
4775
 
@@ -4828,6 +4873,110 @@ class FigInputColor extends HTMLElement {
4828
4873
  customElements.define("fig-input-color", FigInputColor);
4829
4874
 
4830
4875
  /* Input Fill */
4876
+ const GRADIENT_INTERPOLATION_SPACES = [
4877
+ "srgb",
4878
+ "srgb-linear",
4879
+ "display-p3",
4880
+ "oklab",
4881
+ "oklch",
4882
+ ];
4883
+ const GRADIENT_HUE_INTERPOLATIONS = [
4884
+ "shorter",
4885
+ "longer",
4886
+ "increasing",
4887
+ "decreasing",
4888
+ ];
4889
+
4890
+ const GRADIENT_PICKER_SPACES = ["srgb-linear", "oklab", "oklch"];
4891
+
4892
+ function normalizeGradientConfig(gradient) {
4893
+ const next = { ...(gradient ?? {}) };
4894
+ let interpolationSpace = String(
4895
+ next.interpolationSpace ?? "oklab",
4896
+ ).toLowerCase();
4897
+ if (!GRADIENT_INTERPOLATION_SPACES.includes(interpolationSpace)) {
4898
+ interpolationSpace = "oklab";
4899
+ }
4900
+ if (interpolationSpace === "srgb" || interpolationSpace === "display-p3") {
4901
+ interpolationSpace = "oklab";
4902
+ }
4903
+ next.interpolationSpace = interpolationSpace;
4904
+
4905
+ const hueInterpolation = String(
4906
+ next.hueInterpolation ?? "shorter",
4907
+ ).toLowerCase();
4908
+ next.hueInterpolation = GRADIENT_HUE_INTERPOLATIONS.includes(hueInterpolation)
4909
+ ? hueInterpolation
4910
+ : "shorter";
4911
+ return next;
4912
+ }
4913
+
4914
+ function gradientToValueShape(gradient) {
4915
+ const normalized = normalizeGradientConfig(gradient);
4916
+ const output = {
4917
+ ...normalized,
4918
+ interpolationSpace: normalized.interpolationSpace,
4919
+ };
4920
+ if (normalized.interpolationSpace === "oklch") {
4921
+ output.hueInterpolation = normalized.hueInterpolation;
4922
+ } else {
4923
+ delete output.hueInterpolation;
4924
+ }
4925
+ return output;
4926
+ }
4927
+
4928
+ function gradientInterpolationClause(gradient) {
4929
+ const normalized = normalizeGradientConfig(gradient);
4930
+ if (normalized.interpolationSpace === "oklch") {
4931
+ return `in oklch ${normalized.hueInterpolation} hue`;
4932
+ }
4933
+ return `in ${normalized.interpolationSpace}`;
4934
+ }
4935
+
4936
+ function hslToP3(h, s, l) {
4937
+ const sRGB = hslToSRGB(h, s, l);
4938
+ return sRGB.map((c) => +(c / 255).toFixed(4));
4939
+ }
4940
+
4941
+ function hslToSRGB(h, s, l) {
4942
+ s /= 100;
4943
+ l /= 100;
4944
+ const c = (1 - Math.abs(2 * l - 1)) * s;
4945
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
4946
+ const m = l - c / 2;
4947
+ let r, g, b;
4948
+ if (h < 60) {
4949
+ r = c;
4950
+ g = x;
4951
+ b = 0;
4952
+ } else if (h < 120) {
4953
+ r = x;
4954
+ g = c;
4955
+ b = 0;
4956
+ } else if (h < 180) {
4957
+ r = 0;
4958
+ g = c;
4959
+ b = x;
4960
+ } else if (h < 240) {
4961
+ r = 0;
4962
+ g = x;
4963
+ b = c;
4964
+ } else if (h < 300) {
4965
+ r = x;
4966
+ g = 0;
4967
+ b = c;
4968
+ } else {
4969
+ r = c;
4970
+ g = 0;
4971
+ b = x;
4972
+ }
4973
+ return [
4974
+ Math.round((r + m) * 255),
4975
+ Math.round((g + m) * 255),
4976
+ Math.round((b + m) * 255),
4977
+ ];
4978
+ }
4979
+
4831
4980
  /**
4832
4981
  * A fill input that supports solid colors, gradients, images, and videos.
4833
4982
  * @attr {string} value - JSON string with fill data
@@ -4846,6 +4995,8 @@ class FigInputFill extends HTMLElement {
4846
4995
  #gradient = {
4847
4996
  type: "linear",
4848
4997
  angle: 180,
4998
+ interpolationSpace: "oklab",
4999
+ hueInterpolation: "shorter",
4849
5000
  stops: [
4850
5001
  { position: 0, color: "#D9D9D9", opacity: 100 },
4851
5002
  { position: 100, color: "#737373", opacity: 100 },
@@ -4884,8 +5035,12 @@ class FigInputFill extends HTMLElement {
4884
5035
  this.#solid.alpha = parsed.opacity / 100;
4885
5036
  break;
4886
5037
  case "gradient":
4887
- if (parsed.gradient)
4888
- this.#gradient = { ...this.#gradient, ...parsed.gradient };
5038
+ if (parsed.gradient) {
5039
+ this.#gradient = normalizeGradientConfig({
5040
+ ...this.#gradient,
5041
+ ...parsed.gradient,
5042
+ });
5043
+ }
4889
5044
  break;
4890
5045
  case "image":
4891
5046
  if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
@@ -5051,7 +5206,12 @@ class FigInputFill extends HTMLElement {
5051
5206
  this.#solid.alpha = detail.alpha;
5052
5207
  break;
5053
5208
  case "gradient":
5054
- if (detail.gradient) this.#gradient = detail.gradient;
5209
+ if (detail.gradient) {
5210
+ this.#gradient = normalizeGradientConfig({
5211
+ ...this.#gradient,
5212
+ ...detail.gradient,
5213
+ });
5214
+ }
5055
5215
  break;
5056
5216
  case "image":
5057
5217
  if (detail.image) this.#image = detail.image;
@@ -5414,7 +5574,7 @@ class FigInputFill extends HTMLElement {
5414
5574
  case "gradient":
5415
5575
  return {
5416
5576
  type: "gradient",
5417
- gradient: { ...this.#gradient },
5577
+ gradient: gradientToValueShape(this.#gradient),
5418
5578
  };
5419
5579
  case "image":
5420
5580
  return {
@@ -5482,6 +5642,388 @@ class FigInputFill extends HTMLElement {
5482
5642
  }
5483
5643
  customElements.define("fig-input-fill", FigInputFill);
5484
5644
 
5645
+ /* Input Gradient */
5646
+ /**
5647
+ * A gradient-only fill input built on top of fig-fill-picker.
5648
+ * @attr {string} value - JSON string with gradient fill data
5649
+ * @attr {boolean} disabled - Whether the input is disabled
5650
+ * @fires input - When the gradient value changes
5651
+ * @fires change - When the gradient value is committed
5652
+ */
5653
+ class FigInputGradient extends HTMLElement {
5654
+ #fillPicker;
5655
+ #track;
5656
+ #colorObserver = null;
5657
+ #gradient = {
5658
+ type: "linear",
5659
+ angle: 180,
5660
+ interpolationSpace: "oklab",
5661
+ hueInterpolation: "shorter",
5662
+ stops: [
5663
+ { position: 0, color: "#D9D9D9", opacity: 100 },
5664
+ { position: 100, color: "#737373", opacity: 100 },
5665
+ ],
5666
+ };
5667
+
5668
+ constructor() {
5669
+ super();
5670
+ }
5671
+
5672
+ static get observedAttributes() {
5673
+ return ["value", "disabled", "experimental", "picker-anchor"];
5674
+ }
5675
+
5676
+ connectedCallback() {
5677
+ this.#parseValue();
5678
+ this.#render();
5679
+ }
5680
+
5681
+ #parseValue() {
5682
+ const valueAttr = this.getAttribute("value");
5683
+ if (!valueAttr) return;
5684
+ try {
5685
+ const parsed = JSON.parse(valueAttr);
5686
+ if (parsed?.type === "gradient" && parsed.gradient) {
5687
+ this.#gradient = normalizeGradientConfig({
5688
+ ...this.#gradient,
5689
+ ...parsed.gradient,
5690
+ });
5691
+ return;
5692
+ }
5693
+ if (parsed?.gradient) {
5694
+ this.#gradient = normalizeGradientConfig({
5695
+ ...this.#gradient,
5696
+ ...parsed.gradient,
5697
+ });
5698
+ }
5699
+ } catch (e) {
5700
+ // Ignore invalid JSON and keep current/default gradient.
5701
+ }
5702
+ }
5703
+
5704
+ #buildFillPickerAttrs() {
5705
+ const attrs = {};
5706
+ const experimental = this.getAttribute("experimental");
5707
+ if (experimental) attrs["experimental"] = experimental;
5708
+ for (const { name, value } of this.attributes) {
5709
+ if (name.startsWith("picker-") && name !== "picker-anchor") {
5710
+ attrs[name.slice(7)] = value;
5711
+ }
5712
+ }
5713
+ if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
5714
+ return Object.entries(attrs)
5715
+ .map(([k, v]) => `${k}="${v}"`)
5716
+ .join(" ");
5717
+ }
5718
+
5719
+ #buildStopHandles() {
5720
+ const disabled = this.hasAttribute("disabled");
5721
+ return this.#gradient.stops
5722
+ .map(
5723
+ (stop, i) =>
5724
+ `<fig-handle drag drag-axes="x" type="color" color="${stop.color}" value="${stop.position}% 50%" data-stop-index="${i}"${disabled ? " disabled" : ""}></fig-handle>`,
5725
+ )
5726
+ .join("");
5727
+ }
5728
+
5729
+ #ghostHandle = null;
5730
+ #ghostTooltip = null;
5731
+
5732
+ #render() {
5733
+ const disabled = this.hasAttribute("disabled");
5734
+ const fillPickerValue = JSON.stringify(this.value);
5735
+ const fpAttrs = this.#buildFillPickerAttrs();
5736
+ this.innerHTML = `
5737
+ <fig-fill-picker mode="gradient" ${fpAttrs} value='${fillPickerValue}' ${
5738
+ disabled ? "disabled" : ""
5739
+ }></fig-fill-picker>
5740
+ <div class="fig-input-gradient-track">${this.#buildStopHandles()}</div>`;
5741
+ this.#track = this.querySelector(".fig-input-gradient-track");
5742
+ this.#setupGhostHandle();
5743
+ this.#setupEventListeners();
5744
+ }
5745
+
5746
+ #sampleGradientColor(position) {
5747
+ const canvas = document.createElement("canvas");
5748
+ canvas.width = 256;
5749
+ canvas.height = 1;
5750
+ const ctx = canvas.getContext("2d");
5751
+ const grad = ctx.createLinearGradient(0, 0, 256, 0);
5752
+ for (const stop of this.#gradient.stops) {
5753
+ try {
5754
+ grad.addColorStop(stop.position / 100, stop.color);
5755
+ } catch {
5756
+ /* skip invalid */
5757
+ }
5758
+ }
5759
+ ctx.fillStyle = grad;
5760
+ ctx.fillRect(0, 0, 256, 1);
5761
+ const px = Math.round(Math.max(0, Math.min(255, position * 255)));
5762
+ const [r, g, b] = ctx.getImageData(px, 0, 1, 1).data;
5763
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
5764
+ }
5765
+
5766
+ #setupGhostHandle() {
5767
+ if (!this.#track || this.hasAttribute("disabled")) return;
5768
+ const tooltip = document.createElement("fig-tooltip");
5769
+ tooltip.setAttribute("text", "Add color stop");
5770
+ tooltip.setAttribute("action", "manual");
5771
+
5772
+ const ghost = document.createElement("fig-handle");
5773
+ ghost.classList.add("fig-input-gradient-ghost");
5774
+ ghost.style.position = "absolute";
5775
+ ghost.style.top = "50%";
5776
+ ghost.style.transform = "translate(-50%, -50%)";
5777
+ ghost.style.pointerEvents = "none";
5778
+ ghost.style.opacity = "0";
5779
+ ghost.style.transition = "opacity 0.15s";
5780
+
5781
+ tooltip.appendChild(ghost);
5782
+ this.#track.appendChild(tooltip);
5783
+ this.#ghostHandle = ghost;
5784
+ this.#ghostTooltip = tooltip;
5785
+
5786
+ this.addEventListener("pointerenter", this.#onTrackEnter);
5787
+ this.addEventListener("pointermove", this.#onTrackMove);
5788
+ this.addEventListener("pointerleave", this.#onTrackLeave);
5789
+ this.addEventListener("click", this.#onTrackClick);
5790
+ }
5791
+
5792
+ #showGhost() {
5793
+ if (!this.#ghostHandle) return;
5794
+ this.#ghostHandle.style.opacity = "0.5";
5795
+ if (this.#ghostTooltip) {
5796
+ this.#ghostTooltip.render();
5797
+ this.#ghostTooltip.showPopup();
5798
+ }
5799
+ }
5800
+
5801
+ #hideGhost() {
5802
+ if (!this.#ghostHandle) return;
5803
+ this.#ghostHandle.style.opacity = "0";
5804
+ if (this.#ghostTooltip) this.#ghostTooltip.hidePopup();
5805
+ }
5806
+
5807
+ #onTrackEnter = () => {
5808
+ this.#showGhost();
5809
+ };
5810
+
5811
+ #onTrackLeave = () => {
5812
+ this.#hideGhost();
5813
+ };
5814
+
5815
+ #onTrackMove = (e) => {
5816
+ if (!this.#ghostHandle || !this.#track) return;
5817
+ if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) {
5818
+ this.#hideGhost();
5819
+ return;
5820
+ }
5821
+ const trackRect = this.#track.getBoundingClientRect();
5822
+ const pct = Math.max(
5823
+ 0,
5824
+ Math.min(1, (e.clientX - trackRect.left) / trackRect.width),
5825
+ );
5826
+ this.#ghostHandle.style.left = `${pct * 100}%`;
5827
+ const color = this.#sampleGradientColor(pct);
5828
+ this.#ghostHandle.setAttribute("color", color);
5829
+ this.#showGhost();
5830
+ };
5831
+
5832
+ #onTrackClick = (e) => {
5833
+ if (!this.#track) return;
5834
+ if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) return;
5835
+ const trackRect = this.#track.getBoundingClientRect();
5836
+ const pct = Math.max(
5837
+ 0,
5838
+ Math.min(1, (e.clientX - trackRect.left) / trackRect.width),
5839
+ );
5840
+ const position = Math.round(pct * 100);
5841
+ const color = this.#sampleGradientColor(pct);
5842
+ this.#gradient.stops.push({ position, color, opacity: 100 });
5843
+ this.#gradient.stops.sort((a, b) => a.position - b.position);
5844
+ this.#syncHandles();
5845
+ this.#syncFillPicker();
5846
+ this.#emitInput();
5847
+ this.#emitChange();
5848
+ };
5849
+
5850
+ #syncHandles() {
5851
+ if (!this.#track) return;
5852
+ const handles = this.#track.querySelectorAll(
5853
+ "fig-handle:not(.fig-input-gradient-ghost)",
5854
+ );
5855
+ const stops = this.#gradient.stops;
5856
+
5857
+ if (handles.length !== stops.length) {
5858
+ const wrapper = this.#ghostTooltip;
5859
+ this.#track.innerHTML = this.#buildStopHandles();
5860
+ if (wrapper) this.#track.appendChild(wrapper);
5861
+ this.#reobserveHandleColors();
5862
+ return;
5863
+ }
5864
+
5865
+ for (let i = 0; i < stops.length; i++) {
5866
+ const h = handles[i];
5867
+ const stop = stops[i];
5868
+ h.setAttribute("value", `${stop.position}% 50%`);
5869
+ h.setAttribute("color", stop.color);
5870
+ }
5871
+ }
5872
+
5873
+ #reobserveHandleColors() {
5874
+ if (!this.#colorObserver || !this.#track) return;
5875
+ this.#colorObserver.disconnect();
5876
+ this.#track
5877
+ .querySelectorAll("fig-handle:not(.fig-input-gradient-ghost)")
5878
+ .forEach((h) => {
5879
+ this.#colorObserver.observe(h, {
5880
+ attributes: true,
5881
+ attributeFilter: ["color"],
5882
+ });
5883
+ });
5884
+ }
5885
+
5886
+ #syncFillPicker() {
5887
+ if (!this.#fillPicker) return;
5888
+ this.#fillPicker.setAttribute("value", JSON.stringify(this.value));
5889
+ }
5890
+
5891
+ #setupEventListeners() {
5892
+ requestAnimationFrame(() => {
5893
+ this.#fillPicker = this.querySelector("fig-fill-picker");
5894
+ if (!this.#fillPicker) return;
5895
+
5896
+ const anchor = this.getAttribute("picker-anchor");
5897
+ if (!anchor || anchor === "self") {
5898
+ this.#fillPicker.anchorElement = this;
5899
+ } else {
5900
+ const el = document.querySelector(anchor);
5901
+ if (el) this.#fillPicker.anchorElement = el;
5902
+ }
5903
+
5904
+ this.#fillPicker.addEventListener("input", (e) => {
5905
+ e.stopPropagation();
5906
+ const detail = e.detail;
5907
+ if (detail?.gradient) {
5908
+ this.#gradient = normalizeGradientConfig({
5909
+ ...this.#gradient,
5910
+ ...detail.gradient,
5911
+ });
5912
+ this.#syncHandles();
5913
+ this.#emitInput();
5914
+ }
5915
+ });
5916
+
5917
+ this.#fillPicker.addEventListener("change", (e) => {
5918
+ e.stopPropagation();
5919
+ this.#emitChange();
5920
+ });
5921
+
5922
+ if (this.#track) {
5923
+ this.#track.addEventListener("input", (e) => {
5924
+ const handle = e.target.closest("fig-handle");
5925
+ if (!handle) return;
5926
+ e.stopPropagation();
5927
+ const idx = parseInt(handle.dataset.stopIndex, 10);
5928
+ if (isNaN(idx) || !this.#gradient.stops[idx]) return;
5929
+ const px = e.detail?.px ?? 0;
5930
+ this.#gradient.stops[idx].position = Math.round(px * 100);
5931
+ this.#syncFillPicker();
5932
+ this.#emitInput();
5933
+ });
5934
+
5935
+ this.#track.addEventListener("change", (e) => {
5936
+ const handle = e.target.closest("fig-handle");
5937
+ if (!handle) return;
5938
+ e.stopPropagation();
5939
+ const idx = parseInt(handle.dataset.stopIndex, 10);
5940
+ if (isNaN(idx) || !this.#gradient.stops[idx]) return;
5941
+ const px = e.detail?.px ?? 0;
5942
+ this.#gradient.stops[idx].position = Math.round(px * 100);
5943
+ this.#syncFillPicker();
5944
+ this.#emitChange();
5945
+ });
5946
+
5947
+ this.#colorObserver = new MutationObserver((mutations) => {
5948
+ for (const m of mutations) {
5949
+ if (m.attributeName !== "color") continue;
5950
+ const handle = m.target;
5951
+ if (handle.classList.contains("fig-input-gradient-ghost")) continue;
5952
+ const idx = parseInt(handle.dataset.stopIndex, 10);
5953
+ if (isNaN(idx) || !this.#gradient.stops[idx]) continue;
5954
+ const newColor = handle.getAttribute("color");
5955
+ if (newColor && newColor !== this.#gradient.stops[idx].color) {
5956
+ this.#gradient.stops[idx].color = newColor;
5957
+ this.#syncFillPicker();
5958
+ this.#emitInput();
5959
+ }
5960
+ }
5961
+ });
5962
+ this.#track
5963
+ .querySelectorAll("fig-handle:not(.fig-input-gradient-ghost)")
5964
+ .forEach((h) => {
5965
+ this.#colorObserver.observe(h, {
5966
+ attributes: true,
5967
+ attributeFilter: ["color"],
5968
+ });
5969
+ });
5970
+ }
5971
+ });
5972
+ }
5973
+
5974
+ #emitInput() {
5975
+ this.dispatchEvent(
5976
+ new CustomEvent("input", {
5977
+ bubbles: true,
5978
+ detail: this.value,
5979
+ }),
5980
+ );
5981
+ }
5982
+
5983
+ #emitChange() {
5984
+ this.dispatchEvent(
5985
+ new CustomEvent("change", {
5986
+ bubbles: true,
5987
+ detail: this.value,
5988
+ }),
5989
+ );
5990
+ }
5991
+
5992
+ get value() {
5993
+ return {
5994
+ type: "gradient",
5995
+ gradient: gradientToValueShape(this.#gradient),
5996
+ };
5997
+ }
5998
+
5999
+ set value(val) {
6000
+ if (typeof val === "string") {
6001
+ this.setAttribute("value", val);
6002
+ } else {
6003
+ this.setAttribute("value", JSON.stringify(val));
6004
+ }
6005
+ }
6006
+
6007
+ attributeChangedCallback(name, oldValue, newValue) {
6008
+ if (oldValue === newValue) return;
6009
+ switch (name) {
6010
+ case "value":
6011
+ this.#parseValue();
6012
+ if (this.#fillPicker) {
6013
+ this.#syncFillPicker();
6014
+ }
6015
+ this.#syncHandles();
6016
+ break;
6017
+ case "disabled":
6018
+ case "experimental":
6019
+ case "picker-anchor":
6020
+ if (this.#fillPicker) this.#render();
6021
+ break;
6022
+ }
6023
+ }
6024
+ }
6025
+ customElements.define("fig-input-gradient", FigInputGradient);
6026
+
5485
6027
  /* Checkbox */
5486
6028
  /**
5487
6029
  * A custom checkbox input element.
@@ -7150,7 +7692,8 @@ class FigEasingCurve extends HTMLElement {
7150
7692
  );
7151
7693
  if (bezierSurface) {
7152
7694
  bezierSurface.addEventListener("pointerdown", (e) => {
7153
- if (e.target?.closest?.(".fig-easing-curve-handle, fig-handle")) return;
7695
+ if (e.target?.closest?.(".fig-easing-curve-handle, fig-handle"))
7696
+ return;
7154
7697
  this.#startBezierDrag(e, this.#bezierHandleForClientHalf(e));
7155
7698
  });
7156
7699
  }
@@ -7169,7 +7712,8 @@ class FigEasingCurve extends HTMLElement {
7169
7712
  );
7170
7713
  if (springSurface) {
7171
7714
  springSurface.addEventListener("pointerdown", (e) => {
7172
- if (e.target?.closest?.(".fig-easing-curve-handle, fig-handle")) return;
7715
+ if (e.target?.closest?.(".fig-easing-curve-handle, fig-handle"))
7716
+ return;
7173
7717
  this.#startSpringDrag(e, "duration");
7174
7718
  });
7175
7719
  }
@@ -8146,10 +8690,10 @@ customElements.define("fig-origin-grid", FigOriginGrid);
8146
8690
  * @attr {string} axis-labels - Space-delimited labels. 1 token: top. 2 tokens: x y. 4 tokens: left right top bottom.
8147
8691
  */
8148
8692
  class FigInputJoystick extends HTMLElement {
8149
- #boundMouseDown = null;
8150
- #boundTouchStart = null;
8151
- #boundKeyDown = null;
8152
- #boundKeyUp = null;
8693
+ #boundPlanePointerDown = null;
8694
+ #boundHandlePointerDown = null;
8695
+ #boundHandleInput = null;
8696
+ #boundHandleChange = null;
8153
8697
  #boundXInput = null;
8154
8698
  #boundYInput = null;
8155
8699
  #boundXFocusOut = null;
@@ -8162,17 +8706,19 @@ class FigInputJoystick extends HTMLElement {
8162
8706
 
8163
8707
  this.position = { x: 0.5, y: 0.5 };
8164
8708
  this.isDragging = false;
8165
- this.isShiftHeld = false;
8166
8709
  this.plane = null;
8167
8710
  this.cursor = null;
8168
8711
  this.xInput = null;
8169
8712
  this.yInput = null;
8170
8713
  this.coordinates = "screen";
8171
8714
  this.#initialized = false;
8172
- this.#boundMouseDown = (e) => this.#handleMouseDown(e);
8173
- this.#boundTouchStart = (e) => this.#handleTouchStart(e);
8174
- this.#boundKeyDown = (e) => this.#handleKeyDown(e);
8175
- this.#boundKeyUp = (e) => this.#handleKeyUp(e);
8715
+ this.#boundPlanePointerDown = (e) => this.#handlePlanePointerDown(e);
8716
+ this.#boundHandlePointerDown = () => {
8717
+ this.isDragging = true;
8718
+ this.plane?.classList.add("dragging");
8719
+ };
8720
+ this.#boundHandleInput = (e) => this.#handleHandleInput(e);
8721
+ this.#boundHandleChange = () => this.#handleHandleChange();
8176
8722
  this.#boundXInput = (e) => this.#handleXInput(e);
8177
8723
  this.#boundYInput = (e) => this.#handleYInput(e);
8178
8724
  this.#boundXFocusOut = () => this.#handleFieldFocusOut();
@@ -8278,7 +8824,7 @@ class FigInputJoystick extends HTMLElement {
8278
8824
  ${labelsMarkup}
8279
8825
  <div class="fig-input-joystick-plane">
8280
8826
  <div class="fig-input-joystick-guides"></div>
8281
- <fig-handle></fig-handle>
8827
+ <fig-handle drag drag-surface=".fig-input-joystick-plane" drag-axes="x,y" drag-snapping="modifier"></fig-handle>
8282
8828
  </div>
8283
8829
  <fig-tooltip text="Reset">
8284
8830
  <fig-button variant="ghost" icon="true" class="fig-joystick-reset" aria-label="Reset to default">
@@ -8318,10 +8864,10 @@ class FigInputJoystick extends HTMLElement {
8318
8864
  this.cursor = this.querySelector("fig-handle");
8319
8865
  this.xInput = this.querySelector("fig-input-number[name='x']");
8320
8866
  this.yInput = this.querySelector("fig-input-number[name='y']");
8321
- this.plane.addEventListener("mousedown", this.#boundMouseDown);
8322
- this.plane.addEventListener("touchstart", this.#boundTouchStart);
8323
- window.addEventListener("keydown", this.#boundKeyDown);
8324
- window.addEventListener("keyup", this.#boundKeyUp);
8867
+ this.plane?.addEventListener("pointerdown", this.#boundPlanePointerDown);
8868
+ this.cursor?.addEventListener("pointerdown", this.#boundHandlePointerDown);
8869
+ this.cursor?.addEventListener("input", this.#boundHandleInput);
8870
+ this.cursor?.addEventListener("change", this.#boundHandleChange);
8325
8871
  const resetBtn = this.querySelector(".fig-joystick-reset");
8326
8872
  if (resetBtn) {
8327
8873
  resetBtn.addEventListener("click", () => this.#resetToDefault());
@@ -8337,12 +8883,15 @@ class FigInputJoystick extends HTMLElement {
8337
8883
  }
8338
8884
 
8339
8885
  #cleanupListeners() {
8340
- if (this.plane) {
8341
- this.plane.removeEventListener("mousedown", this.#boundMouseDown);
8342
- this.plane.removeEventListener("touchstart", this.#boundTouchStart);
8343
- }
8344
- window.removeEventListener("keydown", this.#boundKeyDown);
8345
- window.removeEventListener("keyup", this.#boundKeyUp);
8886
+ this.plane?.removeEventListener("pointerdown", this.#boundPlanePointerDown);
8887
+ this.cursor?.removeEventListener(
8888
+ "pointerdown",
8889
+ this.#boundHandlePointerDown,
8890
+ );
8891
+ this.cursor?.removeEventListener("input", this.#boundHandleInput);
8892
+ this.cursor?.removeEventListener("change", this.#boundHandleChange);
8893
+ this.plane?.classList.remove("dragging");
8894
+ this.isDragging = false;
8346
8895
  if (this.#fieldsEnabled && this.xInput && this.yInput) {
8347
8896
  this.xInput.removeEventListener("input", this.#boundXInput);
8348
8897
  this.xInput.removeEventListener("change", this.#boundXInput);
@@ -8376,49 +8925,41 @@ class FigInputJoystick extends HTMLElement {
8376
8925
  this.#emitChangeEvent();
8377
8926
  }
8378
8927
 
8379
- #snapToGuide(value) {
8380
- if (!this.isShiftHeld) return value;
8381
- if (value < 0.1) return 0;
8382
- if (value > 0.9) return 1;
8383
- if (value > 0.4 && value < 0.6) return 0.5;
8384
- return value;
8928
+ #applyScreenPosition(screenX, screenY, { syncHandle = true } = {}) {
8929
+ const x = Math.max(0, Math.min(1, screenX));
8930
+ const yScreen = Math.max(0, Math.min(1, screenY));
8931
+ const y = this.coordinates === "math" ? 1 - yScreen : yScreen;
8932
+ this.position = { x, y };
8933
+ if (syncHandle) this.#syncHandlePosition();
8934
+ this.#syncValueAttribute();
8385
8935
  }
8386
8936
 
8387
- #snapToDiagonal(x, y) {
8388
- if (!this.isShiftHeld) return { x, y };
8389
- const diff = Math.abs(x - y);
8390
- if (diff < 0.1) return { x: (x + y) / 2, y: (x + y) / 2 };
8391
- if (Math.abs(1 - x - y) < 0.1) return { x, y: 1 - x };
8392
- return { x, y };
8937
+ #handlePlanePointerDown(e) {
8938
+ if (!this.plane || !this.cursor) return;
8939
+ if (e.target?.closest?.(".fig-joystick-reset, fig-tooltip, fig-handle"))
8940
+ return;
8941
+ const rect = this.plane.getBoundingClientRect();
8942
+ const screenX = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0.5;
8943
+ const screenY =
8944
+ rect.height > 0 ? (e.clientY - rect.top) / rect.height : 0.5;
8945
+ this.cursor.value = `${Math.round(screenX * 100)}% ${Math.round(screenY * 100)}%`;
8946
+ this.#applyScreenPosition(screenX, screenY, { syncHandle: false });
8947
+ this.#emitInputEvent();
8948
+ this.#emitChangeEvent();
8393
8949
  }
8394
8950
 
8395
- #updatePosition(e) {
8396
- const rect = this.plane.getBoundingClientRect();
8397
- let x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
8398
- let screenY = Math.max(
8399
- 0,
8400
- Math.min(1, (e.clientY - rect.top) / rect.height),
8401
- );
8402
-
8403
- // Convert screen Y to internal Y (flip for math coordinates)
8404
- let y = this.coordinates === "math" ? 1 - screenY : screenY;
8405
-
8406
- x = this.#snapToGuide(x);
8407
- y = this.#snapToGuide(y);
8408
-
8409
- const snapped = this.#snapToDiagonal(x, y);
8410
- this.position = snapped;
8411
-
8412
- const displayY = this.#displayY(snapped.y);
8413
- this.cursor.style.left = `${snapped.x * 100}%`;
8414
- this.cursor.style.top = `${displayY * 100}%`;
8415
- if (this.#fieldsEnabled && this.xInput && this.yInput) {
8416
- this.xInput.setAttribute("value", Math.round(snapped.x * 100));
8417
- this.yInput.setAttribute("value", Math.round(snapped.y * 100));
8418
- }
8951
+ #handleHandleInput(e) {
8952
+ const detail = e.detail ?? {};
8953
+ if (typeof detail.px !== "number" || typeof detail.py !== "number") return;
8954
+ this.#applyScreenPosition(detail.px, detail.py, { syncHandle: false });
8955
+ this.#emitInputEvent();
8956
+ }
8419
8957
 
8958
+ #handleHandleChange() {
8959
+ this.isDragging = false;
8960
+ this.plane?.classList.remove("dragging");
8420
8961
  this.#syncValueAttribute();
8421
- this.#emitInputEvent();
8962
+ this.#emitChangeEvent();
8422
8963
  }
8423
8964
 
8424
8965
  #emitInputEvent() {
@@ -8442,8 +8983,7 @@ class FigInputJoystick extends HTMLElement {
8442
8983
  #syncHandlePosition() {
8443
8984
  const displayY = this.#displayY(this.position.y);
8444
8985
  if (this.cursor) {
8445
- this.cursor.style.left = `${this.position.x * 100}%`;
8446
- this.cursor.style.top = `${displayY * 100}%`;
8986
+ this.cursor.value = `${this.position.x * 100}% ${displayY * 100}%`;
8447
8987
  }
8448
8988
  // Also sync text inputs if they exist (convert to percentage 0-100)
8449
8989
  if (this.#fieldsEnabled && this.xInput && this.yInput) {
@@ -8479,64 +9019,6 @@ class FigInputJoystick extends HTMLElement {
8479
9019
  this.#emitChangeEvent();
8480
9020
  }
8481
9021
 
8482
- #handleMouseDown(e) {
8483
- if (e.target.closest(".fig-joystick-reset, fig-tooltip")) return;
8484
- this.isDragging = true;
8485
-
8486
- this.#updatePosition(e);
8487
-
8488
- this.plane.style.cursor = "grabbing";
8489
-
8490
- const handleMouseMove = (e) => {
8491
- this.plane.classList.add("dragging");
8492
- if (this.isDragging) this.#updatePosition(e);
8493
- };
8494
-
8495
- const handleMouseUp = () => {
8496
- this.isDragging = false;
8497
- this.plane.classList.remove("dragging");
8498
- this.plane.style.cursor = "";
8499
- window.removeEventListener("mousemove", handleMouseMove);
8500
- window.removeEventListener("mouseup", handleMouseUp);
8501
- this.#syncValueAttribute();
8502
- this.#emitChangeEvent();
8503
- };
8504
-
8505
- window.addEventListener("mousemove", handleMouseMove);
8506
- window.addEventListener("mouseup", handleMouseUp);
8507
- }
8508
-
8509
- #handleTouchStart(e) {
8510
- if (e.target.closest(".fig-joystick-reset, fig-tooltip")) return;
8511
- e.preventDefault();
8512
- this.isDragging = true;
8513
- this.#updatePosition(e.touches[0]);
8514
-
8515
- const handleTouchMove = (e) => {
8516
- this.plane.classList.add("dragging");
8517
- if (this.isDragging) this.#updatePosition(e.touches[0]);
8518
- };
8519
-
8520
- const handleTouchEnd = () => {
8521
- this.isDragging = false;
8522
- this.plane.classList.remove("dragging");
8523
- window.removeEventListener("touchmove", handleTouchMove);
8524
- window.removeEventListener("touchend", handleTouchEnd);
8525
- this.#syncValueAttribute();
8526
- this.#emitChangeEvent();
8527
- };
8528
-
8529
- window.addEventListener("touchmove", handleTouchMove);
8530
- window.addEventListener("touchend", handleTouchEnd);
8531
- }
8532
-
8533
- #handleKeyDown(e) {
8534
- if (e.key === "Shift") this.isShiftHeld = true;
8535
- }
8536
-
8537
- #handleKeyUp(e) {
8538
- if (e.key === "Shift") this.isShiftHeld = false;
8539
- }
8540
9022
  focus() {
8541
9023
  const container = this.querySelector(".fig-input-joystick-plane-container");
8542
9024
  container?.focus();
@@ -9186,6 +9668,10 @@ class FigShimmer extends HTMLElement {
9186
9668
  }
9187
9669
  customElements.define("fig-shimmer", FigShimmer);
9188
9670
 
9671
+ // FigSkeleton
9672
+ class FigSkeleton extends FigShimmer {}
9673
+ customElements.define("fig-skeleton", FigSkeleton);
9674
+
9189
9675
  // FigLayer
9190
9676
  class FigLayer extends HTMLElement {
9191
9677
  static get observedAttributes() {
@@ -9319,12 +9805,16 @@ class FigFillPicker extends HTMLElement {
9319
9805
 
9320
9806
  // Fill state
9321
9807
  #fillType = "solid";
9808
+ #gamut = "srgb"; // "srgb" or "display-p3"
9322
9809
  #color = { h: 0, s: 0, v: 85, a: 1 }; // Default gray #D9D9D9
9810
+ #colorInputMode = "hex";
9323
9811
  #gradient = {
9324
9812
  type: "linear",
9325
9813
  angle: 0,
9326
9814
  centerX: 50,
9327
9815
  centerY: 50,
9816
+ interpolationSpace: "oklab",
9817
+ hueInterpolation: "shorter",
9328
9818
  stops: [
9329
9819
  { position: 0, color: "#D9D9D9", opacity: 100 },
9330
9820
  { position: 100, color: "#737373", opacity: 100 },
@@ -9344,6 +9834,7 @@ class FigFillPicker extends HTMLElement {
9344
9834
  #hueSlider = null;
9345
9835
  #opacitySlider = null;
9346
9836
  #isDraggingColor = false;
9837
+ #teardownColorAreaEvents = null;
9347
9838
 
9348
9839
  constructor() {
9349
9840
  super();
@@ -9365,6 +9856,10 @@ class FigFillPicker extends HTMLElement {
9365
9856
  }
9366
9857
 
9367
9858
  disconnectedCallback() {
9859
+ if (this.#teardownColorAreaEvents) {
9860
+ this.#teardownColorAreaEvents();
9861
+ this.#teardownColorAreaEvents = null;
9862
+ }
9368
9863
  if (this.#dialog) {
9369
9864
  this.#dialog.close();
9370
9865
  this.#dialog.remove();
@@ -9434,8 +9929,15 @@ class FigFillPicker extends HTMLElement {
9434
9929
  if (parsed.opacity !== undefined) {
9435
9930
  this.#color.a = parsed.opacity / 100;
9436
9931
  }
9437
- if (parsed.gradient)
9438
- this.#gradient = { ...this.#gradient, ...parsed.gradient };
9932
+ if (parsed.colorSpace === "display-p3" || parsed.colorSpace === "srgb") {
9933
+ this.#gamut = parsed.colorSpace;
9934
+ }
9935
+ if (parsed.gradient) {
9936
+ this.#gradient = normalizeGradientConfig({
9937
+ ...this.#gradient,
9938
+ ...parsed.gradient,
9939
+ });
9940
+ }
9439
9941
  if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
9440
9942
  if (parsed.video) this.#video = { ...this.#video, ...parsed.video };
9441
9943
 
@@ -9531,6 +10033,10 @@ class FigFillPicker extends HTMLElement {
9531
10033
  }
9532
10034
 
9533
10035
  this.#switchTab(this.#fillType);
10036
+
10037
+ const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
10038
+ if (gamutEl) gamutEl.value = this.#gamut;
10039
+
9534
10040
  this.#dialog.open = true;
9535
10041
 
9536
10042
  requestAnimationFrame(() => {
@@ -9618,16 +10124,22 @@ class FigFillPicker extends HTMLElement {
9618
10124
  .map((m) => `<div class="fig-fill-picker-tab" data-tab="${m}"></div>`)
9619
10125
  .join("\n ");
9620
10126
 
10127
+ const gamutDropdown = `<fig-dropdown class="fig-fill-picker-gamut" ${expAttr} value="${this.#gamut}">
10128
+ <option value="srgb">sRGB</option>
10129
+ <option value="display-p3">Display P3</option>
10130
+ </fig-dropdown>`;
10131
+
9621
10132
  this.#dialog.innerHTML = `
9622
10133
  <fig-header>
9623
10134
  ${headerContent}
10135
+ ${gamutDropdown}
9624
10136
  <fig-button icon variant="ghost" class="fig-fill-picker-close">
9625
10137
  <span class="fig-mask-icon" style="--icon: var(--icon-close)"></span>
9626
10138
  </fig-button>
9627
10139
  </fig-header>
9628
- <div class="fig-fill-picker-content">
10140
+ <fig-content>
9629
10141
  ${tabDivs}
9630
- </div>
10142
+ </fig-content>
9631
10143
  `;
9632
10144
 
9633
10145
  document.body.appendChild(this.#dialog);
@@ -9659,6 +10171,20 @@ class FigFillPicker extends HTMLElement {
9659
10171
  });
9660
10172
  }
9661
10173
 
10174
+ // Setup gamut dropdown
10175
+ const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
10176
+ if (gamutEl) {
10177
+ const handleGamutChange = (e) => {
10178
+ const val = e.currentTarget?.value ?? e.target?.value ?? e.detail;
10179
+ if (val && val !== this.#gamut) {
10180
+ this.#gamut = val;
10181
+ this.#onGamutChange();
10182
+ }
10183
+ };
10184
+ gamutEl.addEventListener("input", handleGamutChange);
10185
+ gamutEl.addEventListener("change", handleGamutChange);
10186
+ }
10187
+
9662
10188
  this.#dialog
9663
10189
  .querySelector(".fig-fill-picker-close")
9664
10190
  .addEventListener("click", () => {
@@ -9728,7 +10254,7 @@ class FigFillPicker extends HTMLElement {
9728
10254
  });
9729
10255
 
9730
10256
  // Zero out content padding for custom mode tabs
9731
- const contentEl = this.#dialog.querySelector(".fig-fill-picker-content");
10257
+ const contentEl = this.#dialog.querySelector("fig-content");
9732
10258
  if (contentEl) {
9733
10259
  contentEl.style.padding = this.#customSlots[tabName] ? "0" : "";
9734
10260
  }
@@ -9749,13 +10275,27 @@ class FigFillPicker extends HTMLElement {
9749
10275
  #initSolidTab() {
9750
10276
  const container = this.#dialog.querySelector('[data-tab="solid"]');
9751
10277
  const showAlpha = this.getAttribute("alpha") !== "false";
10278
+ const experimental = this.getAttribute("experimental");
10279
+ const expAttr = experimental ? `experimental="${experimental}"` : "";
10280
+ const savedMode = localStorage.getItem("figui-color-input-mode");
10281
+ if (
10282
+ savedMode &&
10283
+ ["hex", "rgb", "hsl", "hsb", "lab", "lch"].includes(savedMode)
10284
+ ) {
10285
+ this.#colorInputMode = savedMode;
10286
+ }
9752
10287
 
9753
10288
  container.innerHTML = `
9754
- <div class="fig-fill-picker-color-area">
10289
+ <fig-preview class="fig-fill-picker-color-area">
9755
10290
  <canvas width="200" height="200"></canvas>
9756
- <div class="fig-fill-picker-handle"></div>
9757
- </div>
10291
+ <fig-handle
10292
+ drag
10293
+ drag-surface=".fig-fill-picker-color-area"
10294
+ drag-axes="x,y"
10295
+ ></fig-handle>
10296
+ </fig-preview>
9758
10297
  <div class="fig-fill-picker-sliders">
10298
+ <fig-tooltip text="Sample color"><fig-button icon variant="ghost" class="fig-fill-picker-eyedropper"><span class="fig-mask-icon" style="--icon: var(--icon-eyedropper)"></span></fig-button></fig-tooltip>
9759
10299
  <fig-slider type="hue" variant="neue" min="0" max="360" value="${
9760
10300
  this.#color.h
9761
10301
  }"></fig-slider>
@@ -9767,17 +10307,22 @@ class FigFillPicker extends HTMLElement {
9767
10307
  : ""
9768
10308
  }
9769
10309
  </div>
9770
- <div class="fig-fill-picker-inputs">
9771
- <fig-button icon variant="ghost" class="fig-fill-picker-eyedropper" title="Pick color from screen"><span class="fig-mask-icon" style="--icon: var(--icon-eyedropper)"></span></fig-button>
9772
- <fig-input-color class="fig-fill-picker-color-input" text="true" picker="false" value="${this.#hsvToHex(
9773
- this.#color,
9774
- )}"></fig-input-color>
9775
- </div>
10310
+ <fig-field class="fig-fill-picker-inputs" direction="horizontal">
10311
+ <fig-dropdown class="fig-fill-picker-input-mode" ${expAttr} value="${this.#colorInputMode}">
10312
+ <option value="hex">Hex</option>
10313
+ <option value="rgb">RGB</option>
10314
+ <option value="hsl">HSL</option>
10315
+ <option value="hsb">HSB</option>
10316
+ <option value="lab">LAB</option>
10317
+ <option value="lch">LCH</option>
10318
+ </fig-dropdown>
10319
+ <span class="fig-fill-picker-input-fields"></span>
10320
+ </fig-field>
9776
10321
  `;
9777
10322
 
9778
10323
  // Setup color area
9779
10324
  this.#colorArea = container.querySelector("canvas");
9780
- this.#colorAreaHandle = container.querySelector(".fig-fill-picker-handle");
10325
+ this.#colorAreaHandle = container.querySelector("fig-handle");
9781
10326
  this.#drawColorArea();
9782
10327
  this.#updateHandlePosition();
9783
10328
  this.#setupColorAreaEvents();
@@ -9810,25 +10355,17 @@ class FigFillPicker extends HTMLElement {
9810
10355
  });
9811
10356
  }
9812
10357
 
9813
- // Setup color input
9814
- const colorInput = container.querySelector(".fig-fill-picker-color-input");
9815
- colorInput.addEventListener("input", (e) => {
9816
- // Skip if we're dragging - prevents feedback loop that loses saturation for dark colors
9817
- if (this.#isDraggingColor) return;
9818
-
9819
- const hex = e.target.value;
9820
- this.#color = { ...this.#hexToHSV(hex), a: this.#color.a };
9821
- this.#drawColorArea();
9822
- this.#updateHandlePosition();
9823
- if (this.#hueSlider) {
9824
- this.#hueSlider.setAttribute("value", this.#color.h);
9825
- }
9826
- this.#emitInput();
9827
- });
9828
- colorInput.addEventListener("change", () => {
9829
- this.#emitChange();
10358
+ // Setup color input mode dropdown
10359
+ const modeDropdown = container.querySelector(".fig-fill-picker-input-mode");
10360
+ modeDropdown.addEventListener("input", (e) => {
10361
+ this.#colorInputMode = e.target.value;
10362
+ localStorage.setItem("figui-color-input-mode", this.#colorInputMode);
10363
+ this.#rebuildColorInputFields();
9830
10364
  });
9831
10365
 
10366
+ // Build initial color input fields
10367
+ this.#rebuildColorInputFields();
10368
+
9832
10369
  // Setup eyedropper
9833
10370
  const eyedropper = container.querySelector(".fig-fill-picker-eyedropper");
9834
10371
  if ("EyeDropper" in window) {
@@ -9851,6 +10388,27 @@ class FigFillPicker extends HTMLElement {
9851
10388
  }
9852
10389
  }
9853
10390
 
10391
+ #onGamutChange() {
10392
+ // Recreate the solid canvas with the new color space
10393
+ const solidContainer = this.#dialog?.querySelector('[data-tab="solid"]');
10394
+ if (solidContainer) {
10395
+ const oldCanvas = solidContainer.querySelector("canvas");
10396
+ if (oldCanvas) {
10397
+ const newCanvas = document.createElement("canvas");
10398
+ newCanvas.width = oldCanvas.width;
10399
+ newCanvas.height = oldCanvas.height;
10400
+ oldCanvas.replaceWith(newCanvas);
10401
+ this.#colorArea = newCanvas;
10402
+ this.#setupColorAreaEvents();
10403
+ }
10404
+ this.#drawColorArea();
10405
+ this.#updateHandlePosition();
10406
+ }
10407
+ // Refresh gradient preview if gradient tab exists
10408
+ this.#updateGradientPreview();
10409
+ this.#emitInput();
10410
+ }
10411
+
9854
10412
  #drawColorArea() {
9855
10413
  // Refresh canvas reference in case DOM changed
9856
10414
  if (!this.#colorArea && this.#dialog) {
@@ -9858,27 +10416,31 @@ class FigFillPicker extends HTMLElement {
9858
10416
  }
9859
10417
  if (!this.#colorArea) return;
9860
10418
 
9861
- const ctx = this.#colorArea.getContext("2d");
10419
+ const colorSpace = this.#gamut === "display-p3" ? "display-p3" : "srgb";
10420
+ const ctx = this.#colorArea.getContext("2d", { colorSpace });
9862
10421
  if (!ctx) return;
9863
10422
 
9864
10423
  const width = this.#colorArea.width;
9865
10424
  const height = this.#colorArea.height;
9866
10425
 
9867
- // Clear canvas first
9868
10426
  ctx.clearRect(0, 0, width, height);
9869
10427
 
9870
- // Draw saturation-value gradient
9871
10428
  const hue = this.#color.h;
10429
+ const isP3 = this.#gamut === "display-p3";
9872
10430
 
9873
- // Create horizontal gradient (white to hue color)
9874
10431
  const gradH = ctx.createLinearGradient(0, 0, width, 0);
9875
- gradH.addColorStop(0, "#FFFFFF");
9876
- gradH.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
10432
+ if (isP3) {
10433
+ gradH.addColorStop(0, "color(display-p3 1 1 1)");
10434
+ const [r, g, b] = hslToP3(hue, 100, 50);
10435
+ gradH.addColorStop(1, `color(display-p3 ${r} ${g} ${b})`);
10436
+ } else {
10437
+ gradH.addColorStop(0, "#FFFFFF");
10438
+ gradH.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
10439
+ }
9877
10440
 
9878
10441
  ctx.fillStyle = gradH;
9879
10442
  ctx.fillRect(0, 0, width, height);
9880
10443
 
9881
- // Create vertical gradient (transparent to black)
9882
10444
  const gradV = ctx.createLinearGradient(0, 0, 0, height);
9883
10445
  gradV.addColorStop(0, "rgba(0,0,0,0)");
9884
10446
  gradV.addColorStop(1, "rgba(0,0,0,1)");
@@ -9898,90 +10460,319 @@ class FigFillPicker extends HTMLElement {
9898
10460
  return;
9899
10461
  }
9900
10462
 
9901
- const x = (this.#color.s / 100) * rect.width;
9902
- const y = ((100 - this.#color.v) / 100) * rect.height;
10463
+ const xPct = Math.max(0, Math.min(100, this.#color.s));
10464
+ const yPct = Math.max(0, Math.min(100, 100 - this.#color.v));
9903
10465
 
9904
- this.#colorAreaHandle.style.left = `${x}px`;
9905
- this.#colorAreaHandle.style.top = `${y}px`;
9906
- this.#colorAreaHandle.style.setProperty(
9907
- "--picker-color",
10466
+ this.#colorAreaHandle.setAttribute("value", `${xPct}% ${yPct}%`);
10467
+ this.#colorAreaHandle.setAttribute(
10468
+ "color",
9908
10469
  this.#hsvToHex({ ...this.#color, a: 1 }),
9909
10470
  );
9910
10471
  }
9911
10472
 
10473
+ #updateColorFromAreaPosition(x, y, opts = {}) {
10474
+ const { updateHandle = true, emitInput = true, emitChange = false } = opts;
10475
+ this.#color.s = Math.max(0, Math.min(100, x * 100));
10476
+ this.#color.v = Math.max(0, Math.min(100, (1 - y) * 100));
10477
+ if (this.#colorAreaHandle) {
10478
+ this.#colorAreaHandle.setAttribute(
10479
+ "color",
10480
+ this.#hsvToHex({ ...this.#color, a: 1 }),
10481
+ );
10482
+ }
10483
+ if (updateHandle) this.#updateHandlePosition();
10484
+ this.#updateColorInputs();
10485
+ if (emitInput) this.#emitInput();
10486
+ if (emitChange) this.#emitChange();
10487
+ }
10488
+
9912
10489
  #setupColorAreaEvents() {
10490
+ if (this.#teardownColorAreaEvents) {
10491
+ this.#teardownColorAreaEvents();
10492
+ this.#teardownColorAreaEvents = null;
10493
+ }
9913
10494
  if (!this.#colorArea || !this.#colorAreaHandle) return;
9914
10495
 
9915
- const endColorDrag = () => {
9916
- if (!this.#isDraggingColor) return;
9917
- this.#isDraggingColor = false;
9918
- this.#emitChange();
9919
- };
10496
+ const colorAreaEl = this.#colorArea.parentElement || this.#colorArea;
10497
+ const colorAreaHandleEl = this.#colorAreaHandle;
9920
10498
 
9921
- const updateFromEvent = (e) => {
9922
- const rect = this.#colorArea.getBoundingClientRect();
10499
+ let isPlaneDragging = false;
10500
+
10501
+ const updatePlaneFromEvent = (e, opts = {}) => {
10502
+ const rect = colorAreaEl.getBoundingClientRect();
10503
+ if (rect.width === 0 || rect.height === 0) return;
9923
10504
  const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
9924
10505
  const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
9925
-
9926
- this.#color.s = (x / rect.width) * 100;
9927
- this.#color.v = 100 - (y / rect.height) * 100;
9928
-
9929
- this.#updateHandlePosition();
9930
- this.#updateColorInputs();
9931
- this.#emitInput();
10506
+ this.#updateColorFromAreaPosition(x / rect.width, y / rect.height, opts);
9932
10507
  };
9933
10508
 
9934
- // Canvas click/drag
9935
- this.#colorArea.addEventListener("pointerdown", (e) => {
10509
+ const onPlanePointerDown = (e) => {
10510
+ if (e.button !== 0) return;
10511
+ if (
10512
+ e.target === colorAreaHandleEl ||
10513
+ colorAreaHandleEl.contains(e.target)
10514
+ )
10515
+ return;
10516
+ isPlaneDragging = true;
9936
10517
  this.#isDraggingColor = true;
9937
- this.#colorArea.setPointerCapture(e.pointerId);
9938
- updateFromEvent(e);
9939
- });
10518
+ colorAreaEl.setPointerCapture(e.pointerId);
10519
+ updatePlaneFromEvent(e, { updateHandle: true, emitInput: true });
10520
+ };
9940
10521
 
9941
- this.#colorArea.addEventListener("pointermove", (e) => {
9942
- if (!this.#isDraggingColor) return;
10522
+ const onPlanePointerMove = (e) => {
10523
+ if (!isPlaneDragging) return;
9943
10524
  if (e.buttons === 0) {
9944
- endColorDrag();
10525
+ onPlaneDragEnd();
9945
10526
  return;
9946
10527
  }
9947
- updateFromEvent(e);
9948
- });
10528
+ updatePlaneFromEvent(e, { updateHandle: true, emitInput: true });
10529
+ };
9949
10530
 
9950
- this.#colorArea.addEventListener("pointerup", endColorDrag);
9951
- this.#colorArea.addEventListener("pointercancel", endColorDrag);
9952
- this.#colorArea.addEventListener("lostpointercapture", endColorDrag);
10531
+ const onPlaneDragEnd = () => {
10532
+ if (!isPlaneDragging) return;
10533
+ isPlaneDragging = false;
10534
+ this.#isDraggingColor = false;
10535
+ this.#emitChange();
10536
+ };
9953
10537
 
9954
- // Handle drag (for when handle is at corners)
9955
- this.#colorAreaHandle.addEventListener("pointerdown", (e) => {
9956
- e.stopPropagation(); // Prevent canvas from also capturing
10538
+ const onHandleInput = (e) => {
9957
10539
  this.#isDraggingColor = true;
9958
- this.#colorAreaHandle.setPointerCapture(e.pointerId);
9959
- });
10540
+ const px = e.detail?.px;
10541
+ const py = e.detail?.py;
10542
+ if (!Number.isFinite(px) || !Number.isFinite(py)) return;
10543
+ colorAreaHandleEl.setAttribute("value", `${px * 100}% ${py * 100}%`);
10544
+ this.#updateColorFromAreaPosition(px, py, {
10545
+ updateHandle: false,
10546
+ emitInput: true,
10547
+ });
10548
+ };
9960
10549
 
9961
- this.#colorAreaHandle.addEventListener("pointermove", (e) => {
9962
- if (!this.#isDraggingColor) return;
9963
- if (e.buttons === 0) {
9964
- endColorDrag();
9965
- return;
10550
+ const onHandleChange = (e) => {
10551
+ const px = e.detail?.px;
10552
+ const py = e.detail?.py;
10553
+ if (Number.isFinite(px) && Number.isFinite(py)) {
10554
+ colorAreaHandleEl.setAttribute("value", `${px * 100}% ${py * 100}%`);
10555
+ this.#updateColorFromAreaPosition(px, py, {
10556
+ updateHandle: false,
10557
+ emitInput: false,
10558
+ });
9966
10559
  }
9967
- updateFromEvent(e);
10560
+ this.#isDraggingColor = false;
10561
+ this.#emitChange();
10562
+ };
10563
+
10564
+ colorAreaEl.addEventListener("pointerdown", onPlanePointerDown);
10565
+ colorAreaEl.addEventListener("pointermove", onPlanePointerMove);
10566
+ colorAreaEl.addEventListener("pointerup", onPlaneDragEnd);
10567
+ colorAreaEl.addEventListener("pointercancel", onPlaneDragEnd);
10568
+ colorAreaEl.addEventListener("lostpointercapture", onPlaneDragEnd);
10569
+
10570
+ colorAreaHandleEl.addEventListener("input", onHandleInput);
10571
+ colorAreaHandleEl.addEventListener("change", onHandleChange);
10572
+
10573
+ this.#teardownColorAreaEvents = () => {
10574
+ colorAreaEl.removeEventListener("pointerdown", onPlanePointerDown);
10575
+ colorAreaEl.removeEventListener("pointermove", onPlanePointerMove);
10576
+ colorAreaEl.removeEventListener("pointerup", onPlaneDragEnd);
10577
+ colorAreaEl.removeEventListener("pointercancel", onPlaneDragEnd);
10578
+ colorAreaEl.removeEventListener("lostpointercapture", onPlaneDragEnd);
10579
+
10580
+ colorAreaHandleEl.removeEventListener("input", onHandleInput);
10581
+ colorAreaHandleEl.removeEventListener("change", onHandleChange);
10582
+ this.#isDraggingColor = false;
10583
+ };
10584
+ }
10585
+
10586
+ #rebuildColorInputFields() {
10587
+ const container = this.#dialog?.querySelector(
10588
+ ".fig-fill-picker-input-fields",
10589
+ );
10590
+ if (!container) return;
10591
+
10592
+ const wrap = (tooltip, html) =>
10593
+ `<fig-tooltip text="${tooltip}">${html}</fig-tooltip>`;
10594
+
10595
+ const num = (cls, min, max, step) =>
10596
+ `<fig-input-number class="${cls}" min="${min}" max="${max}"${step != null ? ` step="${step}"` : ""}></fig-input-number>`;
10597
+
10598
+ let html;
10599
+ switch (this.#colorInputMode) {
10600
+ case "rgb":
10601
+ html = `<div class="input-combo">
10602
+ ${wrap("Red", num("fig-fill-picker-ci-r", 0, 255))}
10603
+ ${wrap("Green", num("fig-fill-picker-ci-g", 0, 255))}
10604
+ ${wrap("Blue", num("fig-fill-picker-ci-b", 0, 255))}
10605
+ </div>`;
10606
+ break;
10607
+ case "hsl":
10608
+ html = `<div class="input-combo">
10609
+ ${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))}
10610
+ ${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))}
10611
+ ${wrap("Lightness", num("fig-fill-picker-ci-l", 0, 100))}
10612
+ </div>`;
10613
+ break;
10614
+ case "hsb":
10615
+ html = `<div class="input-combo">
10616
+ ${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))}
10617
+ ${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))}
10618
+ ${wrap("Brightness", num("fig-fill-picker-ci-v", 0, 100))}
10619
+ </div>`;
10620
+ break;
10621
+ case "lab":
10622
+ html = `<div class="input-combo">
10623
+ ${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))}
10624
+ ${wrap("Green-Red axis", num("fig-fill-picker-ci-oka", -0.4, 0.4, 0.001))}
10625
+ ${wrap("Blue-Yellow axis", num("fig-fill-picker-ci-okb", -0.4, 0.4, 0.001))}
10626
+ </div>`;
10627
+ break;
10628
+ case "lch":
10629
+ html = `<div class="input-combo">
10630
+ ${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))}
10631
+ ${wrap("Chroma", num("fig-fill-picker-ci-okc", 0, 0.4, 0.001))}
10632
+ ${wrap("Hue", num("fig-fill-picker-ci-okh", 0, 360))}
10633
+ </div>`;
10634
+ break;
10635
+ default: // hex
10636
+ html = `<fig-input-text class="fig-fill-picker-ci-hex" placeholder="FFFFFF"></fig-input-text>`;
10637
+ break;
10638
+ }
10639
+
10640
+ container.innerHTML = html;
10641
+ this.#wireColorInputEvents();
10642
+ requestAnimationFrame(() => this.#updateColorInputs());
10643
+ }
10644
+
10645
+ #wireColorInputEvents() {
10646
+ const container = this.#dialog?.querySelector(
10647
+ ".fig-fill-picker-input-fields",
10648
+ );
10649
+ if (!container) return;
10650
+
10651
+ const onInput = () => {
10652
+ if (this.#isDraggingColor) return;
10653
+ const color = this.#readColorFromInputs();
10654
+ if (!color) return;
10655
+ this.#color = { ...color, a: this.#color.a };
10656
+ this.#drawColorArea();
10657
+ this.#updateHandlePosition();
10658
+ if (this.#hueSlider) {
10659
+ this.#hueSlider.setAttribute("value", this.#color.h);
10660
+ }
10661
+ this.#emitInput();
10662
+ };
10663
+
10664
+ const onChange = () => this.#emitChange();
10665
+
10666
+ const inputs = container.querySelectorAll(
10667
+ "fig-input-number, fig-input-text",
10668
+ );
10669
+ inputs.forEach((el) => {
10670
+ el.addEventListener("input", onInput);
10671
+ el.addEventListener("change", onChange);
9968
10672
  });
10673
+ }
10674
+
10675
+ #readColorFromInputs() {
10676
+ const q = (cls) => this.#dialog?.querySelector(`.${cls}`);
10677
+ const val = (cls) => parseFloat(q(cls)?.value ?? 0);
9969
10678
 
9970
- this.#colorAreaHandle.addEventListener("pointerup", endColorDrag);
9971
- this.#colorAreaHandle.addEventListener("pointercancel", endColorDrag);
9972
- this.#colorAreaHandle.addEventListener("lostpointercapture", endColorDrag);
10679
+ switch (this.#colorInputMode) {
10680
+ case "rgb":
10681
+ return this.#rgbToHSV({
10682
+ r: val("fig-fill-picker-ci-r"),
10683
+ g: val("fig-fill-picker-ci-g"),
10684
+ b: val("fig-fill-picker-ci-b"),
10685
+ });
10686
+ case "hsl": {
10687
+ const rgb = this.#hslToRGB({
10688
+ h: val("fig-fill-picker-ci-h"),
10689
+ s: val("fig-fill-picker-ci-s"),
10690
+ l: val("fig-fill-picker-ci-l"),
10691
+ });
10692
+ return this.#rgbToHSV(rgb);
10693
+ }
10694
+ case "hsb":
10695
+ return {
10696
+ h: val("fig-fill-picker-ci-h"),
10697
+ s: val("fig-fill-picker-ci-s"),
10698
+ v: val("fig-fill-picker-ci-v"),
10699
+ a: 1,
10700
+ };
10701
+ case "lab": {
10702
+ const rgb = this.#oklabToRGB({
10703
+ l: val("fig-fill-picker-ci-okl") / 100,
10704
+ a: val("fig-fill-picker-ci-oka"),
10705
+ b: val("fig-fill-picker-ci-okb"),
10706
+ });
10707
+ return this.#rgbToHSV(rgb);
10708
+ }
10709
+ case "lch": {
10710
+ const rgb = this.#oklchToRGB({
10711
+ l: val("fig-fill-picker-ci-okl") / 100,
10712
+ c: val("fig-fill-picker-ci-okc"),
10713
+ h: val("fig-fill-picker-ci-okh"),
10714
+ });
10715
+ return this.#rgbToHSV(rgb);
10716
+ }
10717
+ default: {
10718
+ // hex
10719
+ const hexEl = q("fig-fill-picker-ci-hex");
10720
+ if (!hexEl) return null;
10721
+ let hex = hexEl.value.replace(/^#/, "");
10722
+ if (hex.length === 3)
10723
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
10724
+ if (hex.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(hex)) return null;
10725
+ return this.#hexToHSV(`#${hex}`);
10726
+ }
10727
+ }
9973
10728
  }
9974
10729
 
9975
10730
  #updateColorInputs() {
9976
10731
  if (!this.#dialog) return;
9977
10732
 
9978
10733
  const hex = this.#hsvToHex(this.#color);
10734
+ const rgb = this.#hsvToRGB(this.#color);
10735
+ const q = (cls) => this.#dialog.querySelector(`.${cls}`);
10736
+ const set = (cls, v) => {
10737
+ const el = q(cls);
10738
+ if (el) el.setAttribute("value", v);
10739
+ };
9979
10740
 
9980
- const colorInput = this.#dialog.querySelector(
9981
- ".fig-fill-picker-color-input",
9982
- );
9983
- if (colorInput) {
9984
- colorInput.setAttribute("value", hex);
10741
+ switch (this.#colorInputMode) {
10742
+ case "rgb":
10743
+ set("fig-fill-picker-ci-r", rgb.r);
10744
+ set("fig-fill-picker-ci-g", rgb.g);
10745
+ set("fig-fill-picker-ci-b", rgb.b);
10746
+ break;
10747
+ case "hsl": {
10748
+ const hsl = this.#rgbToHSL(rgb);
10749
+ set("fig-fill-picker-ci-h", Math.round(hsl.h));
10750
+ set("fig-fill-picker-ci-s", Math.round(hsl.s));
10751
+ set("fig-fill-picker-ci-l", Math.round(hsl.l));
10752
+ break;
10753
+ }
10754
+ case "hsb":
10755
+ set("fig-fill-picker-ci-h", Math.round(this.#color.h));
10756
+ set("fig-fill-picker-ci-s", Math.round(this.#color.s));
10757
+ set("fig-fill-picker-ci-v", Math.round(this.#color.v));
10758
+ break;
10759
+ case "lab": {
10760
+ const lab = this.#rgbToOKLAB(rgb);
10761
+ set("fig-fill-picker-ci-okl", Math.round(lab.l * 100));
10762
+ set("fig-fill-picker-ci-oka", +lab.a.toFixed(3));
10763
+ set("fig-fill-picker-ci-okb", +lab.b.toFixed(3));
10764
+ break;
10765
+ }
10766
+ case "lch": {
10767
+ const lch = this.#rgbToOKLCH(rgb);
10768
+ set("fig-fill-picker-ci-okl", Math.round(lch.l * 100));
10769
+ set("fig-fill-picker-ci-okc", +lch.c.toFixed(3));
10770
+ set("fig-fill-picker-ci-okh", Math.round(lch.h));
10771
+ break;
10772
+ }
10773
+ default: // hex
10774
+ set("fig-fill-picker-ci-hex", hex.replace(/^#/, "").toUpperCase());
10775
+ break;
9985
10776
  }
9986
10777
 
9987
10778
  if (this.#opacitySlider) {
@@ -9998,7 +10789,7 @@ class FigFillPicker extends HTMLElement {
9998
10789
  const expAttr = experimental ? `experimental="${experimental}"` : "";
9999
10790
 
10000
10791
  container.innerHTML = `
10001
- <div class="fig-fill-picker-gradient-header">
10792
+ <fig-field class="fig-fill-picker-gradient-header" direction="horizontal">
10002
10793
  <fig-dropdown class="fig-fill-picker-gradient-type" ${expAttr} value="${
10003
10794
  this.#gradient.type
10004
10795
  }">
@@ -10024,18 +10815,39 @@ class FigFillPicker extends HTMLElement {
10024
10815
  <span class="fig-mask-icon" style="--icon: var(--icon-swap)"></span>
10025
10816
  </fig-button>
10026
10817
  </fig-tooltip>
10027
- </div>
10028
- <div class="fig-fill-picker-gradient-preview">
10818
+ </fig-field>
10819
+ <fig-preview class="fig-fill-picker-gradient-preview">
10029
10820
  <div class="fig-fill-picker-gradient-bar"></div>
10030
10821
  <div class="fig-fill-picker-gradient-stops-handles"></div>
10031
- </div>
10822
+ </fig-preview>
10823
+ <fig-field class="fig-fill-picker-gradient-interpolation" direction="horizontal">
10824
+ <label>Mixing</label>
10825
+ <fig-dropdown class="fig-fill-picker-gradient-space" full ${expAttr} value="${
10826
+ this.#gradient.interpolationSpace === "oklch"
10827
+ ? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
10828
+ : this.#gradient.interpolationSpace
10829
+ }">
10830
+ <optgroup label="sRGB">
10831
+ <option value="srgb-linear">Linear</option>
10832
+ </optgroup>
10833
+ <optgroup label="OKLab">
10834
+ <option value="oklab">Perceptual</option>
10835
+ </optgroup>
10836
+ <optgroup label="OKLCH">
10837
+ <option value="oklch-shorter">Shorter hue</option>
10838
+ <option value="oklch-longer">Longer hue</option>
10839
+ <option value="oklch-increasing">Increasing hue</option>
10840
+ <option value="oklch-decreasing">Decreasing hue</option>
10841
+ </optgroup>
10842
+ </fig-dropdown>
10843
+ </fig-field>
10032
10844
  <div class="fig-fill-picker-gradient-stops">
10033
- <div class="fig-fill-picker-gradient-stops-header">
10845
+ <fig-header class="fig-fill-picker-gradient-stops-header" borderless>
10034
10846
  <span>Stops</span>
10035
10847
  <fig-button icon variant="ghost" class="fig-fill-picker-gradient-add" title="Add stop">
10036
10848
  <span class="fig-mask-icon" style="--icon: var(--icon-add)"></span>
10037
10849
  </fig-button>
10038
- </div>
10850
+ </fig-header>
10039
10851
  <div class="fig-fill-picker-gradient-stops-list"></div>
10040
10852
  </div>
10041
10853
  `;
@@ -10049,11 +10861,41 @@ class FigFillPicker extends HTMLElement {
10049
10861
  const typeDropdown = container.querySelector(
10050
10862
  ".fig-fill-picker-gradient-type",
10051
10863
  );
10052
- typeDropdown.addEventListener("change", (e) => {
10053
- this.#gradient.type = e.target.value;
10864
+ const getDropdownValue = (event) =>
10865
+ event.currentTarget?.value ?? event.target?.value ?? event.detail;
10866
+
10867
+ const handleTypeChange = (e) => {
10868
+ this.#gradient.type = getDropdownValue(e);
10054
10869
  this.#updateGradientUI();
10055
10870
  this.#emitInput();
10056
- });
10871
+ };
10872
+ typeDropdown.addEventListener("input", handleTypeChange);
10873
+ typeDropdown.addEventListener("change", handleTypeChange);
10874
+
10875
+ const interpolationDropdown = container.querySelector(
10876
+ ".fig-fill-picker-gradient-space",
10877
+ );
10878
+ const handleInterpolationChange = (e) => {
10879
+ const val = getDropdownValue(e);
10880
+ let space = val;
10881
+ let hue = "shorter";
10882
+ if (val.startsWith("oklch-")) {
10883
+ space = "oklch";
10884
+ hue = val.slice(6);
10885
+ }
10886
+ this.#gradient = normalizeGradientConfig({
10887
+ ...this.#gradient,
10888
+ interpolationSpace: space,
10889
+ hueInterpolation: hue,
10890
+ });
10891
+ this.#updateGradientUI();
10892
+ this.#emitInput();
10893
+ };
10894
+ interpolationDropdown?.addEventListener("input", handleInterpolationChange);
10895
+ interpolationDropdown?.addEventListener(
10896
+ "change",
10897
+ handleInterpolationChange,
10898
+ );
10057
10899
 
10058
10900
  // Angle input
10059
10901
  // Convert from fig-input-angle coordinates (0° = right) to CSS coordinates (0° = up)
@@ -10114,6 +10956,7 @@ class FigFillPicker extends HTMLElement {
10114
10956
 
10115
10957
  const container = this.#dialog.querySelector('[data-tab="gradient"]');
10116
10958
  if (!container) return;
10959
+ this.#gradient = normalizeGradientConfig(this.#gradient);
10117
10960
 
10118
10961
  // Show/hide angle vs center inputs
10119
10962
  const angleInput = container.querySelector(
@@ -10134,6 +10977,16 @@ class FigFillPicker extends HTMLElement {
10134
10977
  angleInput.setAttribute("value", pickerAngle);
10135
10978
  }
10136
10979
 
10980
+ const interpolationDropdown = container.querySelector(
10981
+ ".fig-fill-picker-gradient-space",
10982
+ );
10983
+ if (interpolationDropdown) {
10984
+ interpolationDropdown.value =
10985
+ this.#gradient.interpolationSpace === "oklch"
10986
+ ? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
10987
+ : this.#gradient.interpolationSpace;
10988
+ }
10989
+
10137
10990
  this.#updateGradientPreview();
10138
10991
  this.#updateGradientStopsList();
10139
10992
  }
@@ -10141,9 +10994,14 @@ class FigFillPicker extends HTMLElement {
10141
10994
  #updateGradientPreview() {
10142
10995
  if (!this.#dialog) return;
10143
10996
 
10997
+ const preview = this.#dialog.querySelector(
10998
+ ".fig-fill-picker-gradient-preview",
10999
+ );
10144
11000
  const bar = this.#dialog.querySelector(".fig-fill-picker-gradient-bar");
10145
- if (bar) {
10146
- bar.style.background = this.#getGradientCSS();
11001
+ if (preview || bar) {
11002
+ const css = this.#getGradientCSS();
11003
+ if (bar) bar.style.background = css;
11004
+ if (preview) preview.style.background = css;
10147
11005
  }
10148
11006
 
10149
11007
  this.#updateChit();
@@ -10160,7 +11018,7 @@ class FigFillPicker extends HTMLElement {
10160
11018
  list.innerHTML = this.#gradient.stops
10161
11019
  .map(
10162
11020
  (stop, index) => `
10163
- <div class="fig-fill-picker-gradient-stop-row" data-index="${index}">
11021
+ <fig-field class="fig-fill-picker-gradient-stop-row" direction="horizontal" data-index="${index}">
10164
11022
  <fig-input-number class="fig-fill-picker-stop-position" min="0" max="100" value="${
10165
11023
  stop.position
10166
11024
  }" units="%"></fig-input-number>
@@ -10172,7 +11030,7 @@ class FigFillPicker extends HTMLElement {
10172
11030
  }>
10173
11031
  <span class="fig-mask-icon" style="--icon: var(--icon-minus)"></span>
10174
11032
  </fig-button>
10175
- </div>
11033
+ </fig-field>
10176
11034
  `,
10177
11035
  )
10178
11036
  .join("");
@@ -10226,30 +11084,52 @@ class FigFillPicker extends HTMLElement {
10226
11084
  });
10227
11085
  }
10228
11086
 
10229
- #getGradientCSS() {
10230
- const stops = this.#gradient.stops
11087
+ #buildGradientCSS(interpolationSpaceOverride, includeInterpolation = true) {
11088
+ const gradient = normalizeGradientConfig({
11089
+ ...this.#gradient,
11090
+ interpolationSpace:
11091
+ interpolationSpaceOverride ?? this.#gradient.interpolationSpace,
11092
+ });
11093
+ const isP3 = this.#gamut === "display-p3";
11094
+ const stops = gradient.stops
10231
11095
  .map((s) => {
10232
- const rgba = this.#hexToRGBA(s.color, s.opacity / 100);
10233
- return `${rgba} ${s.position}%`;
11096
+ const color = isP3
11097
+ ? this.#hexToP3(s.color, s.opacity / 100)
11098
+ : this.#hexToRGBA(s.color, s.opacity / 100);
11099
+ return `${color} ${s.position}%`;
10234
11100
  })
10235
11101
  .join(", ");
10236
-
10237
- switch (this.#gradient.type) {
11102
+ const interpolation = includeInterpolation
11103
+ ? ` ${gradientInterpolationClause(gradient)}`
11104
+ : "";
11105
+ switch (gradient.type) {
10238
11106
  case "linear":
10239
- return `linear-gradient(${this.#gradient.angle}deg, ${stops})`;
11107
+ return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
10240
11108
  case "radial":
10241
- return `radial-gradient(circle at ${this.#gradient.centerX}% ${
10242
- this.#gradient.centerY
10243
- }%, ${stops})`;
11109
+ return `radial-gradient(circle at ${gradient.centerX}% ${gradient.centerY}%${interpolation}, ${stops})`;
10244
11110
  case "angular":
10245
- // Internal gradient.angle is already in CSS coordinate system (0° = top)
10246
- // because it's converted when reading from fig-input-angle
10247
- return `conic-gradient(from ${this.#gradient.angle}deg, ${stops})`;
11111
+ return `conic-gradient(from ${gradient.angle}deg${interpolation}, ${stops})`;
10248
11112
  default:
10249
- return `linear-gradient(${this.#gradient.angle}deg, ${stops})`;
11113
+ return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
10250
11114
  }
10251
11115
  }
10252
11116
 
11117
+ #testGradientSupport(css) {
11118
+ const el = document.createElement("div");
11119
+ el.style.background = css;
11120
+ return !!el.style.background;
11121
+ }
11122
+
11123
+ #getGradientCSS() {
11124
+ const preferred = this.#buildGradientCSS(undefined, true);
11125
+ if (this.#testGradientSupport(preferred)) return preferred;
11126
+
11127
+ const oklabFallback = this.#buildGradientCSS("oklab", true);
11128
+ if (this.#testGradientSupport(oklabFallback)) return oklabFallback;
11129
+
11130
+ return this.#buildGradientCSS("oklab", false);
11131
+ }
11132
+
10253
11133
  // ============ IMAGE TAB ============
10254
11134
  #initImageTab() {
10255
11135
  const container = this.#dialog.querySelector('[data-tab="image"]');
@@ -10257,7 +11137,7 @@ class FigFillPicker extends HTMLElement {
10257
11137
  const expAttr = experimental ? `experimental="${experimental}"` : "";
10258
11138
 
10259
11139
  container.innerHTML = `
10260
- <div class="fig-fill-picker-media-header">
11140
+ <fig-field class="fig-fill-picker-media-header" direction="horizontal">
10261
11141
  <fig-dropdown class="fig-fill-picker-scale-mode" ${expAttr} value="${
10262
11142
  this.#image.scaleMode
10263
11143
  }">
@@ -10269,7 +11149,7 @@ class FigFillPicker extends HTMLElement {
10269
11149
  <fig-input-number class="fig-fill-picker-scale" min="1" max="200" value="${
10270
11150
  this.#image.scale
10271
11151
  }" units="%" style="display: none;"></fig-input-number>
10272
- </div>
11152
+ </fig-field>
10273
11153
  <div class="fig-fill-picker-media-preview">
10274
11154
  <div class="fig-fill-picker-checkerboard"></div>
10275
11155
  <div class="fig-fill-picker-image-preview"></div>
@@ -10411,7 +11291,7 @@ class FigFillPicker extends HTMLElement {
10411
11291
  const expAttr = experimental ? `experimental="${experimental}"` : "";
10412
11292
 
10413
11293
  container.innerHTML = `
10414
- <div class="fig-fill-picker-media-header">
11294
+ <fig-field class="fig-fill-picker-media-header" direction="horizontal">
10415
11295
  <fig-dropdown class="fig-fill-picker-scale-mode" ${expAttr} value="${
10416
11296
  this.#video.scaleMode
10417
11297
  }">
@@ -10419,7 +11299,7 @@ class FigFillPicker extends HTMLElement {
10419
11299
  <option value="fit">Fit</option>
10420
11300
  <option value="crop">Crop</option>
10421
11301
  </fig-dropdown>
10422
- </div>
11302
+ </fig-field>
10423
11303
  <div class="fig-fill-picker-media-preview">
10424
11304
  <div class="fig-fill-picker-checkerboard"></div>
10425
11305
  <video class="fig-fill-picker-video-preview" style="display: none;" muted loop></video>
@@ -10509,13 +11389,13 @@ class FigFillPicker extends HTMLElement {
10509
11389
  <span>Camera access required</span>
10510
11390
  </div>
10511
11391
  </div>
10512
- <div class="fig-fill-picker-webcam-controls">
11392
+ <fig-field class="fig-fill-picker-webcam-controls" direction="horizontal">
10513
11393
  <fig-dropdown class="fig-fill-picker-camera-select" ${expAttr} style="display: none;">
10514
11394
  </fig-dropdown>
10515
11395
  <fig-button class="fig-fill-picker-webcam-capture" variant="primary">
10516
11396
  Capture
10517
11397
  </fig-button>
10518
- </div>
11398
+ </fig-field>
10519
11399
  `;
10520
11400
 
10521
11401
  this.#setupWebcamEvents(container);
@@ -10739,6 +11619,13 @@ class FigFillPicker extends HTMLElement {
10739
11619
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
10740
11620
  }
10741
11621
 
11622
+ #hexToP3(hex, alpha = 1) {
11623
+ const r = +(parseInt(hex.slice(1, 3), 16) / 255).toFixed(4);
11624
+ const g = +(parseInt(hex.slice(3, 5), 16) / 255).toFixed(4);
11625
+ const b = +(parseInt(hex.slice(5, 7), 16) / 255).toFixed(4);
11626
+ return `color(display-p3 ${r} ${g} ${b} / ${alpha})`;
11627
+ }
11628
+
10742
11629
  #rgbToHSL(rgb) {
10743
11630
  const r = rgb.r / 255;
10744
11631
  const g = rgb.g / 255;
@@ -10843,6 +11730,37 @@ class FigFillPicker extends HTMLElement {
10843
11730
  };
10844
11731
  }
10845
11732
 
11733
+ #oklabToRGB(lab) {
11734
+ const l_ = lab.l + 0.3963377774 * lab.a + 0.2158037573 * lab.b;
11735
+ const m_ = lab.l - 0.1055613458 * lab.a - 0.0638541728 * lab.b;
11736
+ const s_ = lab.l - 0.0894841775 * lab.a - 1.291485548 * lab.b;
11737
+
11738
+ const l = l_ * l_ * l_;
11739
+ const m = m_ * m_ * m_;
11740
+ const s = s_ * s_ * s_;
11741
+
11742
+ const toSRGB = (c) => {
11743
+ const v =
11744
+ c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
11745
+ return Math.round(Math.max(0, Math.min(1, v)) * 255);
11746
+ };
11747
+
11748
+ return {
11749
+ r: toSRGB(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
11750
+ g: toSRGB(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
11751
+ b: toSRGB(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
11752
+ };
11753
+ }
11754
+
11755
+ #oklchToRGB(lch) {
11756
+ const hRad = (lch.h * Math.PI) / 180;
11757
+ return this.#oklabToRGB({
11758
+ l: lch.l,
11759
+ a: lch.c * Math.cos(hRad),
11760
+ b: lch.c * Math.sin(hRad),
11761
+ });
11762
+ }
11763
+
10846
11764
  // ============ EVENT EMITTERS ============
10847
11765
  #emitInput() {
10848
11766
  this.#updateChit();
@@ -10865,7 +11783,7 @@ class FigFillPicker extends HTMLElement {
10865
11783
 
10866
11784
  // ============ PUBLIC API ============
10867
11785
  get value() {
10868
- const base = { type: this.#fillType };
11786
+ const base = { type: this.#fillType, colorSpace: this.#gamut };
10869
11787
 
10870
11788
  switch (this.#fillType) {
10871
11789
  case "solid":
@@ -10878,7 +11796,7 @@ class FigFillPicker extends HTMLElement {
10878
11796
  case "gradient":
10879
11797
  return {
10880
11798
  ...base,
10881
- gradient: { ...this.#gradient },
11799
+ gradient: gradientToValueShape(this.#gradient),
10882
11800
  css: this.#getGradientCSS(),
10883
11801
  };
10884
11802
  case "image":
@@ -10945,6 +11863,237 @@ class FigFillPicker extends HTMLElement {
10945
11863
  }
10946
11864
  customElements.define("fig-fill-picker", FigFillPicker);
10947
11865
 
11866
+ /* Color Tip */
11867
+ /**
11868
+ * A compact solid-color tip that wraps fig-fill-picker.
11869
+ * @attr {string} value - Solid color string (hex/rgb/hsl/named)
11870
+ * @attr {boolean} selected - Whether the tip is selected
11871
+ * @attr {boolean} disabled - Whether the tip is disabled
11872
+ * @fires input - While color changes
11873
+ * @fires change - When color is committed
11874
+ */
11875
+ class FigColorTip extends HTMLElement {
11876
+ #fillPicker = null;
11877
+ #chit = null;
11878
+ #boundHandleInput = this.#handlePickerInput.bind(this);
11879
+ #boundHandleChange = this.#handlePickerChange.bind(this);
11880
+
11881
+ static get observedAttributes() {
11882
+ return ["value", "selected", "disabled", "alpha"];
11883
+ }
11884
+
11885
+ connectedCallback() {
11886
+ this.#render();
11887
+ this.#syncFromAttributes();
11888
+ }
11889
+
11890
+ disconnectedCallback() {
11891
+ this.#teardownListeners();
11892
+ }
11893
+
11894
+ #teardownListeners() {
11895
+ if (!this.#fillPicker) return;
11896
+ this.#fillPicker.removeEventListener("input", this.#boundHandleInput);
11897
+ this.#fillPicker.removeEventListener("change", this.#boundHandleChange);
11898
+ }
11899
+
11900
+ #watchPickerDialog = () => {
11901
+ requestAnimationFrame(() => {
11902
+ const dialog = document.querySelector(".fig-fill-picker-dialog[open]");
11903
+ if (!dialog) return;
11904
+ dialog.addEventListener("close", () => this.removeAttribute("selected"), {
11905
+ once: true,
11906
+ });
11907
+ });
11908
+ };
11909
+
11910
+ get #alphaEnabled() {
11911
+ const v = this.getAttribute("alpha");
11912
+ return v === null || v !== "false";
11913
+ }
11914
+
11915
+ #render() {
11916
+ const color = this.#normalizeColor(this.getAttribute("value"));
11917
+ const alphaAttr = this.#alphaEnabled ? "" : 'alpha="false"';
11918
+ this.innerHTML = `
11919
+ <fig-fill-picker mode="solid" ${alphaAttr} value='${JSON.stringify({ type: "solid", color })}'>
11920
+ <fig-chit background="${color}"></fig-chit>
11921
+ </fig-fill-picker>`;
11922
+
11923
+ this.#fillPicker = this.querySelector("fig-fill-picker");
11924
+ this.#chit = this.querySelector("fig-chit");
11925
+ this.#teardownListeners();
11926
+ this.#fillPicker?.addEventListener("input", this.#boundHandleInput);
11927
+ this.#fillPicker?.addEventListener("change", this.#boundHandleChange);
11928
+ this.#chit?.addEventListener("click", () => {
11929
+ this.setAttribute("selected", "");
11930
+ this.#watchPickerDialog();
11931
+ });
11932
+ }
11933
+
11934
+ #normalizeHex(hex) {
11935
+ if (!hex) return "#D9D9D9";
11936
+ const raw = hex.replace("#", "").trim();
11937
+ if (raw.length === 3 || raw.length === 4) {
11938
+ const [r, g, b] = raw;
11939
+ return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
11940
+ }
11941
+ if (raw.length === 6 || raw.length === 8) {
11942
+ return `#${raw.slice(0, 6)}`.toUpperCase();
11943
+ }
11944
+ return "#D9D9D9";
11945
+ }
11946
+
11947
+ #normalizeColor(colorValue) {
11948
+ if (!colorValue) return "#D9D9D9";
11949
+ const value = String(colorValue).trim();
11950
+
11951
+ if (value.startsWith("{")) {
11952
+ try {
11953
+ const parsed = JSON.parse(value);
11954
+ if (parsed?.color) {
11955
+ return this.#normalizeColor(parsed.color);
11956
+ }
11957
+ } catch {
11958
+ // Ignore parse errors and continue.
11959
+ }
11960
+ }
11961
+
11962
+ if (value.startsWith("#")) {
11963
+ return this.#normalizeHex(value);
11964
+ }
11965
+
11966
+ try {
11967
+ const ctx = document.createElement("canvas").getContext("2d");
11968
+ if (!ctx) return "#D9D9D9";
11969
+ ctx.fillStyle = value;
11970
+ const resolved = ctx.fillStyle;
11971
+ if (resolved.startsWith("#")) {
11972
+ return this.#normalizeHex(resolved);
11973
+ }
11974
+ const rgb = resolved.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
11975
+ if (rgb) {
11976
+ const toHex = (v) =>
11977
+ Math.max(0, Math.min(255, Number(v)))
11978
+ .toString(16)
11979
+ .padStart(2, "0");
11980
+ return `#${toHex(rgb[1])}${toHex(rgb[2])}${toHex(rgb[3])}`.toUpperCase();
11981
+ }
11982
+ } catch {
11983
+ // Fall through to default.
11984
+ }
11985
+
11986
+ return "#D9D9D9";
11987
+ }
11988
+
11989
+ #syncFromAttributes() {
11990
+ const color = this.#normalizeColor(this.getAttribute("value"));
11991
+ if (this.getAttribute("value") !== color) {
11992
+ this.setAttribute("value", color);
11993
+ return;
11994
+ }
11995
+
11996
+ if (this.#fillPicker) {
11997
+ this.#fillPicker.setAttribute(
11998
+ "value",
11999
+ JSON.stringify({ type: "solid", color }),
12000
+ );
12001
+ if (this.#alphaEnabled) {
12002
+ this.#fillPicker.removeAttribute("alpha");
12003
+ } else {
12004
+ this.#fillPicker.setAttribute("alpha", "false");
12005
+ }
12006
+ if (this.hasAttribute("disabled")) {
12007
+ this.#fillPicker.setAttribute("disabled", "");
12008
+ } else {
12009
+ this.#fillPicker.removeAttribute("disabled");
12010
+ }
12011
+ }
12012
+
12013
+ if (this.#chit) {
12014
+ this.#chit.setAttribute("background", color);
12015
+ if (this.hasAttribute("disabled")) {
12016
+ this.#chit.setAttribute("disabled", "");
12017
+ } else {
12018
+ this.#chit.removeAttribute("disabled");
12019
+ }
12020
+ }
12021
+ }
12022
+
12023
+ #updateColorFromPicker(detail, type) {
12024
+ const nextColor = this.#normalizeColor(detail?.color);
12025
+ const prevColor = this.#normalizeColor(this.getAttribute("value"));
12026
+ if (nextColor !== prevColor) {
12027
+ this.setAttribute("value", nextColor);
12028
+ } else {
12029
+ this.#syncFromAttributes();
12030
+ }
12031
+
12032
+ const eventDetail = { color: this.value };
12033
+ if (this.#alphaEnabled && detail?.opacity !== undefined) {
12034
+ eventDetail.opacity = detail.opacity;
12035
+ }
12036
+
12037
+ this.dispatchEvent(
12038
+ new CustomEvent(type, {
12039
+ bubbles: true,
12040
+ cancelable: true,
12041
+ composed: true,
12042
+ detail: eventDetail,
12043
+ }),
12044
+ );
12045
+ }
12046
+
12047
+ #handlePickerInput(event) {
12048
+ event.stopPropagation();
12049
+ this.#updateColorFromPicker(event.detail, "input");
12050
+ }
12051
+
12052
+ #handlePickerChange(event) {
12053
+ event.stopPropagation();
12054
+ this.#updateColorFromPicker(event.detail, "change");
12055
+ }
12056
+
12057
+ attributeChangedCallback(name, oldValue, newValue) {
12058
+ if (oldValue === newValue) return;
12059
+ if (!this.isConnected) return;
12060
+
12061
+ switch (name) {
12062
+ case "value":
12063
+ case "selected":
12064
+ case "disabled":
12065
+ this.#syncFromAttributes();
12066
+ break;
12067
+ }
12068
+ }
12069
+
12070
+ get value() {
12071
+ return this.#normalizeColor(this.getAttribute("value"));
12072
+ }
12073
+ set value(value) {
12074
+ if (value === null || value === undefined || value === "") {
12075
+ this.removeAttribute("value");
12076
+ return;
12077
+ }
12078
+ this.setAttribute("value", this.#normalizeColor(value));
12079
+ }
12080
+
12081
+ get selected() {
12082
+ return this.hasAttribute("selected");
12083
+ }
12084
+ set selected(value) {
12085
+ this.toggleAttribute("selected", Boolean(value));
12086
+ }
12087
+
12088
+ get disabled() {
12089
+ return this.hasAttribute("disabled");
12090
+ }
12091
+ set disabled(value) {
12092
+ this.toggleAttribute("disabled", Boolean(value));
12093
+ }
12094
+ }
12095
+ customElements.define("fig-color-tip", FigColorTip);
12096
+
10948
12097
  /* Choice */
10949
12098
  /**
10950
12099
  * A generic choice container for use within FigChooser.
@@ -11509,8 +12658,354 @@ customElements.define("fig-chooser", FigChooser);
11509
12658
 
11510
12659
  /* Handle */
11511
12660
  class FigHandle extends HTMLElement {
11512
- constructor() {
11513
- super();
12661
+ static observedAttributes = [
12662
+ "color",
12663
+ "selected",
12664
+ "disabled",
12665
+ "drag",
12666
+ "drag-surface",
12667
+ "drag-axes",
12668
+ "drag-snapping",
12669
+ "value",
12670
+ "type",
12671
+ ];
12672
+
12673
+ #isDragging = false;
12674
+ #didDrag = false;
12675
+ #boundPointerDown = null;
12676
+ #applyingValue = false;
12677
+ #colorTip = null;
12678
+
12679
+ get #dragEnabled() {
12680
+ const v = this.getAttribute("drag");
12681
+ return v !== null && v !== "false";
12682
+ }
12683
+
12684
+ get #axes() {
12685
+ const v = (this.getAttribute("drag-axes") || "x,y").toLowerCase();
12686
+ return { x: v.includes("x"), y: v.includes("y") };
12687
+ }
12688
+
12689
+ get #dragSnappingMode() {
12690
+ const raw = this.getAttribute("drag-snapping");
12691
+ if (raw === null) return "false";
12692
+ const normalized = raw.trim().toLowerCase();
12693
+ if (normalized === "modifier") return "modifier";
12694
+ if (normalized === "" || normalized === "true") return "true";
12695
+ return "false";
12696
+ }
12697
+
12698
+ #shouldSnap(shiftKey) {
12699
+ const mode = this.#dragSnappingMode;
12700
+ if (mode === "true") return true;
12701
+ if (mode === "modifier") return !!shiftKey;
12702
+ return false;
12703
+ }
12704
+
12705
+ #snapGuide(value) {
12706
+ if (value < 0.1) return 0;
12707
+ if (value > 0.9) return 1;
12708
+ if (value > 0.4 && value < 0.6) return 0.5;
12709
+ return value;
12710
+ }
12711
+
12712
+ #snapDiagonal(x, y) {
12713
+ const diff = Math.abs(x - y);
12714
+ if (diff < 0.1) {
12715
+ const avg = (x + y) / 2;
12716
+ return { x: avg, y: avg };
12717
+ }
12718
+ if (Math.abs(1 - x - y) < 0.1) return { x, y: 1 - x };
12719
+ return { x, y };
12720
+ }
12721
+
12722
+ #getContainer() {
12723
+ const attr = this.getAttribute("drag-surface");
12724
+ if (!attr || attr === "parent") return this.parentElement;
12725
+ return this.closest(attr);
12726
+ }
12727
+
12728
+ get value() {
12729
+ const container = this.#getContainer();
12730
+ if (!container) return "0% 0%";
12731
+ const rect = container.getBoundingClientRect();
12732
+ const hw = this.offsetWidth / 2;
12733
+ const hh = this.offsetHeight / 2;
12734
+ const x = parseFloat(this.style.left) || 0;
12735
+ const y = parseFloat(this.style.top) || 0;
12736
+ const px = rect.width > 0 ? ((x + hw) / rect.width) * 100 : 0;
12737
+ const py = rect.height > 0 ? ((y + hh) / rect.height) * 100 : 0;
12738
+ return `${Math.round(px)}% ${Math.round(py)}%`;
12739
+ }
12740
+
12741
+ set value(v) {
12742
+ this.setAttribute("value", v ?? "0% 0%");
12743
+ }
12744
+
12745
+ #parseValue(str) {
12746
+ const normalized = str == null ? "" : String(str).trim();
12747
+ if (!normalized) return { xPct: 0, yPct: 0 };
12748
+
12749
+ const parts = normalized.split(/[\s,]+/).filter(Boolean);
12750
+
12751
+ const parseToken = (token) => {
12752
+ if (!token) return 0;
12753
+ const hasPx = token.includes("px");
12754
+ const hasPct = token.includes("%");
12755
+ const numeric = parseFloat(token.replace(/[%px]/g, ""));
12756
+ if (!Number.isFinite(numeric)) return 0;
12757
+ if (hasPx) return { px: numeric };
12758
+ if (hasPct || Math.abs(numeric) > 1)
12759
+ return Math.max(0, Math.min(100, numeric));
12760
+ return Math.max(0, Math.min(100, numeric * 100));
12761
+ };
12762
+
12763
+ const xToken = parseToken(parts[0]);
12764
+ const yToken = parseToken(parts[1] ?? parts[0]);
12765
+ return { xToken, yToken };
12766
+ }
12767
+
12768
+ #applyValue(str) {
12769
+ const container = this.#getContainer();
12770
+ if (!container) return;
12771
+
12772
+ const { xToken, yToken } = this.#parseValue(str);
12773
+ const rect = container.getBoundingClientRect();
12774
+ const hw = this.offsetWidth / 2;
12775
+ const hh = this.offsetHeight / 2;
12776
+
12777
+ const resolve = (token, containerDim, halfHandle) => {
12778
+ if (token && typeof token === "object" && "px" in token) {
12779
+ return Math.max(
12780
+ -halfHandle,
12781
+ Math.min(containerDim - halfHandle, token.px - halfHandle),
12782
+ );
12783
+ }
12784
+ const pct = typeof token === "number" ? token : 0;
12785
+ const center = (pct / 100) * containerDim;
12786
+ return Math.max(
12787
+ -halfHandle,
12788
+ Math.min(containerDim - halfHandle, center - halfHandle),
12789
+ );
12790
+ };
12791
+
12792
+ const axes = this.#axes;
12793
+ if (axes.x) this.style.left = `${resolve(xToken, rect.width, hw)}px`;
12794
+ if (axes.y) this.style.top = `${resolve(yToken, rect.height, hh)}px`;
12795
+ }
12796
+
12797
+ #syncValueAttribute() {
12798
+ this.#applyingValue = true;
12799
+ this.setAttribute("value", this.value);
12800
+ this.#applyingValue = false;
12801
+ }
12802
+
12803
+ connectedCallback() {
12804
+ this.#syncDrag();
12805
+ this.addEventListener("click", this.#handleSelect);
12806
+ document.addEventListener("pointerdown", this.#handleDeselect);
12807
+ const initial = this.getAttribute("value");
12808
+ if (initial) this.#applyValue(initial);
12809
+ }
12810
+
12811
+ disconnectedCallback() {
12812
+ this.#teardownDrag();
12813
+ this.#hideColorTip();
12814
+ this.removeEventListener("click", this.#handleSelect);
12815
+ document.removeEventListener("pointerdown", this.#handleDeselect);
12816
+ }
12817
+
12818
+ #handleSelect = (e) => {
12819
+ if (this.hasAttribute("disabled")) return;
12820
+ if (this.#didDrag) {
12821
+ this.#didDrag = false;
12822
+ return;
12823
+ }
12824
+ this.setAttribute("selected", "");
12825
+ if (this.getAttribute("type") === "color") this.#showColorTip();
12826
+ };
12827
+
12828
+ #handleDeselect = (e) => {
12829
+ if (this.contains(e.target)) return;
12830
+ if (this.#colorTip && e.target.closest?.("dialog, [popover]")) return;
12831
+ this.removeAttribute("selected");
12832
+ this.#hideColorTip();
12833
+ };
12834
+
12835
+ attributeChangedCallback(name, _old, value) {
12836
+ if (name === "color") {
12837
+ if (!value || value === "false") {
12838
+ this.style.removeProperty("--fill");
12839
+ } else {
12840
+ this.style.setProperty("--fill", value);
12841
+ }
12842
+ }
12843
+ if (name === "drag") this.#syncDrag();
12844
+ if (name === "value" && !this.#applyingValue && !this.#isDragging) {
12845
+ this.#applyValue(value);
12846
+ }
12847
+ }
12848
+
12849
+ #syncDrag() {
12850
+ if (this.#dragEnabled && !this.#boundPointerDown) {
12851
+ this.#boundPointerDown = (e) => this.#onPointerDown(e);
12852
+ this.addEventListener("pointerdown", this.#boundPointerDown);
12853
+ } else if (!this.#dragEnabled && this.#boundPointerDown) {
12854
+ this.#teardownDrag();
12855
+ }
12856
+ }
12857
+
12858
+ #teardownDrag() {
12859
+ if (this.#boundPointerDown) {
12860
+ this.removeEventListener("pointerdown", this.#boundPointerDown);
12861
+ this.#boundPointerDown = null;
12862
+ }
12863
+ this.#isDragging = false;
12864
+ }
12865
+
12866
+ #onPointerDown(e) {
12867
+ if (!this.#dragEnabled || this.hasAttribute("disabled")) return;
12868
+ e.preventDefault();
12869
+ const container = this.#getContainer();
12870
+ if (!container) return;
12871
+
12872
+ this.#isDragging = true;
12873
+ const axes = this.#axes;
12874
+ const containerRect = container.getBoundingClientRect();
12875
+ const handleW = this.offsetWidth;
12876
+ const handleH = this.offsetHeight;
12877
+
12878
+ const clampAndApply = (clientX, clientY, shiftKey = false) => {
12879
+ const rect = container.getBoundingClientRect();
12880
+ const currentLeft = parseFloat(this.style.left) || 0;
12881
+ const currentTop = parseFloat(this.style.top) || 0;
12882
+ const rawX = clientX - rect.left - handleW / 2;
12883
+ const rawY = clientY - rect.top - handleH / 2;
12884
+
12885
+ const clampedX = Math.max(
12886
+ -handleW / 2,
12887
+ Math.min(rect.width - handleW / 2, rawX),
12888
+ );
12889
+ const clampedY = Math.max(
12890
+ -handleH / 2,
12891
+ Math.min(rect.height - handleH / 2, rawY),
12892
+ );
12893
+
12894
+ let centerX =
12895
+ rect.width > 0
12896
+ ? ((axes.x ? clampedX : currentLeft) + handleW / 2) / rect.width
12897
+ : 0.5;
12898
+ let centerY =
12899
+ rect.height > 0
12900
+ ? ((axes.y ? clampedY : currentTop) + handleH / 2) / rect.height
12901
+ : 0.5;
12902
+
12903
+ if (this.#shouldSnap(shiftKey)) {
12904
+ if (axes.x) centerX = this.#snapGuide(centerX);
12905
+ if (axes.y) centerY = this.#snapGuide(centerY);
12906
+ if (axes.x && axes.y) {
12907
+ const diagonal = this.#snapDiagonal(centerX, centerY);
12908
+ centerX = diagonal.x;
12909
+ centerY = diagonal.y;
12910
+ }
12911
+ }
12912
+
12913
+ if (axes.x) {
12914
+ const left = centerX * rect.width - handleW / 2;
12915
+ this.style.left = `${Math.max(-handleW / 2, Math.min(rect.width - handleW / 2, left))}px`;
12916
+ }
12917
+ if (axes.y) {
12918
+ const top = centerY * rect.height - handleH / 2;
12919
+ this.style.top = `${Math.max(-handleH / 2, Math.min(rect.height - handleH / 2, top))}px`;
12920
+ }
12921
+ };
12922
+
12923
+ const isColorType = this.getAttribute("type") === "color";
12924
+ if (!isColorType) {
12925
+ clampAndApply(e.clientX, e.clientY, e.shiftKey);
12926
+ }
12927
+ this.style.cursor = "grabbing";
12928
+ if (!isColorType) {
12929
+ this.dispatchEvent(
12930
+ new CustomEvent("input", {
12931
+ bubbles: true,
12932
+ detail: this.#positionDetail(containerRect),
12933
+ }),
12934
+ );
12935
+ }
12936
+
12937
+ const onMove = (e) => {
12938
+ if (!this.#isDragging) return;
12939
+ this.#didDrag = true;
12940
+ clampAndApply(e.clientX, e.clientY, e.shiftKey);
12941
+ this.dispatchEvent(
12942
+ new CustomEvent("input", {
12943
+ bubbles: true,
12944
+ detail: this.#positionDetail(container.getBoundingClientRect()),
12945
+ }),
12946
+ );
12947
+ };
12948
+
12949
+ const onUp = (e) => {
12950
+ this.#isDragging = false;
12951
+ this.style.cursor = "";
12952
+ window.removeEventListener("pointermove", onMove);
12953
+ window.removeEventListener("pointerup", onUp);
12954
+ if (this.#didDrag || !isColorType) {
12955
+ clampAndApply(e.clientX, e.clientY, e.shiftKey);
12956
+ }
12957
+ this.#syncValueAttribute();
12958
+ this.dispatchEvent(
12959
+ new CustomEvent("change", {
12960
+ bubbles: true,
12961
+ detail: this.#positionDetail(container.getBoundingClientRect()),
12962
+ }),
12963
+ );
12964
+ };
12965
+
12966
+ window.addEventListener("pointermove", onMove);
12967
+ window.addEventListener("pointerup", onUp);
12968
+ }
12969
+
12970
+ #showColorTip() {
12971
+ if (this.#colorTip) return;
12972
+ const tip = document.createElement("fig-color-tip");
12973
+ tip.setAttribute("value", this.getAttribute("color") || "#D9D9D9");
12974
+ tip.setAttribute("selected", "");
12975
+ tip.setAttribute("alpha", "true");
12976
+ tip.addEventListener("pointerdown", (e) => e.stopPropagation());
12977
+ tip.addEventListener("input", this.#handleColorTipInput);
12978
+ tip.addEventListener("change", this.#handleColorTipChange);
12979
+ this.appendChild(tip);
12980
+ this.#colorTip = tip;
12981
+ }
12982
+
12983
+ #hideColorTip() {
12984
+ if (!this.#colorTip) return;
12985
+ this.#colorTip.removeEventListener("input", this.#handleColorTipInput);
12986
+ this.#colorTip.removeEventListener("change", this.#handleColorTipChange);
12987
+ this.#colorTip.remove();
12988
+ this.#colorTip = null;
12989
+ }
12990
+
12991
+ #handleColorTipInput = (e) => {
12992
+ e.stopPropagation();
12993
+ if (e.detail?.color) this.setAttribute("color", e.detail.color);
12994
+ };
12995
+
12996
+ #handleColorTipChange = (e) => {
12997
+ e.stopPropagation();
12998
+ if (e.detail?.color) this.setAttribute("color", e.detail.color);
12999
+ };
13000
+
13001
+ #positionDetail(containerRect) {
13002
+ const hw = this.offsetWidth / 2;
13003
+ const hh = this.offsetHeight / 2;
13004
+ const x = parseFloat(this.style.left) || 0;
13005
+ const y = parseFloat(this.style.top) || 0;
13006
+ const px = containerRect.width > 0 ? (x + hw) / containerRect.width : 0;
13007
+ const py = containerRect.height > 0 ? (y + hh) / containerRect.height : 0;
13008
+ return { x, y, px, py };
11514
13009
  }
11515
13010
  }
11516
13011
  customElements.define("fig-handle", FigHandle);