@rogieking/figui3 3.13.1 → 3.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/components.css +43 -23
  2. package/fig.js +415 -163
  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,18 @@ 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
+ &:hover {
3209
+ z-index: 5;
3210
+ }
3202
3211
  }
3203
3212
  }
3204
3213
  }
@@ -4244,12 +4253,6 @@ fig-preview {
4244
4253
  }
4245
4254
 
4246
4255
  .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
4256
  z-index: 1;
4254
4257
  }
4255
4258
 
@@ -4743,8 +4746,8 @@ fig-choice {
4743
4746
  }
4744
4747
 
4745
4748
  fig-handle {
4746
- --width: 0.75rem;
4747
- --height: 0.75rem;
4749
+ --width: 0.875rem;
4750
+ --height: 0.875rem;
4748
4751
  --fill: var(--figma-color-bg-brand);
4749
4752
  --border-radius: 50%;
4750
4753
  --ring-width: 1.25px;
@@ -4754,15 +4757,27 @@ fig-handle {
4754
4757
  --outline: none;
4755
4758
  --border: none;
4756
4759
 
4757
- display: inline-block;
4760
+ display: inline-grid;
4761
+ place-items: center;
4758
4762
  width: var(--width);
4759
4763
  height: var(--height);
4760
- background: var(--fill);
4764
+ background: var(--handle-color);
4761
4765
  border-radius: var(--border-radius);
4762
4766
  box-shadow: var(--box-shadow);
4763
4767
  outline: var(--outline);
4764
4768
  border: var(--border);
4765
4769
 
4770
+ &::before {
4771
+ content: "";
4772
+ color-scheme: light only;
4773
+ width: calc(var(--width) - 4px);
4774
+ height: calc(var(--height) - 4px);
4775
+ background: var(--fill);
4776
+ border-radius: var(--border-radius);
4777
+ box-shadow: inset 0 0 0 1px var(--figma-color-bordertranslucent);
4778
+ place-self: center;
4779
+ }
4780
+
4766
4781
  &[size="small"] {
4767
4782
  --width: 0.5625rem;
4768
4783
  --height: 0.5625rem;
@@ -4772,10 +4787,10 @@ fig-handle {
4772
4787
  position: absolute;
4773
4788
  touch-action: none;
4774
4789
  }
4775
-
4790
+ &:hover,
4776
4791
  &[selected]:not([selected="false"]) {
4777
4792
  outline: var(--ring-width) solid var(--figma-color-border-selected);
4778
- outline-offset: var(--ring-width);
4793
+ outline-offset: 0;
4779
4794
  }
4780
4795
 
4781
4796
  &[disabled]:not([disabled="false"]),
@@ -4786,7 +4801,7 @@ fig-handle {
4786
4801
  }
4787
4802
 
4788
4803
  &[type="color"],
4789
- &[add]:not([add="false"]) {
4804
+ &[control] {
4790
4805
  overflow: visible;
4791
4806
 
4792
4807
  fig-color-tip {
@@ -4823,6 +4838,11 @@ fig-color-tip {
4823
4838
  drop-shadow(0px 2.5px 6px rgba(0, 0, 0, 0.13))
4824
4839
  drop-shadow(0px 0px 0.5px rgba(0, 0, 0, 0.15));
4825
4840
 
4841
+ &[control="add"],
4842
+ &[control="remove"] {
4843
+ cursor: pointer;
4844
+ }
4845
+
4826
4846
  fig-chit {
4827
4847
  input[type="color"] {
4828
4848
  background: transparent;
package/fig.js CHANGED
@@ -591,6 +591,7 @@ class FigTooltip extends HTMLElement {
591
591
  this.#stopObserving();
592
592
  if (this.popup) {
593
593
  this.popup.remove();
594
+ this.popup = null;
594
595
  }
595
596
  // Remove the click outside listener if it was added
596
597
  if (this.action === "click") {
@@ -659,7 +660,12 @@ class FigTooltip extends HTMLElement {
659
660
  };
660
661
  }
661
662
 
663
+ get #showPersisted() {
664
+ return this.hasAttribute("show") && this.getAttribute("show") !== "false";
665
+ }
666
+
662
667
  showDelayedPopup() {
668
+ if (this.#showPersisted) return;
663
669
  this.render();
664
670
  clearTimeout(this.timeout);
665
671
  const warm =
@@ -669,10 +675,12 @@ class FigTooltip extends HTMLElement {
669
675
  }
670
676
 
671
677
  showPopup() {
678
+ if (!this.popup) this.render();
679
+ this.popup.style.display = "block";
680
+ this.popup.style.visibility = "hidden";
672
681
  this.#repositionPopup();
673
682
  this.popup.style.opacity = "1";
674
683
  this.popup.style.visibility = "visible";
675
- this.popup.style.display = "block";
676
684
  this.popup.style.pointerEvents = "all";
677
685
  this.popup.style.zIndex = figGetHighestZIndex() + 1;
678
686
 
@@ -725,6 +733,7 @@ class FigTooltip extends HTMLElement {
725
733
  }
726
734
 
727
735
  hidePopup() {
736
+ if (this.#showPersisted) return;
728
737
  clearTimeout(this.timeout);
729
738
  clearTimeout(this.#touchTimeout);
730
739
  this.#stopObserving();
@@ -824,7 +833,28 @@ class FigTooltip extends HTMLElement {
824
833
  }
825
834
 
826
835
  static get observedAttributes() {
827
- return ["action", "delay", "open"];
836
+ return ["action", "delay", "open", "show", "text"];
837
+ }
838
+ get text() {
839
+ return this.getAttribute("text") ?? "";
840
+ }
841
+ set text(value) {
842
+ this.setAttribute("text", value);
843
+ }
844
+ #updateText(value) {
845
+ if (!this.popup) return;
846
+ const content = this.popup.firstElementChild ?? this.popup.firstChild;
847
+ if (!content) return;
848
+ content.innerText = value;
849
+ content.style.width = "";
850
+ const textNode = content.childNodes[0];
851
+ if (textNode) {
852
+ const range = document.createRange();
853
+ range.setStartBefore(textNode);
854
+ range.setEndAfter(textNode);
855
+ content.style.width = `${range.getBoundingClientRect().width}px`;
856
+ }
857
+ if (this.isOpen) this.#repositionPopup();
828
858
  }
829
859
  get open() {
830
860
  return this.hasAttribute("open") && this.getAttribute("open") === "true";
@@ -851,6 +881,17 @@ class FigTooltip extends HTMLElement {
851
881
  });
852
882
  }
853
883
  }
884
+ if (name === "show") {
885
+ const on = newValue !== null && newValue !== "false";
886
+ if (on) {
887
+ this.showPopup();
888
+ } else {
889
+ this.hidePopup();
890
+ }
891
+ }
892
+ if (name === "text") {
893
+ this.#updateText(newValue ?? "");
894
+ }
854
895
  }
855
896
 
856
897
  #hideOnChromeOpen(e) {
@@ -5651,8 +5692,10 @@ customElements.define("fig-input-fill", FigInputFill);
5651
5692
  * @fires change - When the gradient value is committed
5652
5693
  */
5653
5694
  class FigInputGradient extends HTMLElement {
5654
- #fillPicker;
5695
+ #chit;
5655
5696
  #track;
5697
+ #handleDragging = false;
5698
+ #arrowTooltipTimer = null;
5656
5699
  #colorObserver = null;
5657
5700
  #gradient = {
5658
5701
  type: "linear",
@@ -5670,14 +5713,96 @@ class FigInputGradient extends HTMLElement {
5670
5713
  }
5671
5714
 
5672
5715
  static get observedAttributes() {
5673
- return ["value", "disabled", "experimental", "picker-anchor"];
5716
+ return ["value", "disabled"];
5674
5717
  }
5675
5718
 
5676
5719
  connectedCallback() {
5677
5720
  this.#parseValue();
5678
5721
  this.#render();
5722
+ document.addEventListener("keydown", this.#onKeyDown);
5679
5723
  }
5680
5724
 
5725
+ disconnectedCallback() {
5726
+ document.removeEventListener("keydown", this.#onKeyDown);
5727
+ }
5728
+
5729
+ #onKeyDown = (e) => {
5730
+ const active = document.activeElement;
5731
+ const isTyping =
5732
+ active &&
5733
+ (active.tagName === "INPUT" ||
5734
+ active.tagName === "TEXTAREA" ||
5735
+ active.isContentEditable);
5736
+ if (!this.#track) return;
5737
+
5738
+ if (e.key === "Tab" && !isTyping) {
5739
+ const selected = this.#track.querySelector(
5740
+ "fig-handle[selected]:not(.fig-input-gradient-ghost)",
5741
+ );
5742
+ if (!selected) return;
5743
+ e.preventDefault();
5744
+ const handles = [
5745
+ ...this.#track.querySelectorAll(
5746
+ "fig-handle:not(.fig-input-gradient-ghost)",
5747
+ ),
5748
+ ];
5749
+ const curIdx = handles.indexOf(selected);
5750
+ const next = e.shiftKey
5751
+ ? (curIdx - 1 + handles.length) % handles.length
5752
+ : (curIdx + 1) % handles.length;
5753
+ selected.deselect();
5754
+ handles[next].select();
5755
+ return;
5756
+ }
5757
+
5758
+ if ((e.key === "ArrowLeft" || e.key === "ArrowRight") && !isTyping) {
5759
+ const selected = this.#track.querySelector(
5760
+ "fig-handle[selected]:not(.fig-input-gradient-ghost)",
5761
+ );
5762
+ if (!selected) return;
5763
+ const idx = parseInt(selected.dataset.stopIndex, 10);
5764
+ if (isNaN(idx) || !this.#gradient.stops[idx]) return;
5765
+ e.preventDefault();
5766
+ const delta = (e.key === "ArrowRight" ? 1 : -1) * (e.shiftKey ? 10 : 1);
5767
+ const stop = this.#gradient.stops[idx];
5768
+ stop.position = Math.max(0, Math.min(100, stop.position + delta));
5769
+ selected.setAttribute("value", `${stop.position}% 50%`);
5770
+ const tip = selected.closest("fig-tooltip");
5771
+ if (tip) {
5772
+ tip.text = `${Math.round(stop.position)}%`;
5773
+ tip.setAttribute("show", "true");
5774
+ tip.showPopup();
5775
+ selected.hideColorTip();
5776
+ clearTimeout(this.#arrowTooltipTimer);
5777
+ this.#arrowTooltipTimer = setTimeout(() => {
5778
+ tip.removeAttribute("show");
5779
+ selected.showColorTip();
5780
+ }, 600);
5781
+ }
5782
+ this.#syncChit();
5783
+ this.#emitInput();
5784
+ this.#emitChange();
5785
+ return;
5786
+ }
5787
+
5788
+ if (e.key !== "Delete" && e.key !== "Backspace") return;
5789
+ if (isTyping) return;
5790
+ if (this.#gradient.stops.length <= 2) return;
5791
+ const selected = this.#track.querySelector(
5792
+ "fig-handle[selected]:not(.fig-input-gradient-ghost)",
5793
+ );
5794
+ if (!selected) return;
5795
+ const idx = parseInt(selected.dataset.stopIndex, 10);
5796
+ if (isNaN(idx) || !this.#gradient.stops[idx]) return;
5797
+ e.preventDefault();
5798
+ selected.removeAttribute("selected");
5799
+ this.#gradient.stops.splice(idx, 1);
5800
+ this.#syncHandles();
5801
+ this.#syncChit();
5802
+ this.#emitInput();
5803
+ this.#emitChange();
5804
+ };
5805
+
5681
5806
  #parseValue() {
5682
5807
  const valueAttr = this.getAttribute("value");
5683
5808
  if (!valueAttr) return;
@@ -5701,19 +5826,12 @@ class FigInputGradient extends HTMLElement {
5701
5826
  }
5702
5827
  }
5703
5828
 
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(" ");
5829
+ #buildGradientCSS() {
5830
+ const sorted = [...this.#gradient.stops].sort(
5831
+ (a, b) => a.position - b.position,
5832
+ );
5833
+ const stops = sorted.map((s) => `${s.color} ${s.position}%`).join(", ");
5834
+ return `linear-gradient(${this.#gradient.angle}deg, ${stops})`;
5717
5835
  }
5718
5836
 
5719
5837
  #buildStopHandles() {
@@ -5721,23 +5839,19 @@ class FigInputGradient extends HTMLElement {
5721
5839
  return this.#gradient.stops
5722
5840
  .map(
5723
5841
  (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>`,
5842
+ `<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
5843
  )
5726
5844
  .join("");
5727
5845
  }
5728
5846
 
5729
5847
  #ghostHandle = null;
5730
- #ghostTooltip = null;
5731
5848
 
5732
5849
  #render() {
5733
5850
  const disabled = this.hasAttribute("disabled");
5734
- const fillPickerValue = JSON.stringify(this.value);
5735
- const fpAttrs = this.#buildFillPickerAttrs();
5736
5851
  this.innerHTML = `
5737
- <fig-fill-picker mode="gradient" ${fpAttrs} value='${fillPickerValue}' ${
5738
- disabled ? "disabled" : ""
5739
- }></fig-fill-picker>
5852
+ <fig-chit size="medium" background="${this.#buildGradientCSS()}"${disabled ? " disabled" : ""}></fig-chit>
5740
5853
  <div class="fig-input-gradient-track">${this.#buildStopHandles()}</div>`;
5854
+ this.#chit = this.querySelector("fig-chit");
5741
5855
  this.#track = this.querySelector(".fig-input-gradient-track");
5742
5856
  this.#setupGhostHandle();
5743
5857
  this.#setupEventListeners();
@@ -5768,22 +5882,31 @@ class FigInputGradient extends HTMLElement {
5768
5882
 
5769
5883
  const ghost = document.createElement("fig-handle");
5770
5884
  ghost.classList.add("fig-input-gradient-ghost");
5771
- ghost.setAttribute("add", "");
5772
5885
  ghost.style.position = "absolute";
5773
5886
  ghost.style.top = "50%";
5774
5887
  ghost.style.transform = "translate(-50%, -50%)";
5775
5888
  ghost.style.pointerEvents = "none";
5776
5889
  ghost.style.opacity = "0";
5777
5890
  ghost.style.transition = "opacity 0.15s";
5891
+ ghost.style.overflow = "visible";
5892
+
5893
+ const tip = document.createElement("fig-color-tip");
5894
+ tip.setAttribute("control", "add");
5895
+ tip.style.position = "absolute";
5896
+ tip.style.bottom = "calc(100% + 6px)";
5897
+ tip.style.left = "50%";
5898
+ tip.style.transform = "translateX(-50%)";
5899
+ tip.style.zIndex = "10";
5900
+ ghost.appendChild(tip);
5778
5901
 
5779
5902
  this.#track.appendChild(ghost);
5780
5903
  this.#ghostHandle = ghost;
5781
- this.#ghostTooltip = null;
5782
5904
 
5783
5905
  this.addEventListener("pointerenter", this.#onTrackEnter);
5784
5906
  this.addEventListener("pointermove", this.#onTrackMove);
5785
5907
  this.addEventListener("pointerleave", this.#onTrackLeave);
5786
5908
  this.addEventListener("click", this.#onTrackClick);
5909
+ this.addEventListener("dblclick", this.#onTrackDblClick);
5787
5910
  }
5788
5911
 
5789
5912
  #showGhost() {
@@ -5797,6 +5920,7 @@ class FigInputGradient extends HTMLElement {
5797
5920
  }
5798
5921
 
5799
5922
  #onTrackEnter = () => {
5923
+ if (this.#handleDragging) return;
5800
5924
  this.#showGhost();
5801
5925
  };
5802
5926
 
@@ -5805,6 +5929,10 @@ class FigInputGradient extends HTMLElement {
5805
5929
  };
5806
5930
 
5807
5931
  #onTrackMove = (e) => {
5932
+ if (this.#handleDragging) {
5933
+ this.#hideGhost();
5934
+ return;
5935
+ }
5808
5936
  if (!this.#ghostHandle || !this.#track) return;
5809
5937
  if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) {
5810
5938
  this.#hideGhost();
@@ -5821,9 +5949,37 @@ class FigInputGradient extends HTMLElement {
5821
5949
  this.#showGhost();
5822
5950
  };
5823
5951
 
5952
+ #distributeStops() {
5953
+ const count = this.#gradient.stops.length;
5954
+ if (count < 2) return;
5955
+ for (let i = 0; i < count; i++) {
5956
+ this.#gradient.stops[i].position = Math.round((i / (count - 1)) * 100);
5957
+ }
5958
+ this.#syncHandles();
5959
+ this.#syncChit();
5960
+ this.#emitInput();
5961
+ this.#emitChange();
5962
+ this.#track?.querySelectorAll("fig-handle[selected]").forEach((h) => {
5963
+ h.removeAttribute("selected");
5964
+ });
5965
+ }
5966
+
5967
+ #onTrackDblClick = (e) => {
5968
+ if (!this.#track) return;
5969
+ if (!e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) return;
5970
+ this.#distributeStops();
5971
+ };
5972
+
5824
5973
  #onTrackClick = (e) => {
5825
5974
  if (!this.#track) return;
5826
- if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) return;
5975
+ if (this.#handleDragging) return;
5976
+ if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) {
5977
+ if (e.shiftKey) {
5978
+ this.#distributeStops();
5979
+ e.stopPropagation();
5980
+ }
5981
+ return;
5982
+ }
5827
5983
  const trackRect = this.#track.getBoundingClientRect();
5828
5984
  const pct = Math.max(
5829
5985
  0,
@@ -5837,7 +5993,7 @@ class FigInputGradient extends HTMLElement {
5837
5993
  (s) => s.position === position && s.color === color,
5838
5994
  );
5839
5995
  this.#syncHandles();
5840
- this.#syncFillPicker();
5996
+ this.#syncChit();
5841
5997
  this.#emitInput();
5842
5998
  this.#emitChange();
5843
5999
 
@@ -5870,6 +6026,8 @@ class FigInputGradient extends HTMLElement {
5870
6026
  const stop = stops[i];
5871
6027
  h.setAttribute("value", `${stop.position}% 50%`);
5872
6028
  h.setAttribute("color", stop.color);
6029
+ const tip = h.closest("fig-tooltip");
6030
+ if (tip) tip.setAttribute("text", `${Math.round(stop.position)}%`);
5873
6031
  }
5874
6032
  }
5875
6033
 
@@ -5886,92 +6044,150 @@ class FigInputGradient extends HTMLElement {
5886
6044
  });
5887
6045
  }
5888
6046
 
5889
- #syncFillPicker() {
5890
- if (!this.#fillPicker) return;
5891
- this.#fillPicker.setAttribute("value", JSON.stringify(this.value));
6047
+ #syncStopIndices() {
6048
+ if (!this.#track) return;
6049
+ const handles = this.#track.querySelectorAll(
6050
+ "fig-handle:not(.fig-input-gradient-ghost)",
6051
+ );
6052
+ const stops = this.#gradient.stops;
6053
+ const used = new Set();
6054
+ handles.forEach((h) => {
6055
+ const pos = Math.round(parseFloat(h.getAttribute("value")) || 0);
6056
+ const color = (h.getAttribute("color") || "").toUpperCase();
6057
+ let best = -1;
6058
+ for (let i = 0; i < stops.length; i++) {
6059
+ if (used.has(i)) continue;
6060
+ if (
6061
+ stops[i].position === pos &&
6062
+ stops[i].color.toUpperCase() === color
6063
+ ) {
6064
+ best = i;
6065
+ break;
6066
+ }
6067
+ }
6068
+ if (best === -1) {
6069
+ let minDist = Infinity;
6070
+ for (let i = 0; i < stops.length; i++) {
6071
+ if (used.has(i)) continue;
6072
+ const d = Math.abs(stops[i].position - pos);
6073
+ if (d < minDist) {
6074
+ minDist = d;
6075
+ best = i;
6076
+ }
6077
+ }
6078
+ }
6079
+ if (best !== -1) {
6080
+ used.add(best);
6081
+ h.dataset.stopIndex = best;
6082
+ }
6083
+ });
6084
+ }
6085
+
6086
+ #syncChit() {
6087
+ if (!this.#chit) return;
6088
+ this.#chit.setAttribute("background", this.#buildGradientCSS());
5892
6089
  }
5893
6090
 
5894
6091
  #setupEventListeners() {
5895
- requestAnimationFrame(() => {
5896
- this.#fillPicker = this.querySelector("fig-fill-picker");
5897
- if (!this.#fillPicker) return;
6092
+ if (!this.#track) return;
5898
6093
 
5899
- const anchor = this.getAttribute("picker-anchor");
5900
- if (!anchor || anchor === "self") {
5901
- this.#fillPicker.anchorElement = this;
6094
+ this.#track.addEventListener("input", (e) => {
6095
+ const handle = e.target.closest("fig-handle");
6096
+ if (!handle) return;
6097
+ e.stopPropagation();
6098
+ if (!this.#handleDragging) handle.style.zIndex = "5";
6099
+ this.#handleDragging = true;
6100
+ const idx = parseInt(handle.dataset.stopIndex, 10);
6101
+ if (isNaN(idx) || !this.#gradient.stops[idx]) return;
6102
+ const px = e.detail?.px ?? 0;
6103
+ const rawPosition = Math.round(px * 100);
6104
+ let position = rawPosition;
6105
+ const trackW = this.#track.getBoundingClientRect().width;
6106
+ if (e.detail?.shiftKey) {
6107
+ position = Math.round(position / 10) * 10;
5902
6108
  } else {
5903
- const el = document.querySelector(anchor);
5904
- if (el) this.#fillPicker.anchorElement = el;
6109
+ const snapPct = trackW > 0 ? (5 / trackW) * 100 : 0;
6110
+ for (let i = 0; i < this.#gradient.stops.length; i++) {
6111
+ if (i === idx) continue;
6112
+ if (
6113
+ Math.abs(this.#gradient.stops[i].position - position) <= snapPct
6114
+ ) {
6115
+ position = this.#gradient.stops[i].position;
6116
+ break;
6117
+ }
6118
+ }
5905
6119
  }
5906
-
5907
- this.#fillPicker.addEventListener("input", (e) => {
5908
- e.stopPropagation();
5909
- const detail = e.detail;
5910
- if (detail?.gradient) {
5911
- this.#gradient = normalizeGradientConfig({
5912
- ...this.#gradient,
5913
- ...detail.gradient,
5914
- });
5915
- this.#syncHandles();
5916
- this.#emitInput();
6120
+ this.#gradient.stops[idx].position = position;
6121
+ if (position !== rawPosition) {
6122
+ handle.style.left = `${(position / 100) * trackW - handle.offsetWidth / 2}px`;
6123
+ }
6124
+ const tooltip = handle.closest("fig-tooltip");
6125
+ if (tooltip) {
6126
+ tooltip.text = `${Math.round(position)}%`;
6127
+ if (!tooltip.hasAttribute("show")) {
6128
+ tooltip.setAttribute("show", "true");
6129
+ handle.hideColorTip();
5917
6130
  }
5918
- });
6131
+ }
6132
+ this.#syncChit();
6133
+ this.#emitInput();
6134
+ });
5919
6135
 
5920
- this.#fillPicker.addEventListener("change", (e) => {
5921
- e.stopPropagation();
5922
- this.#emitChange();
6136
+ this.#track.addEventListener("change", (e) => {
6137
+ const handle = e.target.closest("fig-handle");
6138
+ if (!handle) return;
6139
+ e.stopPropagation();
6140
+ handle.style.zIndex = "";
6141
+ const tooltip = handle.closest("fig-tooltip");
6142
+ if (tooltip) tooltip.removeAttribute("show");
6143
+ handle.showColorTip();
6144
+ const idx = parseInt(handle.dataset.stopIndex, 10);
6145
+ if (isNaN(idx) || !this.#gradient.stops[idx]) return;
6146
+ const px = e.detail?.px ?? 0;
6147
+ let position = Math.round(px * 100);
6148
+ const trackW = this.#track.getBoundingClientRect().width;
6149
+ const snapPct = trackW > 0 ? (5 / trackW) * 100 : 0;
6150
+ for (let i = 0; i < this.#gradient.stops.length; i++) {
6151
+ if (i === idx) continue;
6152
+ if (Math.abs(this.#gradient.stops[i].position - position) <= snapPct) {
6153
+ position = this.#gradient.stops[i].position;
6154
+ break;
6155
+ }
6156
+ }
6157
+ this.#gradient.stops[idx].position = position;
6158
+ handle.style.left = `${(position / 100) * trackW - handle.offsetWidth / 2}px`;
6159
+ this.#gradient.stops.sort((a, b) => a.position - b.position);
6160
+ this.#syncStopIndices();
6161
+ this.#syncChit();
6162
+ this.#emitChange();
6163
+ requestAnimationFrame(() => {
6164
+ this.#handleDragging = false;
5923
6165
  });
6166
+ });
5924
6167
 
5925
- if (this.#track) {
5926
- this.#track.addEventListener("input", (e) => {
5927
- const handle = e.target.closest("fig-handle");
5928
- if (!handle) return;
5929
- e.stopPropagation();
5930
- const idx = parseInt(handle.dataset.stopIndex, 10);
5931
- if (isNaN(idx) || !this.#gradient.stops[idx]) return;
5932
- const px = e.detail?.px ?? 0;
5933
- this.#gradient.stops[idx].position = Math.round(px * 100);
5934
- this.#syncFillPicker();
6168
+ this.#colorObserver = new MutationObserver((mutations) => {
6169
+ for (const m of mutations) {
6170
+ if (m.attributeName !== "color") continue;
6171
+ const handle = m.target;
6172
+ if (handle.classList.contains("fig-input-gradient-ghost")) continue;
6173
+ const idx = parseInt(handle.dataset.stopIndex, 10);
6174
+ if (isNaN(idx) || !this.#gradient.stops[idx]) continue;
6175
+ const newColor = handle.getAttribute("color");
6176
+ if (newColor && newColor !== this.#gradient.stops[idx].color) {
6177
+ this.#gradient.stops[idx].color = newColor;
6178
+ this.#syncChit();
5935
6179
  this.#emitInput();
5936
- });
5937
-
5938
- this.#track.addEventListener("change", (e) => {
5939
- const handle = e.target.closest("fig-handle");
5940
- if (!handle) return;
5941
- e.stopPropagation();
5942
- const idx = parseInt(handle.dataset.stopIndex, 10);
5943
- if (isNaN(idx) || !this.#gradient.stops[idx]) return;
5944
- const px = e.detail?.px ?? 0;
5945
- this.#gradient.stops[idx].position = Math.round(px * 100);
5946
- this.#syncFillPicker();
5947
- this.#emitChange();
5948
- });
5949
-
5950
- this.#colorObserver = new MutationObserver((mutations) => {
5951
- for (const m of mutations) {
5952
- if (m.attributeName !== "color") continue;
5953
- const handle = m.target;
5954
- if (handle.classList.contains("fig-input-gradient-ghost")) continue;
5955
- const idx = parseInt(handle.dataset.stopIndex, 10);
5956
- if (isNaN(idx) || !this.#gradient.stops[idx]) continue;
5957
- const newColor = handle.getAttribute("color");
5958
- if (newColor && newColor !== this.#gradient.stops[idx].color) {
5959
- this.#gradient.stops[idx].color = newColor;
5960
- this.#syncFillPicker();
5961
- this.#emitInput();
5962
- }
5963
- }
5964
- });
5965
- this.#track
5966
- .querySelectorAll("fig-handle:not(.fig-input-gradient-ghost)")
5967
- .forEach((h) => {
5968
- this.#colorObserver.observe(h, {
5969
- attributes: true,
5970
- attributeFilter: ["color"],
5971
- });
5972
- });
6180
+ }
5973
6181
  }
5974
6182
  });
6183
+ this.#track
6184
+ .querySelectorAll("fig-handle:not(.fig-input-gradient-ghost)")
6185
+ .forEach((h) => {
6186
+ this.#colorObserver.observe(h, {
6187
+ attributes: true,
6188
+ attributeFilter: ["color"],
6189
+ });
6190
+ });
5975
6191
  }
5976
6192
 
5977
6193
  #emitInput() {
@@ -6012,15 +6228,11 @@ class FigInputGradient extends HTMLElement {
6012
6228
  switch (name) {
6013
6229
  case "value":
6014
6230
  this.#parseValue();
6015
- if (this.#fillPicker) {
6016
- this.#syncFillPicker();
6017
- }
6231
+ this.#syncChit();
6018
6232
  this.#syncHandles();
6019
6233
  break;
6020
6234
  case "disabled":
6021
- case "experimental":
6022
- case "picker-anchor":
6023
- if (this.#fillPicker) this.#render();
6235
+ this.#render();
6024
6236
  break;
6025
6237
  }
6026
6238
  }
@@ -10295,6 +10507,7 @@ class FigFillPicker extends HTMLElement {
10295
10507
  drag
10296
10508
  drag-surface=".fig-fill-picker-color-area"
10297
10509
  drag-axes="x,y"
10510
+ drag-snapping="modifier"
10298
10511
  ></fig-handle>
10299
10512
  </fig-preview>
10300
10513
  <div class="fig-fill-picker-sliders">
@@ -11882,12 +12095,11 @@ class FigColorTip extends HTMLElement {
11882
12095
  #boundHandleChange = this.#handlePickerChange.bind(this);
11883
12096
 
11884
12097
  static get observedAttributes() {
11885
- return ["value", "selected", "disabled", "alpha", "add"];
12098
+ return ["value", "selected", "disabled", "alpha", "control"];
11886
12099
  }
11887
12100
 
11888
- get #isAddMode() {
11889
- const v = this.getAttribute("add");
11890
- return v !== null && v !== "false";
12101
+ get #controlMode() {
12102
+ return this.getAttribute("control") || "color";
11891
12103
  }
11892
12104
 
11893
12105
  connectedCallback() {
@@ -11897,7 +12109,7 @@ class FigColorTip extends HTMLElement {
11897
12109
 
11898
12110
  disconnectedCallback() {
11899
12111
  this.#teardownListeners();
11900
- this.removeEventListener("click", this.#handleAddClick);
12112
+ this.removeEventListener("click", this.#handleControlClick);
11901
12113
  }
11902
12114
 
11903
12115
  #teardownListeners() {
@@ -11922,14 +12134,16 @@ class FigColorTip extends HTMLElement {
11922
12134
  }
11923
12135
 
11924
12136
  #render() {
11925
- if (this.#isAddMode) {
11926
- this.innerHTML = `<span class="fig-mask-icon" style="--icon: var(--icon-add)"></span>`;
12137
+ const mode = this.#controlMode;
12138
+ if (mode === "add" || mode === "remove") {
12139
+ const icon = mode === "add" ? "var(--icon-add)" : "var(--icon-minus)";
12140
+ this.innerHTML = `<fig-button icon variant="ghost"><span class="fig-mask-icon" style="--icon: ${icon}"></span></fig-button>`;
11927
12141
  this.#fillPicker = null;
11928
12142
  this.#chit = null;
11929
- this.addEventListener("click", this.#handleAddClick);
12143
+ this.addEventListener("click", this.#handleControlClick);
11930
12144
  return;
11931
12145
  }
11932
- this.removeEventListener("click", this.#handleAddClick);
12146
+ this.removeEventListener("click", this.#handleControlClick);
11933
12147
 
11934
12148
  const color = this.#normalizeColor(this.getAttribute("value"));
11935
12149
  const alphaAttr = this.#alphaEnabled ? "" : 'alpha="false"';
@@ -11949,9 +12163,10 @@ class FigColorTip extends HTMLElement {
11949
12163
  });
11950
12164
  }
11951
12165
 
11952
- #handleAddClick = () => {
12166
+ #handleControlClick = () => {
12167
+ const mode = this.#controlMode;
11953
12168
  this.dispatchEvent(
11954
- new CustomEvent("add", { bubbles: true, composed: true }),
12169
+ new CustomEvent(mode, { bubbles: true, composed: true }),
11955
12170
  );
11956
12171
  };
11957
12172
 
@@ -12083,7 +12298,7 @@ class FigColorTip extends HTMLElement {
12083
12298
  if (!this.isConnected) return;
12084
12299
 
12085
12300
  switch (name) {
12086
- case "add":
12301
+ case "control":
12087
12302
  this.#render();
12088
12303
  break;
12089
12304
  case "value":
@@ -12695,7 +12910,7 @@ class FigHandle extends HTMLElement {
12695
12910
  "drag-snapping",
12696
12911
  "value",
12697
12912
  "type",
12698
- "add",
12913
+ "control",
12699
12914
  ];
12700
12915
 
12701
12916
  #isDragging = false;
@@ -12704,8 +12919,16 @@ class FigHandle extends HTMLElement {
12704
12919
  #applyingValue = false;
12705
12920
  #colorTip = null;
12706
12921
 
12707
- get #isAddMode() {
12708
- return this.hasAttribute("add") && this.getAttribute("add") !== "false";
12922
+ get #controlMode() {
12923
+ return this.getAttribute("control") || null;
12924
+ }
12925
+
12926
+ get #hasControlMode() {
12927
+ return this.#controlMode === "add" || this.#controlMode === "remove";
12928
+ }
12929
+
12930
+ get #isGhost() {
12931
+ return this.classList.contains("fig-input-gradient-ghost");
12709
12932
  }
12710
12933
 
12711
12934
  get #dragEnabled() {
@@ -12838,7 +13061,7 @@ class FigHandle extends HTMLElement {
12838
13061
  document.addEventListener("pointerdown", this.#handleDeselect);
12839
13062
  const initial = this.getAttribute("value");
12840
13063
  if (initial) this.#applyValue(initial);
12841
- if (this.#isAddMode) this.#showColorTip();
13064
+ if (this.#hasControlMode && !this.#isGhost) this.#showColorTip();
12842
13065
  }
12843
13066
 
12844
13067
  disconnectedCallback() {
@@ -12848,28 +13071,36 @@ class FigHandle extends HTMLElement {
12848
13071
  document.removeEventListener("pointerdown", this.#handleDeselect);
12849
13072
  }
12850
13073
 
12851
- #handleSelect = (e) => {
13074
+ select() {
12852
13075
  if (this.hasAttribute("disabled")) return;
12853
- if (this.#isAddMode) return;
13076
+ this.setAttribute("selected", "");
13077
+ if (this.getAttribute("type") === "color") this.#showColorTip();
13078
+ }
13079
+
13080
+ deselect() {
13081
+ this.removeAttribute("selected");
13082
+ this.#hideColorTip();
13083
+ }
13084
+
13085
+ #handleSelect = (e) => {
13086
+ if (this.#hasControlMode) return;
12854
13087
  if (this.#didDrag) {
12855
13088
  this.#didDrag = false;
12856
13089
  return;
12857
13090
  }
12858
- this.setAttribute("selected", "");
12859
- if (this.getAttribute("type") === "color") this.#showColorTip();
13091
+ this.select();
12860
13092
  };
12861
13093
 
12862
13094
  #handleDeselect = (e) => {
12863
- if (this.#isAddMode) return;
13095
+ if (this.#hasControlMode) return;
12864
13096
  if (this.contains(e.target)) return;
12865
13097
  if (this.#colorTip && e.target.closest?.("dialog, [popover]")) return;
12866
- this.removeAttribute("selected");
12867
- this.#hideColorTip();
13098
+ this.deselect();
12868
13099
  };
12869
13100
 
12870
13101
  attributeChangedCallback(name, _old, value) {
12871
13102
  if (name === "color") {
12872
- if (!value || value === "false") {
13103
+ if (!value || value === "false" || value === "true") {
12873
13104
  this.style.removeProperty("--fill");
12874
13105
  } else {
12875
13106
  this.style.setProperty("--fill", value);
@@ -12879,8 +13110,9 @@ class FigHandle extends HTMLElement {
12879
13110
  if (name === "value" && !this.#applyingValue && !this.#isDragging) {
12880
13111
  this.#applyValue(value);
12881
13112
  }
12882
- if (name === "add") {
12883
- if (this.#isAddMode) {
13113
+ if (name === "control" && !this.#isGhost) {
13114
+ if (this.#hasControlMode) {
13115
+ this.#hideColorTip();
12884
13116
  this.#showColorTip();
12885
13117
  } else {
12886
13118
  this.#hideColorTip();
@@ -12962,28 +13194,22 @@ class FigHandle extends HTMLElement {
12962
13194
  }
12963
13195
  };
12964
13196
 
12965
- const isColorType = this.getAttribute("type") === "color";
12966
- if (!isColorType) {
12967
- clampAndApply(e.clientX, e.clientY, e.shiftKey);
12968
- }
12969
- this.style.cursor = "grabbing";
12970
- if (!isColorType) {
12971
- this.dispatchEvent(
12972
- new CustomEvent("input", {
12973
- bubbles: true,
12974
- detail: this.#positionDetail(containerRect),
12975
- }),
12976
- );
12977
- }
12978
-
12979
13197
  const onMove = (e) => {
12980
13198
  if (!this.#isDragging) return;
13199
+ if (!this.#didDrag) {
13200
+ this.classList.add("dragging");
13201
+ this.style.cursor = "grabbing";
13202
+ if (!this.hasAttribute("selected")) this.select();
13203
+ }
12981
13204
  this.#didDrag = true;
12982
13205
  clampAndApply(e.clientX, e.clientY, e.shiftKey);
12983
13206
  this.dispatchEvent(
12984
13207
  new CustomEvent("input", {
12985
13208
  bubbles: true,
12986
- detail: this.#positionDetail(container.getBoundingClientRect()),
13209
+ detail: {
13210
+ ...this.#positionDetail(container.getBoundingClientRect()),
13211
+ shiftKey: e.shiftKey,
13212
+ },
12987
13213
  }),
12988
13214
  );
12989
13215
  };
@@ -12991,38 +13217,63 @@ class FigHandle extends HTMLElement {
12991
13217
  const onUp = (e) => {
12992
13218
  this.#isDragging = false;
12993
13219
  this.style.cursor = "";
13220
+ this.classList.remove("dragging");
12994
13221
  window.removeEventListener("pointermove", onMove);
12995
13222
  window.removeEventListener("pointerup", onUp);
12996
- if (this.#didDrag || !isColorType) {
13223
+ if (this.#didDrag) {
12997
13224
  clampAndApply(e.clientX, e.clientY, e.shiftKey);
13225
+ this.#syncValueAttribute();
13226
+ this.dispatchEvent(
13227
+ new CustomEvent("change", {
13228
+ bubbles: true,
13229
+ detail: this.#positionDetail(container.getBoundingClientRect()),
13230
+ }),
13231
+ );
13232
+ const swallowClick = (evt) => {
13233
+ evt.stopPropagation();
13234
+ evt.preventDefault();
13235
+ };
13236
+ this.addEventListener("click", swallowClick, {
13237
+ capture: true,
13238
+ once: true,
13239
+ });
13240
+ } else {
13241
+ this.#syncValueAttribute();
12998
13242
  }
12999
- this.#syncValueAttribute();
13000
- this.dispatchEvent(
13001
- new CustomEvent("change", {
13002
- bubbles: true,
13003
- detail: this.#positionDetail(container.getBoundingClientRect()),
13004
- }),
13005
- );
13243
+ this.#didDrag = false;
13006
13244
  };
13007
13245
 
13008
13246
  window.addEventListener("pointermove", onMove);
13009
13247
  window.addEventListener("pointerup", onUp);
13010
13248
  }
13011
13249
 
13250
+ showColorTip() {
13251
+ if (this.#colorTip) {
13252
+ this.#colorTip.style.display = "";
13253
+ return;
13254
+ }
13255
+ this.#showColorTip();
13256
+ }
13257
+
13258
+ hideColorTip() {
13259
+ if (!this.#colorTip) return;
13260
+ this.#colorTip.style.display = "none";
13261
+ }
13262
+
13012
13263
  #showColorTip() {
13013
13264
  if (this.#colorTip) return;
13014
13265
  const tip = document.createElement("fig-color-tip");
13015
- if (this.#isAddMode) {
13016
- tip.setAttribute("add", "");
13266
+ if (this.#hasControlMode) {
13267
+ tip.setAttribute("control", this.#controlMode);
13017
13268
  } else {
13018
13269
  tip.setAttribute("value", this.getAttribute("color") || "#D9D9D9");
13019
13270
  tip.setAttribute("alpha", "true");
13020
13271
  tip.setAttribute("selected", "");
13021
13272
  }
13022
- tip.addEventListener("pointerdown", (e) => e.stopPropagation());
13023
13273
  tip.addEventListener("input", this.#handleColorTipInput);
13024
13274
  tip.addEventListener("change", this.#handleColorTipChange);
13025
- tip.addEventListener("add", this.#handleColorTipAdd);
13275
+ tip.addEventListener("add", this.#handleColorTipControl);
13276
+ tip.addEventListener("remove", this.#handleColorTipControl);
13026
13277
  this.appendChild(tip);
13027
13278
  this.#colorTip = tip;
13028
13279
  }
@@ -13031,7 +13282,8 @@ class FigHandle extends HTMLElement {
13031
13282
  if (!this.#colorTip) return;
13032
13283
  this.#colorTip.removeEventListener("input", this.#handleColorTipInput);
13033
13284
  this.#colorTip.removeEventListener("change", this.#handleColorTipChange);
13034
- this.#colorTip.removeEventListener("add", this.#handleColorTipAdd);
13285
+ this.#colorTip.removeEventListener("add", this.#handleColorTipControl);
13286
+ this.#colorTip.removeEventListener("remove", this.#handleColorTipControl);
13035
13287
  this.#colorTip.remove();
13036
13288
  this.#colorTip = null;
13037
13289
  }
@@ -13046,10 +13298,10 @@ class FigHandle extends HTMLElement {
13046
13298
  if (e.detail?.color) this.setAttribute("color", e.detail.color);
13047
13299
  };
13048
13300
 
13049
- #handleColorTipAdd = (e) => {
13301
+ #handleColorTipControl = (e) => {
13050
13302
  e.stopPropagation();
13051
13303
  this.dispatchEvent(
13052
- new CustomEvent("add", { bubbles: true, composed: true }),
13304
+ new CustomEvent(e.type, { bubbles: true, composed: true }),
13053
13305
  );
13054
13306
  };
13055
13307
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "3.13.1",
3
+ "version": "3.14.1",
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",