@rogieking/figui3 11.4.2 → 11.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig-editor.js CHANGED
@@ -1,5 +1,10 @@
1
1
  /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
2
  import "./fig.js";
3
+ import "./fig-property.js";
4
+ import { figGradientCSS } from "./fig-gradient-editor.js";
5
+ import "./fig-popup-easing.js";
6
+ import "./fig-popup-gradient.js";
7
+ import "./fig-popup-palette.js";
3
8
 
4
9
  function figEditorDefineElement(name, constructor) {
5
10
  if (!customElements.get(name)) {
@@ -82,6 +87,114 @@ function figEditorCreateElement(tagName, attributes, children) {
82
87
  return figEditorAppendChildren(element, children);
83
88
  }
84
89
 
90
+ /**
91
+ * Paired X/Y number inputs for editing a point.
92
+ *
93
+ * @element fig-input-point
94
+ * @attr {number} x - Horizontal coordinate
95
+ * @attr {number} y - Vertical coordinate
96
+ * @attr {string} units - Unit string forwarded to both coordinate inputs
97
+ * @fires input - Emitted while either coordinate is edited with `{ x, y }`
98
+ * @fires change - Emitted when either coordinate is committed with `{ x, y }`
99
+ */
100
+ class FigInputPoint extends HTMLElement {
101
+ #inputs = {};
102
+
103
+ static get observedAttributes() {
104
+ return ["x", "y", "units"];
105
+ }
106
+
107
+ connectedCallback() {
108
+ this.#render();
109
+ this.#sync();
110
+ }
111
+
112
+ attributeChangedCallback(name, oldValue, newValue) {
113
+ if (oldValue === newValue || !this.isConnected) return;
114
+ if (name === "units") this.#syncUnits();
115
+ else this.#syncAxis(name);
116
+ }
117
+
118
+ get value() {
119
+ return {
120
+ x: this.#axisValue("x"),
121
+ y: this.#axisValue("y"),
122
+ };
123
+ }
124
+
125
+ #render() {
126
+ if (this.#inputs.x && this.#inputs.y) return;
127
+
128
+ const createInput = (axis) => {
129
+ const label = axis.toUpperCase();
130
+ const input = figEditorCreateElement(
131
+ "fig-input-number",
132
+ {
133
+ full: true,
134
+ "aria-label": `${label} coordinate`,
135
+ "data-axis": axis,
136
+ },
137
+ figEditorCreateElement("span", { slot: "prepend" }, label),
138
+ );
139
+ input.addEventListener("input", (event) =>
140
+ this.#handleInput(axis, event),
141
+ );
142
+ input.addEventListener("change", (event) =>
143
+ this.#handleInput(axis, event),
144
+ );
145
+ this.#inputs[axis] = input;
146
+ return input;
147
+ };
148
+
149
+ this.replaceChildren(
150
+ figEditorCreateElement(
151
+ "fig-input-combo",
152
+ { full: true },
153
+ ["x", "y"].map(createInput),
154
+ ),
155
+ );
156
+ }
157
+
158
+ #sync() {
159
+ this.#syncAxis("x");
160
+ this.#syncAxis("y");
161
+ this.#syncUnits();
162
+ }
163
+
164
+ #syncAxis(axis) {
165
+ const input = this.#inputs[axis];
166
+ if (!input) return;
167
+ const value = String(this.#axisValue(axis));
168
+ if (input.getAttribute("value") !== value) input.setAttribute("value", value);
169
+ }
170
+
171
+ #syncUnits() {
172
+ const units = this.getAttribute("units");
173
+ for (const input of Object.values(this.#inputs)) {
174
+ if (units === null) input.removeAttribute("units");
175
+ else input.setAttribute("units", units);
176
+ }
177
+ }
178
+
179
+ #axisValue(axis) {
180
+ const value = Number(this.getAttribute(axis));
181
+ return Number.isFinite(value) ? value : 0;
182
+ }
183
+
184
+ #handleInput(axis, event) {
185
+ event.stopPropagation();
186
+ const value = Number(event.detail);
187
+ this.setAttribute(axis, String(Number.isFinite(value) ? value : 0));
188
+ this.dispatchEvent(
189
+ new CustomEvent(event.type, {
190
+ bubbles: true,
191
+ detail: this.value,
192
+ }),
193
+ );
194
+ }
195
+ }
196
+ figEditorDefineElement("fig-input-point", FigInputPoint);
197
+
85
198
  function figEditorNormalizePaletteValue(value) {
86
199
  let entries = value;
87
200
  if (typeof entries === "string") {
@@ -429,42 +542,27 @@ class FigPaletteEditor extends HTMLElement {
429
542
  this.#emit(type);
430
543
  }
431
544
 
432
- #createRemoveButton(index) {
433
- const button = figEditorCreateElement(
434
- "fig-button",
435
- {
436
- className: "fig-palette-editor-remove",
437
- variant: "ghost",
438
- icon: true,
439
- "aria-label": `Remove color ${index + 1}`,
440
- disabled:
441
- this.#disabled || this.#colors.length <= this.#min || null,
442
- },
443
- figEditorCreateIcon("minus"),
444
- );
445
- button.addEventListener("click", () => {
446
- const row = button.closest(".fig-palette-editor-color-row");
447
- const currentIndex = Number.parseInt(row?.dataset.index ?? "", 10);
448
- if (
449
- this.#disabled ||
450
- this.#fixed ||
451
- this.#colors.length <= this.#min ||
452
- !Number.isInteger(currentIndex)
453
- ) {
454
- return;
455
- }
456
- this.#colors.splice(currentIndex, 1);
457
- this.#reflectValue();
458
- this.#render();
459
- this.#emit("input");
460
- this.#emit("change");
461
- });
462
- return button;
545
+ #removeRow(row) {
546
+ const currentIndex = Number.parseInt(row?.dataset.index ?? "", 10);
547
+ if (
548
+ this.#disabled ||
549
+ this.#fixed ||
550
+ this.#colors.length <= this.#min ||
551
+ !Number.isInteger(currentIndex)
552
+ ) {
553
+ return;
554
+ }
555
+ this.#colors.splice(currentIndex, 1);
556
+ this.#reflectValue();
557
+ this.#render();
558
+ this.#emit("input");
559
+ this.#emit("change");
463
560
  }
464
561
 
465
562
  #createRow(entry, index) {
466
563
  const colorInput = figEditorCreateElement("fig-input-color", {
467
564
  className: "fig-palette-editor-color",
565
+ slot: "input",
468
566
  value: figEditorPaletteEntryValue(entry),
469
567
  text: true,
470
568
  alpha: true,
@@ -494,17 +592,22 @@ class FigPaletteEditor extends HTMLElement {
494
592
  syncColor(event, "change"),
495
593
  );
496
594
 
497
- return figEditorCreateElement(
498
- "fig-field",
595
+ const row = figEditorCreateElement(
596
+ "fig-property",
499
597
  {
500
598
  className: "fig-palette-editor-color-row",
501
599
  "data-index": index,
600
+ leading: "drag",
601
+ trailing: this.#fixed ? null : "remove",
602
+ "show-trailing": this.#fixed ? null : true,
502
603
  },
503
- [
504
- colorInput,
505
- ...(this.#fixed ? [] : [this.#createRemoveButton(index)]),
506
- ],
604
+ colorInput,
507
605
  );
606
+ row.addEventListener("remove", (event) => {
607
+ event.stopPropagation();
608
+ this.#removeRow(row);
609
+ });
610
+ return row;
508
611
  }
509
612
 
510
613
  #handleReorder = (event) => {
@@ -594,6 +697,20 @@ class FigPaletteEditor extends HTMLElement {
594
697
  children.push(figEditorCreateElement("fig-field", {}, addButton));
595
698
  }
596
699
  this.replaceChildren(...children);
700
+ this.querySelectorAll(".fig-palette-editor-color-row").forEach(
701
+ (row, index) => {
702
+ const remove = row.querySelector(
703
+ ':scope > [data-fig-property-action="remove"]',
704
+ );
705
+ if (!remove) return;
706
+ remove.classList.add("fig-palette-editor-remove");
707
+ remove.setAttribute("aria-label", `Remove color ${index + 1}`);
708
+ remove.toggleAttribute(
709
+ "disabled",
710
+ this.#disabled || this.#colors.length <= this.#min,
711
+ );
712
+ },
713
+ );
597
714
  }
598
715
  }
599
716
  figEditorDefineElement("fig-palette-editor", FigPaletteEditor);
@@ -3460,7 +3577,6 @@ class FigFillPicker extends HTMLElement {
3460
3577
  eyedropperControl,
3461
3578
  figEditorCreateElement("fig-slider", {
3462
3579
  type: "hue",
3463
- variant: "classic",
3464
3580
  text: "false",
3465
3581
  min: "0",
3466
3582
  max: "360",
@@ -3470,7 +3586,6 @@ class FigFillPicker extends HTMLElement {
3470
3586
  showAlpha
3471
3587
  ? figEditorCreateElement("fig-slider", {
3472
3588
  type: "opacity",
3473
- variant: "classic",
3474
3589
  text: "false",
3475
3590
  min: "0",
3476
3591
  max: "100",
@@ -3929,7 +4044,14 @@ class FigFillPicker extends HTMLElement {
3929
4044
  placeholder: "FFFFFF",
3930
4045
  });
3931
4046
  content = showAlpha
3932
- ? combo([hexInput, alphaField()], "fig-fill-picker-ci-hex-row")
4047
+ ? figEditorCreateElement(
4048
+ "fig-input-combo",
4049
+ {
4050
+ className: "input-combo fig-fill-picker-ci-hex-row",
4051
+ full: true,
4052
+ },
4053
+ [hexInput, alphaField()],
4054
+ )
3933
4055
  : hexInput;
3934
4056
  }
3935
4057
  break;
@@ -4122,6 +4244,29 @@ class FigFillPicker extends HTMLElement {
4122
4244
 
4123
4245
  // ============ GRADIENT TAB ============
4124
4246
  #initGradientTab() {
4247
+ {
4248
+ const container = this.#dialog.querySelector('[data-tab="gradient"]');
4249
+ const editor = document.createElement("fig-gradient-editor");
4250
+ editor.value = gradientToValueShape(this.#gradient);
4251
+ editor.setAttribute("color-space", this.#gamut);
4252
+ editor.toggleAttribute("disabled", this.#isDisabled());
4253
+ editor.pickerAnchor = this.#dialog;
4254
+ const syncFromEditor = (event) => {
4255
+ event.stopPropagation();
4256
+ if (!event.detail?.gradient) return;
4257
+ this.#gradient = lockFillPickerGradientInterpolation(
4258
+ event.detail.gradient,
4259
+ );
4260
+ this.#updateSwatch();
4261
+ if (event.type === "change") this.#emitChange();
4262
+ else this.#emitInput();
4263
+ };
4264
+ editor.addEventListener("input", syncFromEditor);
4265
+ editor.addEventListener("change", syncFromEditor);
4266
+ container.replaceChildren(editor);
4267
+ return;
4268
+ }
4269
+
4125
4270
  const container = this.#dialog.querySelector('[data-tab="gradient"]');
4126
4271
  const gradientType = figEditorCreateElement(
4127
4272
  "fig-select",
@@ -4699,6 +4844,14 @@ class FigFillPicker extends HTMLElement {
4699
4844
  const container = this.#dialog.querySelector('[data-tab="gradient"]');
4700
4845
  if (!container) return;
4701
4846
  this.#gradient = lockFillPickerGradientInterpolation(this.#gradient);
4847
+ const editor = container.querySelector("fig-gradient-editor");
4848
+ if (editor) {
4849
+ editor.value = this.#gradient;
4850
+ editor.setAttribute("color-space", this.#gamut);
4851
+ editor.toggleAttribute("disabled", this.#isDisabled());
4852
+ editor.pickerAnchor = this.#dialog;
4853
+ return;
4854
+ }
4702
4855
 
4703
4856
  const interpolationValue = gradientInterpolationSelectValue(this.#gradient);
4704
4857
  const interpolationSelect = container.querySelector(
@@ -4777,6 +4930,16 @@ class FigFillPicker extends HTMLElement {
4777
4930
  #updateGradientPreview() {
4778
4931
  if (!this.#dialog) return;
4779
4932
 
4933
+ const editor = this.#dialog.querySelector(
4934
+ '[data-tab="gradient"] > fig-gradient-editor',
4935
+ );
4936
+ if (editor) {
4937
+ editor.value = this.#gradient;
4938
+ editor.setAttribute("color-space", this.#gamut);
4939
+ this.#updateSwatch();
4940
+ return;
4941
+ }
4942
+
4780
4943
  const barInput = this.#dialog.querySelector(
4781
4944
  ".fig-fill-picker-gradient-bar-input",
4782
4945
  );
@@ -4834,41 +4997,44 @@ class FigFillPicker extends HTMLElement {
4834
4997
 
4835
4998
  const rows = this.#gradient.stops.map((stop, index) =>
4836
4999
  figEditorCreateElement(
4837
- "fig-field",
5000
+ "fig-property",
4838
5001
  {
4839
5002
  className: "fig-fill-picker-gradient-stop-row",
4840
5003
  "data-index": index,
5004
+ leading: "drag",
5005
+ trailing: "remove",
5006
+ "show-leading": "hover",
5007
+ "show-trailing": true,
4841
5008
  },
4842
- [
4843
- figEditorCreateElement("fig-input-number", {
4844
- className: "fig-fill-picker-stop-position",
4845
- "aria-label": "Gradient stop position",
4846
- min: "0",
4847
- max: "100",
4848
- value: stop.position,
4849
- units: "%",
4850
- }),
4851
- figEditorCreateElement("fig-input-color", {
4852
- className: "fig-fill-picker-stop-color",
4853
- "aria-label": "Gradient stop color",
4854
- text: "true",
4855
- alpha: "true",
4856
- picker: "figma",
4857
- "picker-dialog-position": "right",
4858
- value: this.#formatStopColorValue(stop),
4859
- }),
4860
- figEditorCreateElement(
4861
- "fig-button",
4862
- {
4863
- icon: true,
4864
- variant: "ghost",
4865
- className: "fig-fill-picker-stop-remove",
4866
- disabled: this.#gradient.stops.length <= 2,
4867
- "aria-label": "Remove gradient stop",
4868
- },
4869
- figEditorCreateIcon("minus"),
4870
- ),
4871
- ],
5009
+ figEditorCreateElement(
5010
+ "fig-stack",
5011
+ {
5012
+ className: "fig-fill-picker-gradient-stop-inputs",
5013
+ slot: "input",
5014
+ direction: "horizontal",
5015
+ gap: "2",
5016
+ full: true,
5017
+ },
5018
+ [
5019
+ figEditorCreateElement("fig-input-number", {
5020
+ className: "fig-fill-picker-stop-position",
5021
+ "aria-label": "Gradient stop position",
5022
+ min: "0",
5023
+ max: "100",
5024
+ value: stop.position,
5025
+ units: "%",
5026
+ }),
5027
+ figEditorCreateElement("fig-input-color", {
5028
+ className: "fig-fill-picker-stop-color",
5029
+ "aria-label": "Gradient stop color",
5030
+ text: "true",
5031
+ alpha: "true",
5032
+ picker: "figma",
5033
+ "picker-dialog-position": "right",
5034
+ value: this.#formatStopColorValue(stop),
5035
+ }),
5036
+ ],
5037
+ ),
4872
5038
  ),
4873
5039
  );
4874
5040
  reorder.replaceChildren(...rows);
@@ -4911,16 +5077,21 @@ class FigFillPicker extends HTMLElement {
4911
5077
  stopColor.addEventListener("input", syncStopColor);
4912
5078
  stopColor.addEventListener("change", syncStopColor);
4913
5079
 
4914
- row
4915
- .querySelector(".fig-fill-picker-stop-remove")
4916
- .addEventListener("click", () => {
4917
- const index = parseInt(row.dataset.index, 10);
4918
- if (this.#gradient.stops.length > 2) {
4919
- this.#gradient.stops.splice(index, 1);
4920
- this.#updateGradientUI();
4921
- this.#emitInput();
4922
- }
4923
- });
5080
+ const remove = row.querySelector(
5081
+ ':scope > [data-fig-property-action="remove"]',
5082
+ );
5083
+ remove.classList.add("fig-fill-picker-stop-remove");
5084
+ remove.setAttribute("aria-label", "Remove gradient stop");
5085
+ remove.toggleAttribute("disabled", this.#gradient.stops.length <= 2);
5086
+ row.addEventListener("remove", (event) => {
5087
+ event.stopPropagation();
5088
+ const index = parseInt(row.dataset.index, 10);
5089
+ if (this.#gradient.stops.length > 2) {
5090
+ this.#gradient.stops.splice(index, 1);
5091
+ this.#updateGradientUI();
5092
+ this.#emitInput();
5093
+ }
5094
+ });
4924
5095
  });
4925
5096
  }
4926
5097
 
@@ -4967,13 +5138,7 @@ class FigFillPicker extends HTMLElement {
4967
5138
  }
4968
5139
 
4969
5140
  #getGradientCSS() {
4970
- const preferred = this.#buildGradientCSS(undefined, true);
4971
- if (this.#testGradientSupport(preferred)) return preferred;
4972
-
4973
- const oklabFallback = this.#buildGradientCSS("oklab", true);
4974
- if (this.#testGradientSupport(oklabFallback)) return oklabFallback;
4975
-
4976
- return this.#buildGradientCSS("oklab", false);
5141
+ return figGradientCSS(this.#gradient, { colorSpace: this.#gamut });
4977
5142
  }
4978
5143
 
4979
5144
  // ============ IMAGE TAB ============
@@ -0,0 +1,76 @@
1
+ /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
+ fig-gradient-editor {
3
+ display: block;
4
+ width: 100%;
5
+ min-width: 0;
6
+
7
+ .fig-fill-picker-gradient-preview {
8
+ aspect-ratio: auto;
9
+ width: calc(100% - var(--spacer-3) * 2);
10
+ margin: var(--spacer-3) var(--spacer-3) 0;
11
+ background: transparent;
12
+ overflow: visible;
13
+
14
+ &::after {
15
+ display: none;
16
+ }
17
+ }
18
+
19
+ fig-header.fig-fill-picker-gradient-header {
20
+ .fig-fill-picker-gradient-type {
21
+ display: flex;
22
+ flex: 0 1 auto;
23
+ min-width: 0;
24
+ width: auto;
25
+ }
26
+
27
+ .fig-fill-picker-gradient-actions {
28
+ flex: 0 0 auto;
29
+ }
30
+ }
31
+
32
+ .fig-fill-picker-gradient-stops {
33
+ .fig-fill-picker-gradient-stops-header > span {
34
+ flex: 1;
35
+ }
36
+ }
37
+
38
+ .fig-fill-picker-gradient-stops-list {
39
+ display: flex;
40
+ flex-direction: column;
41
+
42
+ fig-reorder {
43
+ display: contents;
44
+ }
45
+ }
46
+
47
+ .fig-fill-picker-gradient-stop-row {
48
+ &.dragging {
49
+ background-color: var(--figma-color-bg-selected);
50
+ }
51
+
52
+ .fig-fill-picker-gradient-stop-inputs {
53
+ width: 100%;
54
+ min-width: 0;
55
+ }
56
+
57
+ .fig-fill-picker-stop-position {
58
+ flex: 0 0 3rem;
59
+ min-width: 0;
60
+ width: 3rem;
61
+ }
62
+
63
+ .fig-fill-picker-stop-color {
64
+ flex: 1;
65
+ min-width: 0;
66
+
67
+ fig-input-text {
68
+ min-width: 0;
69
+ }
70
+ }
71
+
72
+ .fig-fill-picker-stop-remove {
73
+ flex-shrink: 0;
74
+ }
75
+ }
76
+ }