@rogieking/figui3 3.13.0 → 3.14.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 (3) hide show
  1. package/components.css +51 -27
  2. package/fig.js +385 -147
  3. package/package.json +1 -1
package/components.css CHANGED
@@ -1204,10 +1204,8 @@ fig-chit {
1204
1204
  box-shadow: inset 0 0 0 1px var(--figma-color-border-selected);
1205
1205
  }
1206
1206
 
1207
+ &[size="medium"],
1207
1208
  &[size="large"] {
1208
- --size: 1.75rem;
1209
-
1210
- /* Large size: swatch fills the whole area */
1211
1209
  input[type="color"] {
1212
1210
  padding: 0;
1213
1211
  width: var(--size);
@@ -1233,7 +1231,6 @@ fig-chit {
1233
1231
  inset 0 0 0 3px var(--figma-color-bg);
1234
1232
  }
1235
1233
 
1236
- /* Large gradient/image: also fill the area */
1237
1234
  &[data-type="gradient"]::after,
1238
1235
  &[data-type="image"]::after,
1239
1236
  &[data-type="gradient"]::before,
@@ -1244,6 +1241,14 @@ fig-chit {
1244
1241
  }
1245
1242
  }
1246
1243
 
1244
+ &[size="medium"] {
1245
+ --size: 1.5rem;
1246
+ }
1247
+
1248
+ &[size="large"] {
1249
+ --size: 2rem;
1250
+ }
1251
+
1247
1252
  &[disabled] {
1248
1253
  pointer-events: none;
1249
1254
  }
@@ -3174,14 +3179,12 @@ fig-input-gradient {
3174
3179
  }
3175
3180
  fig-chit {
3176
3181
  flex: 1 1 auto;
3177
- width: 100%;
3178
- min-width: 0;
3182
+ width: 100% !important;
3183
+ min-width: 0 !important;
3179
3184
  &::before,
3180
3185
  &::after {
3181
- width: auto;
3182
- height: auto;
3186
+ width: 100% !important;
3183
3187
  place-self: stretch;
3184
- border-radius: var(--radius-large) !important;
3185
3188
  }
3186
3189
  &[data-type="gradient"]::after {
3187
3190
  width: calc(100% - 0.625rem);
@@ -3193,12 +3196,24 @@ fig-input-gradient {
3193
3196
 
3194
3197
  .fig-input-gradient-track {
3195
3198
  position: absolute;
3196
- inset: calc(var(--spacer-1) + 2px) var(--spacer-2-5);
3199
+ display: flex;
3200
+ align-items: center;
3201
+ inset: 0;
3197
3202
  pointer-events: auto;
3198
3203
 
3199
3204
  fig-handle {
3200
3205
  pointer-events: auto;
3201
3206
  cursor: default;
3207
+
3208
+ &.dragging {
3209
+ fig-color-tip {
3210
+ display: none;
3211
+ }
3212
+ }
3213
+
3214
+ &:hover {
3215
+ z-index: 5;
3216
+ }
3202
3217
  }
3203
3218
  }
3204
3219
  }
@@ -4244,12 +4259,6 @@ fig-preview {
4244
4259
  }
4245
4260
 
4246
4261
  .fig-fill-picker-color-area fig-handle {
4247
- --width: 1rem;
4248
- --height: 1rem;
4249
- --border-radius: 50%;
4250
- --box-shadow:
4251
- inset 0 0 0 0.125rem var(--handle-color),
4252
- 0px 0 0 0.5px rgba(0, 0, 0, 0.1), var(--elevation-100-canvas);
4253
4262
  z-index: 1;
4254
4263
  }
4255
4264
 
@@ -4743,9 +4752,8 @@ fig-choice {
4743
4752
  }
4744
4753
 
4745
4754
  fig-handle {
4746
- contain: strict;
4747
- --width: 0.75rem;
4748
- --height: 0.75rem;
4755
+ --width: 0.875rem;
4756
+ --height: 0.875rem;
4749
4757
  --fill: var(--figma-color-bg-brand);
4750
4758
  --border-radius: 50%;
4751
4759
  --ring-width: 1.25px;
@@ -4755,15 +4763,27 @@ fig-handle {
4755
4763
  --outline: none;
4756
4764
  --border: none;
4757
4765
 
4758
- display: inline-block;
4766
+ display: inline-grid;
4767
+ place-items: center;
4759
4768
  width: var(--width);
4760
4769
  height: var(--height);
4761
- background: var(--fill);
4770
+ background: var(--handle-color);
4762
4771
  border-radius: var(--border-radius);
4763
4772
  box-shadow: var(--box-shadow);
4764
4773
  outline: var(--outline);
4765
4774
  border: var(--border);
4766
4775
 
4776
+ &::before {
4777
+ content: "";
4778
+ color-scheme: light only;
4779
+ width: calc(var(--width) - 4px);
4780
+ height: calc(var(--height) - 4px);
4781
+ background: var(--fill);
4782
+ border-radius: var(--border-radius);
4783
+ box-shadow: inset 0 0 0 1px var(--figma-color-bordertranslucent);
4784
+ place-self: center;
4785
+ }
4786
+
4767
4787
  &[size="small"] {
4768
4788
  --width: 0.5625rem;
4769
4789
  --height: 0.5625rem;
@@ -4773,10 +4793,10 @@ fig-handle {
4773
4793
  position: absolute;
4774
4794
  touch-action: none;
4775
4795
  }
4776
-
4796
+ &:hover,
4777
4797
  &[selected]:not([selected="false"]) {
4778
4798
  outline: var(--ring-width) solid var(--figma-color-border-selected);
4779
- outline-offset: var(--ring-width);
4799
+ outline-offset: 0;
4780
4800
  }
4781
4801
 
4782
4802
  &[disabled]:not([disabled="false"]),
@@ -4786,10 +4806,9 @@ fig-handle {
4786
4806
  cursor: default;
4787
4807
  }
4788
4808
 
4789
- &[type="color"] {
4790
- contain: layout style;
4809
+ &[type="color"],
4810
+ &[control] {
4791
4811
  overflow: visible;
4792
- position: relative;
4793
4812
 
4794
4813
  fig-color-tip {
4795
4814
  position: absolute;
@@ -4812,7 +4831,7 @@ fig-color-tip {
4812
4831
  place-items: center;
4813
4832
  overflow: visible;
4814
4833
  position: relative;
4815
- color: var(--text-primary);
4834
+ color: var(--figma-color-text);
4816
4835
  color-scheme: light only;
4817
4836
  aspect-ratio: 1 / 1;
4818
4837
  flex-shrink: 0;
@@ -4825,6 +4844,11 @@ fig-color-tip {
4825
4844
  drop-shadow(0px 2.5px 6px rgba(0, 0, 0, 0.13))
4826
4845
  drop-shadow(0px 0px 0.5px rgba(0, 0, 0, 0.15));
4827
4846
 
4847
+ &[control="add"],
4848
+ &[control="remove"] {
4849
+ cursor: pointer;
4850
+ }
4851
+
4828
4852
  fig-chit {
4829
4853
  input[type="color"] {
4830
4854
  background: transparent;
package/fig.js CHANGED
@@ -659,7 +659,12 @@ class FigTooltip extends HTMLElement {
659
659
  };
660
660
  }
661
661
 
662
+ get #showPersisted() {
663
+ return this.hasAttribute("show") && this.getAttribute("show") !== "false";
664
+ }
665
+
662
666
  showDelayedPopup() {
667
+ if (this.#showPersisted) return;
663
668
  this.render();
664
669
  clearTimeout(this.timeout);
665
670
  const warm =
@@ -669,10 +674,12 @@ class FigTooltip extends HTMLElement {
669
674
  }
670
675
 
671
676
  showPopup() {
677
+ if (!this.popup) this.render();
678
+ this.popup.style.display = "block";
679
+ this.popup.style.visibility = "hidden";
672
680
  this.#repositionPopup();
673
681
  this.popup.style.opacity = "1";
674
682
  this.popup.style.visibility = "visible";
675
- this.popup.style.display = "block";
676
683
  this.popup.style.pointerEvents = "all";
677
684
  this.popup.style.zIndex = figGetHighestZIndex() + 1;
678
685
 
@@ -725,6 +732,7 @@ class FigTooltip extends HTMLElement {
725
732
  }
726
733
 
727
734
  hidePopup() {
735
+ if (this.#showPersisted) return;
728
736
  clearTimeout(this.timeout);
729
737
  clearTimeout(this.#touchTimeout);
730
738
  this.#stopObserving();
@@ -824,7 +832,28 @@ class FigTooltip extends HTMLElement {
824
832
  }
825
833
 
826
834
  static get observedAttributes() {
827
- return ["action", "delay", "open"];
835
+ return ["action", "delay", "open", "show", "text"];
836
+ }
837
+ get text() {
838
+ return this.getAttribute("text") ?? "";
839
+ }
840
+ set text(value) {
841
+ this.setAttribute("text", value);
842
+ }
843
+ #updateText(value) {
844
+ if (!this.popup) return;
845
+ const content = this.popup.firstElementChild ?? this.popup.firstChild;
846
+ if (!content) return;
847
+ content.innerText = value;
848
+ content.style.width = "";
849
+ const textNode = content.childNodes[0];
850
+ if (textNode) {
851
+ const range = document.createRange();
852
+ range.setStartBefore(textNode);
853
+ range.setEndAfter(textNode);
854
+ content.style.width = `${range.getBoundingClientRect().width}px`;
855
+ }
856
+ if (this.isOpen) this.#repositionPopup();
828
857
  }
829
858
  get open() {
830
859
  return this.hasAttribute("open") && this.getAttribute("open") === "true";
@@ -851,6 +880,17 @@ class FigTooltip extends HTMLElement {
851
880
  });
852
881
  }
853
882
  }
883
+ if (name === "show") {
884
+ const on = newValue !== null && newValue !== "false";
885
+ if (on) {
886
+ this.showPopup();
887
+ } else {
888
+ this.hidePopup();
889
+ }
890
+ }
891
+ if (name === "text") {
892
+ this.#updateText(newValue ?? "");
893
+ }
854
894
  }
855
895
 
856
896
  #hideOnChromeOpen(e) {
@@ -5651,8 +5691,9 @@ customElements.define("fig-input-fill", FigInputFill);
5651
5691
  * @fires change - When the gradient value is committed
5652
5692
  */
5653
5693
  class FigInputGradient extends HTMLElement {
5654
- #fillPicker;
5694
+ #chit;
5655
5695
  #track;
5696
+ #handleDragging = false;
5656
5697
  #colorObserver = null;
5657
5698
  #gradient = {
5658
5699
  type: "linear",
@@ -5670,14 +5711,46 @@ class FigInputGradient extends HTMLElement {
5670
5711
  }
5671
5712
 
5672
5713
  static get observedAttributes() {
5673
- return ["value", "disabled", "experimental", "picker-anchor"];
5714
+ return ["value", "disabled"];
5674
5715
  }
5675
5716
 
5676
5717
  connectedCallback() {
5677
5718
  this.#parseValue();
5678
5719
  this.#render();
5720
+ document.addEventListener("keydown", this.#onKeyDown);
5721
+ }
5722
+
5723
+ disconnectedCallback() {
5724
+ document.removeEventListener("keydown", this.#onKeyDown);
5679
5725
  }
5680
5726
 
5727
+ #onKeyDown = (e) => {
5728
+ if (e.key !== "Delete" && e.key !== "Backspace") return;
5729
+ const active = document.activeElement;
5730
+ if (
5731
+ active &&
5732
+ (active.tagName === "INPUT" ||
5733
+ active.tagName === "TEXTAREA" ||
5734
+ active.isContentEditable)
5735
+ )
5736
+ return;
5737
+ if (this.#gradient.stops.length <= 2) return;
5738
+ if (!this.#track) return;
5739
+ const selected = this.#track.querySelector(
5740
+ "fig-handle[selected]:not(.fig-input-gradient-ghost)",
5741
+ );
5742
+ if (!selected) return;
5743
+ const idx = parseInt(selected.dataset.stopIndex, 10);
5744
+ if (isNaN(idx) || !this.#gradient.stops[idx]) return;
5745
+ e.preventDefault();
5746
+ selected.removeAttribute("selected");
5747
+ this.#gradient.stops.splice(idx, 1);
5748
+ this.#syncHandles();
5749
+ this.#syncChit();
5750
+ this.#emitInput();
5751
+ this.#emitChange();
5752
+ };
5753
+
5681
5754
  #parseValue() {
5682
5755
  const valueAttr = this.getAttribute("value");
5683
5756
  if (!valueAttr) return;
@@ -5701,19 +5774,12 @@ class FigInputGradient extends HTMLElement {
5701
5774
  }
5702
5775
  }
5703
5776
 
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(" ");
5777
+ #buildGradientCSS() {
5778
+ const sorted = [...this.#gradient.stops].sort(
5779
+ (a, b) => a.position - b.position,
5780
+ );
5781
+ const stops = sorted.map((s) => `${s.color} ${s.position}%`).join(", ");
5782
+ return `linear-gradient(${this.#gradient.angle}deg, ${stops})`;
5717
5783
  }
5718
5784
 
5719
5785
  #buildStopHandles() {
@@ -5721,23 +5787,19 @@ class FigInputGradient extends HTMLElement {
5721
5787
  return this.#gradient.stops
5722
5788
  .map(
5723
5789
  (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>`,
5790
+ `<fig-tooltip action="manual" text="${Math.round(stop.position)}%"><fig-handle drag drag-axes="x" drag-surface=".fig-input-gradient-track" type="color" color="${stop.color}" value="${stop.position}% 50%" data-stop-index="${i}"${disabled ? " disabled" : ""}></fig-handle></fig-tooltip>`,
5725
5791
  )
5726
5792
  .join("");
5727
5793
  }
5728
5794
 
5729
5795
  #ghostHandle = null;
5730
- #ghostTooltip = null;
5731
5796
 
5732
5797
  #render() {
5733
5798
  const disabled = this.hasAttribute("disabled");
5734
- const fillPickerValue = JSON.stringify(this.value);
5735
- const fpAttrs = this.#buildFillPickerAttrs();
5736
5799
  this.innerHTML = `
5737
- <fig-fill-picker mode="gradient" ${fpAttrs} value='${fillPickerValue}' ${
5738
- disabled ? "disabled" : ""
5739
- }></fig-fill-picker>
5800
+ <fig-chit size="medium" background="${this.#buildGradientCSS()}"${disabled ? " disabled" : ""}></fig-chit>
5740
5801
  <div class="fig-input-gradient-track">${this.#buildStopHandles()}</div>`;
5802
+ this.#chit = this.querySelector("fig-chit");
5741
5803
  this.#track = this.querySelector(".fig-input-gradient-track");
5742
5804
  this.#setupGhostHandle();
5743
5805
  this.#setupEventListeners();
@@ -5765,9 +5827,6 @@ class FigInputGradient extends HTMLElement {
5765
5827
 
5766
5828
  #setupGhostHandle() {
5767
5829
  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
5830
 
5772
5831
  const ghost = document.createElement("fig-handle");
5773
5832
  ghost.classList.add("fig-input-gradient-ghost");
@@ -5777,34 +5836,39 @@ class FigInputGradient extends HTMLElement {
5777
5836
  ghost.style.pointerEvents = "none";
5778
5837
  ghost.style.opacity = "0";
5779
5838
  ghost.style.transition = "opacity 0.15s";
5839
+ ghost.style.overflow = "visible";
5780
5840
 
5781
- tooltip.appendChild(ghost);
5782
- this.#track.appendChild(tooltip);
5841
+ const tip = document.createElement("fig-color-tip");
5842
+ tip.setAttribute("control", "add");
5843
+ tip.style.position = "absolute";
5844
+ tip.style.bottom = "calc(100% + 6px)";
5845
+ tip.style.left = "50%";
5846
+ tip.style.transform = "translateX(-50%)";
5847
+ tip.style.zIndex = "10";
5848
+ ghost.appendChild(tip);
5849
+
5850
+ this.#track.appendChild(ghost);
5783
5851
  this.#ghostHandle = ghost;
5784
- this.#ghostTooltip = tooltip;
5785
5852
 
5786
5853
  this.addEventListener("pointerenter", this.#onTrackEnter);
5787
5854
  this.addEventListener("pointermove", this.#onTrackMove);
5788
5855
  this.addEventListener("pointerleave", this.#onTrackLeave);
5789
5856
  this.addEventListener("click", this.#onTrackClick);
5857
+ this.addEventListener("dblclick", this.#onTrackDblClick);
5790
5858
  }
5791
5859
 
5792
5860
  #showGhost() {
5793
5861
  if (!this.#ghostHandle) return;
5794
- this.#ghostHandle.style.opacity = "0.5";
5795
- if (this.#ghostTooltip) {
5796
- this.#ghostTooltip.render();
5797
- this.#ghostTooltip.showPopup();
5798
- }
5862
+ this.#ghostHandle.style.opacity = "1";
5799
5863
  }
5800
5864
 
5801
5865
  #hideGhost() {
5802
5866
  if (!this.#ghostHandle) return;
5803
5867
  this.#ghostHandle.style.opacity = "0";
5804
- if (this.#ghostTooltip) this.#ghostTooltip.hidePopup();
5805
5868
  }
5806
5869
 
5807
5870
  #onTrackEnter = () => {
5871
+ if (this.#handleDragging) return;
5808
5872
  this.#showGhost();
5809
5873
  };
5810
5874
 
@@ -5813,6 +5877,10 @@ class FigInputGradient extends HTMLElement {
5813
5877
  };
5814
5878
 
5815
5879
  #onTrackMove = (e) => {
5880
+ if (this.#handleDragging) {
5881
+ this.#hideGhost();
5882
+ return;
5883
+ }
5816
5884
  if (!this.#ghostHandle || !this.#track) return;
5817
5885
  if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) {
5818
5886
  this.#hideGhost();
@@ -5829,9 +5897,37 @@ class FigInputGradient extends HTMLElement {
5829
5897
  this.#showGhost();
5830
5898
  };
5831
5899
 
5900
+ #distributeStops() {
5901
+ const count = this.#gradient.stops.length;
5902
+ if (count < 2) return;
5903
+ for (let i = 0; i < count; i++) {
5904
+ this.#gradient.stops[i].position = Math.round((i / (count - 1)) * 100);
5905
+ }
5906
+ this.#syncHandles();
5907
+ this.#syncChit();
5908
+ this.#emitInput();
5909
+ this.#emitChange();
5910
+ this.#track?.querySelectorAll("fig-handle[selected]").forEach((h) => {
5911
+ h.removeAttribute("selected");
5912
+ });
5913
+ }
5914
+
5915
+ #onTrackDblClick = (e) => {
5916
+ if (!this.#track) return;
5917
+ if (!e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) return;
5918
+ this.#distributeStops();
5919
+ };
5920
+
5832
5921
  #onTrackClick = (e) => {
5833
5922
  if (!this.#track) return;
5834
- if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) return;
5923
+ if (this.#handleDragging) return;
5924
+ if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) {
5925
+ if (e.shiftKey) {
5926
+ this.#distributeStops();
5927
+ e.stopPropagation();
5928
+ }
5929
+ return;
5930
+ }
5835
5931
  const trackRect = this.#track.getBoundingClientRect();
5836
5932
  const pct = Math.max(
5837
5933
  0,
@@ -5841,10 +5937,21 @@ class FigInputGradient extends HTMLElement {
5841
5937
  const color = this.#sampleGradientColor(pct);
5842
5938
  this.#gradient.stops.push({ position, color, opacity: 100 });
5843
5939
  this.#gradient.stops.sort((a, b) => a.position - b.position);
5940
+ const newIndex = this.#gradient.stops.findIndex(
5941
+ (s) => s.position === position && s.color === color,
5942
+ );
5844
5943
  this.#syncHandles();
5845
- this.#syncFillPicker();
5944
+ this.#syncChit();
5846
5945
  this.#emitInput();
5847
5946
  this.#emitChange();
5947
+
5948
+ requestAnimationFrame(() => {
5949
+ const handles = this.#track.querySelectorAll(
5950
+ "fig-handle:not(.fig-input-gradient-ghost)",
5951
+ );
5952
+ const newHandle = handles[newIndex];
5953
+ if (newHandle) newHandle.click();
5954
+ });
5848
5955
  };
5849
5956
 
5850
5957
  #syncHandles() {
@@ -5855,9 +5962,9 @@ class FigInputGradient extends HTMLElement {
5855
5962
  const stops = this.#gradient.stops;
5856
5963
 
5857
5964
  if (handles.length !== stops.length) {
5858
- const wrapper = this.#ghostTooltip;
5965
+ const ghost = this.#ghostHandle;
5859
5966
  this.#track.innerHTML = this.#buildStopHandles();
5860
- if (wrapper) this.#track.appendChild(wrapper);
5967
+ if (ghost) this.#track.appendChild(ghost);
5861
5968
  this.#reobserveHandleColors();
5862
5969
  return;
5863
5970
  }
@@ -5867,6 +5974,8 @@ class FigInputGradient extends HTMLElement {
5867
5974
  const stop = stops[i];
5868
5975
  h.setAttribute("value", `${stop.position}% 50%`);
5869
5976
  h.setAttribute("color", stop.color);
5977
+ const tip = h.closest("fig-tooltip");
5978
+ if (tip) tip.setAttribute("text", `${Math.round(stop.position)}%`);
5870
5979
  }
5871
5980
  }
5872
5981
 
@@ -5883,92 +5992,155 @@ class FigInputGradient extends HTMLElement {
5883
5992
  });
5884
5993
  }
5885
5994
 
5886
- #syncFillPicker() {
5887
- if (!this.#fillPicker) return;
5888
- this.#fillPicker.setAttribute("value", JSON.stringify(this.value));
5995
+ #syncStopIndices() {
5996
+ if (!this.#track) return;
5997
+ const handles = this.#track.querySelectorAll(
5998
+ "fig-handle:not(.fig-input-gradient-ghost)",
5999
+ );
6000
+ const stops = this.#gradient.stops;
6001
+ const used = new Set();
6002
+ handles.forEach((h) => {
6003
+ const pos = Math.round(parseFloat(h.getAttribute("value")) || 0);
6004
+ const color = (h.getAttribute("color") || "").toUpperCase();
6005
+ let best = -1;
6006
+ for (let i = 0; i < stops.length; i++) {
6007
+ if (used.has(i)) continue;
6008
+ if (
6009
+ stops[i].position === pos &&
6010
+ stops[i].color.toUpperCase() === color
6011
+ ) {
6012
+ best = i;
6013
+ break;
6014
+ }
6015
+ }
6016
+ if (best === -1) {
6017
+ let minDist = Infinity;
6018
+ for (let i = 0; i < stops.length; i++) {
6019
+ if (used.has(i)) continue;
6020
+ const d = Math.abs(stops[i].position - pos);
6021
+ if (d < minDist) {
6022
+ minDist = d;
6023
+ best = i;
6024
+ }
6025
+ }
6026
+ }
6027
+ if (best !== -1) {
6028
+ used.add(best);
6029
+ h.dataset.stopIndex = best;
6030
+ }
6031
+ });
6032
+ }
6033
+
6034
+ #syncChit() {
6035
+ if (!this.#chit) return;
6036
+ this.#chit.setAttribute("background", this.#buildGradientCSS());
5889
6037
  }
5890
6038
 
5891
6039
  #setupEventListeners() {
5892
- requestAnimationFrame(() => {
5893
- this.#fillPicker = this.querySelector("fig-fill-picker");
5894
- if (!this.#fillPicker) return;
6040
+ if (!this.#track) return;
5895
6041
 
5896
- const anchor = this.getAttribute("picker-anchor");
5897
- if (!anchor || anchor === "self") {
5898
- this.#fillPicker.anchorElement = this;
6042
+ this.#track.addEventListener("input", (e) => {
6043
+ const handle = e.target.closest("fig-handle");
6044
+ if (!handle) return;
6045
+ e.stopPropagation();
6046
+ if (!this.#handleDragging) handle.style.zIndex = "5";
6047
+ this.#handleDragging = true;
6048
+ const idx = parseInt(handle.dataset.stopIndex, 10);
6049
+ if (isNaN(idx) || !this.#gradient.stops[idx]) return;
6050
+ const px = e.detail?.px ?? 0;
6051
+ const rawPosition = Math.round(px * 100);
6052
+ let position = rawPosition;
6053
+ const trackW = this.#track.getBoundingClientRect().width;
6054
+ if (e.detail?.shiftKey) {
6055
+ position = Math.round(position / 10) * 10;
5899
6056
  } else {
5900
- const el = document.querySelector(anchor);
5901
- if (el) this.#fillPicker.anchorElement = el;
6057
+ const snapPct = trackW > 0 ? (5 / trackW) * 100 : 0;
6058
+ for (let i = 0; i < this.#gradient.stops.length; i++) {
6059
+ if (i === idx) continue;
6060
+ if (
6061
+ Math.abs(this.#gradient.stops[i].position - position) <= snapPct
6062
+ ) {
6063
+ position = this.#gradient.stops[i].position;
6064
+ break;
6065
+ }
6066
+ }
6067
+ }
6068
+ this.#gradient.stops[idx].position = position;
6069
+ if (position !== rawPosition) {
6070
+ handle.style.left = `${(position / 100) * trackW - handle.offsetWidth / 2}px`;
5902
6071
  }
6072
+ const tooltip = handle.closest("fig-tooltip");
6073
+ if (tooltip) {
6074
+ tooltip.text = `${Math.round(position)}%`;
6075
+ if (!tooltip.hasAttribute("show")) tooltip.setAttribute("show", "true");
6076
+ }
6077
+ this.#syncChit();
6078
+ this.#emitInput();
6079
+ });
5903
6080
 
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();
6081
+ this.#track.addEventListener("change", (e) => {
6082
+ const handle = e.target.closest("fig-handle");
6083
+ if (!handle) return;
6084
+ e.stopPropagation();
6085
+ handle.style.zIndex = "";
6086
+ const tooltip = handle.closest("fig-tooltip");
6087
+ if (tooltip) tooltip.removeAttribute("show");
6088
+ const idx = parseInt(handle.dataset.stopIndex, 10);
6089
+ if (isNaN(idx) || !this.#gradient.stops[idx]) return;
6090
+ const px = e.detail?.px ?? 0;
6091
+ let position = Math.round(px * 100);
6092
+ const trackW = this.#track.getBoundingClientRect().width;
6093
+ const snapPct = trackW > 0 ? (5 / trackW) * 100 : 0;
6094
+ for (let i = 0; i < this.#gradient.stops.length; i++) {
6095
+ if (i === idx) continue;
6096
+ if (Math.abs(this.#gradient.stops[i].position - position) <= snapPct) {
6097
+ position = this.#gradient.stops[i].position;
6098
+ break;
5914
6099
  }
6100
+ }
6101
+ this.#gradient.stops[idx].position = position;
6102
+ handle.style.left = `${(position / 100) * trackW - handle.offsetWidth / 2}px`;
6103
+ this.#gradient.stops.sort((a, b) => a.position - b.position);
6104
+ this.#syncStopIndices();
6105
+ this.#syncChit();
6106
+ this.#emitChange();
6107
+ const swallow = (evt) => {
6108
+ evt.stopPropagation();
6109
+ evt.preventDefault();
6110
+ };
6111
+ this.#track.addEventListener("click", swallow, {
6112
+ capture: true,
6113
+ once: true,
5915
6114
  });
5916
-
5917
- this.#fillPicker.addEventListener("change", (e) => {
5918
- e.stopPropagation();
5919
- this.#emitChange();
6115
+ requestAnimationFrame(() => {
6116
+ this.#handleDragging = false;
6117
+ this.#track.removeEventListener("click", swallow, { capture: true });
5920
6118
  });
6119
+ });
5921
6120
 
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();
6121
+ this.#colorObserver = new MutationObserver((mutations) => {
6122
+ for (const m of mutations) {
6123
+ if (m.attributeName !== "color") continue;
6124
+ const handle = m.target;
6125
+ if (handle.classList.contains("fig-input-gradient-ghost")) continue;
6126
+ const idx = parseInt(handle.dataset.stopIndex, 10);
6127
+ if (isNaN(idx) || !this.#gradient.stops[idx]) continue;
6128
+ const newColor = handle.getAttribute("color");
6129
+ if (newColor && newColor !== this.#gradient.stops[idx].color) {
6130
+ this.#gradient.stops[idx].color = newColor;
6131
+ this.#syncChit();
5932
6132
  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
- });
6133
+ }
5970
6134
  }
5971
6135
  });
6136
+ this.#track
6137
+ .querySelectorAll("fig-handle:not(.fig-input-gradient-ghost)")
6138
+ .forEach((h) => {
6139
+ this.#colorObserver.observe(h, {
6140
+ attributes: true,
6141
+ attributeFilter: ["color"],
6142
+ });
6143
+ });
5972
6144
  }
5973
6145
 
5974
6146
  #emitInput() {
@@ -6009,15 +6181,11 @@ class FigInputGradient extends HTMLElement {
6009
6181
  switch (name) {
6010
6182
  case "value":
6011
6183
  this.#parseValue();
6012
- if (this.#fillPicker) {
6013
- this.#syncFillPicker();
6014
- }
6184
+ this.#syncChit();
6015
6185
  this.#syncHandles();
6016
6186
  break;
6017
6187
  case "disabled":
6018
- case "experimental":
6019
- case "picker-anchor":
6020
- if (this.#fillPicker) this.#render();
6188
+ this.#render();
6021
6189
  break;
6022
6190
  }
6023
6191
  }
@@ -10292,6 +10460,7 @@ class FigFillPicker extends HTMLElement {
10292
10460
  drag
10293
10461
  drag-surface=".fig-fill-picker-color-area"
10294
10462
  drag-axes="x,y"
10463
+ drag-snapping="modifier"
10295
10464
  ></fig-handle>
10296
10465
  </fig-preview>
10297
10466
  <div class="fig-fill-picker-sliders">
@@ -11879,7 +12048,11 @@ class FigColorTip extends HTMLElement {
11879
12048
  #boundHandleChange = this.#handlePickerChange.bind(this);
11880
12049
 
11881
12050
  static get observedAttributes() {
11882
- return ["value", "selected", "disabled", "alpha"];
12051
+ return ["value", "selected", "disabled", "alpha", "control"];
12052
+ }
12053
+
12054
+ get #controlMode() {
12055
+ return this.getAttribute("control") || "color";
11883
12056
  }
11884
12057
 
11885
12058
  connectedCallback() {
@@ -11889,6 +12062,7 @@ class FigColorTip extends HTMLElement {
11889
12062
 
11890
12063
  disconnectedCallback() {
11891
12064
  this.#teardownListeners();
12065
+ this.removeEventListener("click", this.#handleControlClick);
11892
12066
  }
11893
12067
 
11894
12068
  #teardownListeners() {
@@ -11913,6 +12087,17 @@ class FigColorTip extends HTMLElement {
11913
12087
  }
11914
12088
 
11915
12089
  #render() {
12090
+ const mode = this.#controlMode;
12091
+ if (mode === "add" || mode === "remove") {
12092
+ const icon = mode === "add" ? "var(--icon-add)" : "var(--icon-minus)";
12093
+ this.innerHTML = `<fig-button icon variant="ghost"><span class="fig-mask-icon" style="--icon: ${icon}"></span></fig-button>`;
12094
+ this.#fillPicker = null;
12095
+ this.#chit = null;
12096
+ this.addEventListener("click", this.#handleControlClick);
12097
+ return;
12098
+ }
12099
+ this.removeEventListener("click", this.#handleControlClick);
12100
+
11916
12101
  const color = this.#normalizeColor(this.getAttribute("value"));
11917
12102
  const alphaAttr = this.#alphaEnabled ? "" : 'alpha="false"';
11918
12103
  this.innerHTML = `
@@ -11931,6 +12116,13 @@ class FigColorTip extends HTMLElement {
11931
12116
  });
11932
12117
  }
11933
12118
 
12119
+ #handleControlClick = () => {
12120
+ const mode = this.#controlMode;
12121
+ this.dispatchEvent(
12122
+ new CustomEvent(mode, { bubbles: true, composed: true }),
12123
+ );
12124
+ };
12125
+
11934
12126
  #normalizeHex(hex) {
11935
12127
  if (!hex) return "#D9D9D9";
11936
12128
  const raw = hex.replace("#", "").trim();
@@ -12059,6 +12251,9 @@ class FigColorTip extends HTMLElement {
12059
12251
  if (!this.isConnected) return;
12060
12252
 
12061
12253
  switch (name) {
12254
+ case "control":
12255
+ this.#render();
12256
+ break;
12062
12257
  case "value":
12063
12258
  case "selected":
12064
12259
  case "disabled":
@@ -12668,6 +12863,7 @@ class FigHandle extends HTMLElement {
12668
12863
  "drag-snapping",
12669
12864
  "value",
12670
12865
  "type",
12866
+ "control",
12671
12867
  ];
12672
12868
 
12673
12869
  #isDragging = false;
@@ -12676,6 +12872,18 @@ class FigHandle extends HTMLElement {
12676
12872
  #applyingValue = false;
12677
12873
  #colorTip = null;
12678
12874
 
12875
+ get #controlMode() {
12876
+ return this.getAttribute("control") || null;
12877
+ }
12878
+
12879
+ get #hasControlMode() {
12880
+ return this.#controlMode === "add" || this.#controlMode === "remove";
12881
+ }
12882
+
12883
+ get #isGhost() {
12884
+ return this.classList.contains("fig-input-gradient-ghost");
12885
+ }
12886
+
12679
12887
  get #dragEnabled() {
12680
12888
  const v = this.getAttribute("drag");
12681
12889
  return v !== null && v !== "false";
@@ -12806,6 +13014,7 @@ class FigHandle extends HTMLElement {
12806
13014
  document.addEventListener("pointerdown", this.#handleDeselect);
12807
13015
  const initial = this.getAttribute("value");
12808
13016
  if (initial) this.#applyValue(initial);
13017
+ if (this.#hasControlMode && !this.#isGhost) this.#showColorTip();
12809
13018
  }
12810
13019
 
12811
13020
  disconnectedCallback() {
@@ -12817,6 +13026,7 @@ class FigHandle extends HTMLElement {
12817
13026
 
12818
13027
  #handleSelect = (e) => {
12819
13028
  if (this.hasAttribute("disabled")) return;
13029
+ if (this.#hasControlMode) return;
12820
13030
  if (this.#didDrag) {
12821
13031
  this.#didDrag = false;
12822
13032
  return;
@@ -12826,6 +13036,7 @@ class FigHandle extends HTMLElement {
12826
13036
  };
12827
13037
 
12828
13038
  #handleDeselect = (e) => {
13039
+ if (this.#hasControlMode) return;
12829
13040
  if (this.contains(e.target)) return;
12830
13041
  if (this.#colorTip && e.target.closest?.("dialog, [popover]")) return;
12831
13042
  this.removeAttribute("selected");
@@ -12834,7 +13045,7 @@ class FigHandle extends HTMLElement {
12834
13045
 
12835
13046
  attributeChangedCallback(name, _old, value) {
12836
13047
  if (name === "color") {
12837
- if (!value || value === "false") {
13048
+ if (!value || value === "false" || value === "true") {
12838
13049
  this.style.removeProperty("--fill");
12839
13050
  } else {
12840
13051
  this.style.setProperty("--fill", value);
@@ -12844,6 +13055,14 @@ class FigHandle extends HTMLElement {
12844
13055
  if (name === "value" && !this.#applyingValue && !this.#isDragging) {
12845
13056
  this.#applyValue(value);
12846
13057
  }
13058
+ if (name === "control" && !this.#isGhost) {
13059
+ if (this.#hasControlMode) {
13060
+ this.#hideColorTip();
13061
+ this.#showColorTip();
13062
+ } else {
13063
+ this.#hideColorTip();
13064
+ }
13065
+ }
12847
13066
  }
12848
13067
 
12849
13068
  #syncDrag() {
@@ -12920,28 +13139,21 @@ class FigHandle extends HTMLElement {
12920
13139
  }
12921
13140
  };
12922
13141
 
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
13142
  const onMove = (e) => {
12938
13143
  if (!this.#isDragging) return;
13144
+ if (!this.#didDrag) {
13145
+ this.classList.add("dragging");
13146
+ this.style.cursor = "grabbing";
13147
+ }
12939
13148
  this.#didDrag = true;
12940
13149
  clampAndApply(e.clientX, e.clientY, e.shiftKey);
12941
13150
  this.dispatchEvent(
12942
13151
  new CustomEvent("input", {
12943
13152
  bubbles: true,
12944
- detail: this.#positionDetail(container.getBoundingClientRect()),
13153
+ detail: {
13154
+ ...this.#positionDetail(container.getBoundingClientRect()),
13155
+ shiftKey: e.shiftKey,
13156
+ },
12945
13157
  }),
12946
13158
  );
12947
13159
  };
@@ -12949,18 +13161,30 @@ class FigHandle extends HTMLElement {
12949
13161
  const onUp = (e) => {
12950
13162
  this.#isDragging = false;
12951
13163
  this.style.cursor = "";
13164
+ this.classList.remove("dragging");
12952
13165
  window.removeEventListener("pointermove", onMove);
12953
13166
  window.removeEventListener("pointerup", onUp);
12954
- if (this.#didDrag || !isColorType) {
13167
+ if (this.#didDrag) {
12955
13168
  clampAndApply(e.clientX, e.clientY, e.shiftKey);
13169
+ this.#syncValueAttribute();
13170
+ this.dispatchEvent(
13171
+ new CustomEvent("change", {
13172
+ bubbles: true,
13173
+ detail: this.#positionDetail(container.getBoundingClientRect()),
13174
+ }),
13175
+ );
13176
+ const swallowClick = (evt) => {
13177
+ evt.stopPropagation();
13178
+ evt.preventDefault();
13179
+ };
13180
+ this.addEventListener("click", swallowClick, {
13181
+ capture: true,
13182
+ once: true,
13183
+ });
13184
+ } else {
13185
+ this.#syncValueAttribute();
12956
13186
  }
12957
- this.#syncValueAttribute();
12958
- this.dispatchEvent(
12959
- new CustomEvent("change", {
12960
- bubbles: true,
12961
- detail: this.#positionDetail(container.getBoundingClientRect()),
12962
- }),
12963
- );
13187
+ this.#didDrag = false;
12964
13188
  };
12965
13189
 
12966
13190
  window.addEventListener("pointermove", onMove);
@@ -12970,12 +13194,17 @@ class FigHandle extends HTMLElement {
12970
13194
  #showColorTip() {
12971
13195
  if (this.#colorTip) return;
12972
13196
  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());
13197
+ if (this.#hasControlMode) {
13198
+ tip.setAttribute("control", this.#controlMode);
13199
+ } else {
13200
+ tip.setAttribute("value", this.getAttribute("color") || "#D9D9D9");
13201
+ tip.setAttribute("alpha", "true");
13202
+ tip.setAttribute("selected", "");
13203
+ }
12977
13204
  tip.addEventListener("input", this.#handleColorTipInput);
12978
13205
  tip.addEventListener("change", this.#handleColorTipChange);
13206
+ tip.addEventListener("add", this.#handleColorTipControl);
13207
+ tip.addEventListener("remove", this.#handleColorTipControl);
12979
13208
  this.appendChild(tip);
12980
13209
  this.#colorTip = tip;
12981
13210
  }
@@ -12984,6 +13213,8 @@ class FigHandle extends HTMLElement {
12984
13213
  if (!this.#colorTip) return;
12985
13214
  this.#colorTip.removeEventListener("input", this.#handleColorTipInput);
12986
13215
  this.#colorTip.removeEventListener("change", this.#handleColorTipChange);
13216
+ this.#colorTip.removeEventListener("add", this.#handleColorTipControl);
13217
+ this.#colorTip.removeEventListener("remove", this.#handleColorTipControl);
12987
13218
  this.#colorTip.remove();
12988
13219
  this.#colorTip = null;
12989
13220
  }
@@ -12998,6 +13229,13 @@ class FigHandle extends HTMLElement {
12998
13229
  if (e.detail?.color) this.setAttribute("color", e.detail.color);
12999
13230
  };
13000
13231
 
13232
+ #handleColorTipControl = (e) => {
13233
+ e.stopPropagation();
13234
+ this.dispatchEvent(
13235
+ new CustomEvent(e.type, { bubbles: true, composed: true }),
13236
+ );
13237
+ };
13238
+
13001
13239
  #positionDetail(containerRect) {
13002
13240
  const hw = this.offsetWidth / 2;
13003
13241
  const hh = this.offsetHeight / 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "MIT",