@rogieking/figui3 11.4.2 → 11.5.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.css CHANGED
@@ -1,5 +1,24 @@
1
1
  /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
2
  @import url("fig-layer.css");
3
+ @import url("fig-property.css");
4
+
5
+ fig-input-point {
6
+ display: inline-flex;
7
+ width: 8.9375rem;
8
+
9
+ &[full]:not([full="false"]) {
10
+ width: 100%;
11
+ }
12
+
13
+ > fig-input-combo {
14
+ width: 100%;
15
+
16
+ > fig-input-number {
17
+ flex: 1 1 0;
18
+ width: auto;
19
+ }
20
+ }
21
+ }
3
22
 
4
23
  :root,
5
24
  :host {
@@ -230,7 +249,8 @@ fig-fill-picker {
230
249
  grid-template-rows: 1fr 1fr;
231
250
  align-items: center;
232
251
  padding-right: var(--spacer-3);
233
- gap: var(--spacer-2);
252
+ row-gap: var(--spacer-2);
253
+ column-gap: var(--spacer-2);
234
254
  padding-bottom: var(--spacer-1);
235
255
 
236
256
  .fig-fill-picker-eyedropper {
@@ -261,20 +281,9 @@ fig-fill-picker {
261
281
  }
262
282
 
263
283
  .fig-fill-picker-inputs {
264
- /* Override labelless 1fr/2fr split so mode select matches eyedropper width
265
- and color inputs take the remaining row. */
266
- &:not([direction="vertical"]):not(:has(> label)) {
267
- grid-template-columns:
268
- var(--fig-field-left-padding)
269
- 3.5rem
270
- var(--spacer-2)
271
- minmax(0, 1fr)
272
- var(--fig-field-right-padding);
273
- }
274
-
275
284
  .fig-fill-picker-input-mode {
276
285
  width: 3.5rem;
277
- flex-shrink: 0;
286
+ flex: unset;
278
287
  align-self: center;
279
288
  }
280
289
 
@@ -364,7 +373,7 @@ fig-fill-picker {
364
373
  padding-right: var(--spacer-2);
365
374
  gap: var(--spacer-1);
366
375
 
367
- > *:not(.fig-field-chevron):not(label) {
376
+ > *:not([slot="leading"]):not([slot="trailing"]):not(label) {
368
377
  grid-area: unset;
369
378
  }
370
379
  }
@@ -454,16 +463,16 @@ fig-fill-picker {
454
463
  }
455
464
 
456
465
  .fig-fill-picker-gradient-stop-row {
457
- padding-right: var(--spacer-2);
458
- display: flex;
459
- padding-left: var(--spacer-3);
460
- gap: var(--spacer-2);
461
-
462
466
  &.dragging {
463
467
  background-color: var(--figma-color-bg-selected);
464
468
  }
465
469
 
466
- & > .fig-fill-picker-stop-position {
470
+ .fig-fill-picker-gradient-stop-inputs {
471
+ width: 100%;
472
+ min-width: 0;
473
+ }
474
+
475
+ .fig-fill-picker-stop-position {
467
476
  flex: 0;
468
477
  flex-basis: 3rem;
469
478
  min-width: 0;
@@ -494,7 +503,7 @@ fig-fill-picker {
494
503
  padding-right: var(--spacer-2);
495
504
  gap: var(--spacer-2);
496
505
 
497
- > *:not(.fig-field-chevron):not(label) {
506
+ > *:not([slot="leading"]):not([slot="trailing"]):not(label) {
498
507
  grid-area: unset;
499
508
  }
500
509
 
@@ -937,18 +946,28 @@ fig-select-option {
937
946
  pointer-events: none;
938
947
  z-index: 1000000;
939
948
  border-radius: 1px;
940
- &::before{
949
+ &::before {
941
950
  content: "";
942
951
  position: absolute;
943
952
  top: 0;
944
- left: var(--spacer-1);
953
+ left: 0;
945
954
  width: 100%;
946
955
  height: 100%;
947
956
  background: var(--figma-color-border-selected);
948
957
  }
949
958
 
950
959
  &.fig-reorder-indicator-ring {
951
- &::after{
960
+ &[data-axis="vertical"]::before {
961
+ left: var(--spacer-1);
962
+ width: calc(100% - var(--spacer-1));
963
+ }
964
+
965
+ &[data-axis="horizontal"]::before {
966
+ top: var(--spacer-1);
967
+ height: calc(100% - var(--spacer-1));
968
+ }
969
+
970
+ &::after {
952
971
  content: "";
953
972
  position: absolute;
954
973
  top: 1px;
@@ -959,6 +978,11 @@ fig-select-option {
959
978
  border: 2px solid var(--figma-color-border-selected);
960
979
  border-radius: 50%;
961
980
  }
981
+
982
+ &[data-axis="horizontal"]::after {
983
+ top: 0;
984
+ left: 1px;
985
+ }
962
986
  }
963
987
 
964
988
  &[data-axis="vertical"] {
package/fig-editor.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
2
  import "./fig.js";
3
+ import "./fig-property.js";
3
4
 
4
5
  function figEditorDefineElement(name, constructor) {
5
6
  if (!customElements.get(name)) {
@@ -82,6 +83,114 @@ function figEditorCreateElement(tagName, attributes, children) {
82
83
  return figEditorAppendChildren(element, children);
83
84
  }
84
85
 
86
+ /**
87
+ * Paired X/Y number inputs for editing a point.
88
+ *
89
+ * @element fig-input-point
90
+ * @attr {number} x - Horizontal coordinate
91
+ * @attr {number} y - Vertical coordinate
92
+ * @attr {string} units - Unit string forwarded to both coordinate inputs
93
+ * @fires input - Emitted while either coordinate is edited with `{ x, y }`
94
+ * @fires change - Emitted when either coordinate is committed with `{ x, y }`
95
+ */
96
+ class FigInputPoint extends HTMLElement {
97
+ #inputs = {};
98
+
99
+ static get observedAttributes() {
100
+ return ["x", "y", "units"];
101
+ }
102
+
103
+ connectedCallback() {
104
+ this.#render();
105
+ this.#sync();
106
+ }
107
+
108
+ attributeChangedCallback(name, oldValue, newValue) {
109
+ if (oldValue === newValue || !this.isConnected) return;
110
+ if (name === "units") this.#syncUnits();
111
+ else this.#syncAxis(name);
112
+ }
113
+
114
+ get value() {
115
+ return {
116
+ x: this.#axisValue("x"),
117
+ y: this.#axisValue("y"),
118
+ };
119
+ }
120
+
121
+ #render() {
122
+ if (this.#inputs.x && this.#inputs.y) return;
123
+
124
+ const createInput = (axis) => {
125
+ const label = axis.toUpperCase();
126
+ const input = figEditorCreateElement(
127
+ "fig-input-number",
128
+ {
129
+ full: true,
130
+ "aria-label": `${label} coordinate`,
131
+ "data-axis": axis,
132
+ },
133
+ figEditorCreateElement("span", { slot: "prepend" }, label),
134
+ );
135
+ input.addEventListener("input", (event) =>
136
+ this.#handleInput(axis, event),
137
+ );
138
+ input.addEventListener("change", (event) =>
139
+ this.#handleInput(axis, event),
140
+ );
141
+ this.#inputs[axis] = input;
142
+ return input;
143
+ };
144
+
145
+ this.replaceChildren(
146
+ figEditorCreateElement(
147
+ "fig-input-combo",
148
+ { full: true },
149
+ ["x", "y"].map(createInput),
150
+ ),
151
+ );
152
+ }
153
+
154
+ #sync() {
155
+ this.#syncAxis("x");
156
+ this.#syncAxis("y");
157
+ this.#syncUnits();
158
+ }
159
+
160
+ #syncAxis(axis) {
161
+ const input = this.#inputs[axis];
162
+ if (!input) return;
163
+ const value = String(this.#axisValue(axis));
164
+ if (input.getAttribute("value") !== value) input.setAttribute("value", value);
165
+ }
166
+
167
+ #syncUnits() {
168
+ const units = this.getAttribute("units");
169
+ for (const input of Object.values(this.#inputs)) {
170
+ if (units === null) input.removeAttribute("units");
171
+ else input.setAttribute("units", units);
172
+ }
173
+ }
174
+
175
+ #axisValue(axis) {
176
+ const value = Number(this.getAttribute(axis));
177
+ return Number.isFinite(value) ? value : 0;
178
+ }
179
+
180
+ #handleInput(axis, event) {
181
+ event.stopPropagation();
182
+ const value = Number(event.detail);
183
+ this.setAttribute(axis, String(Number.isFinite(value) ? value : 0));
184
+ this.dispatchEvent(
185
+ new CustomEvent(event.type, {
186
+ bubbles: true,
187
+ detail: this.value,
188
+ }),
189
+ );
190
+ }
191
+ }
192
+ figEditorDefineElement("fig-input-point", FigInputPoint);
193
+
85
194
  function figEditorNormalizePaletteValue(value) {
86
195
  let entries = value;
87
196
  if (typeof entries === "string") {
@@ -429,42 +538,27 @@ class FigPaletteEditor extends HTMLElement {
429
538
  this.#emit(type);
430
539
  }
431
540
 
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;
541
+ #removeRow(row) {
542
+ const currentIndex = Number.parseInt(row?.dataset.index ?? "", 10);
543
+ if (
544
+ this.#disabled ||
545
+ this.#fixed ||
546
+ this.#colors.length <= this.#min ||
547
+ !Number.isInteger(currentIndex)
548
+ ) {
549
+ return;
550
+ }
551
+ this.#colors.splice(currentIndex, 1);
552
+ this.#reflectValue();
553
+ this.#render();
554
+ this.#emit("input");
555
+ this.#emit("change");
463
556
  }
464
557
 
465
558
  #createRow(entry, index) {
466
559
  const colorInput = figEditorCreateElement("fig-input-color", {
467
560
  className: "fig-palette-editor-color",
561
+ slot: "input",
468
562
  value: figEditorPaletteEntryValue(entry),
469
563
  text: true,
470
564
  alpha: true,
@@ -494,17 +588,22 @@ class FigPaletteEditor extends HTMLElement {
494
588
  syncColor(event, "change"),
495
589
  );
496
590
 
497
- return figEditorCreateElement(
498
- "fig-field",
591
+ const row = figEditorCreateElement(
592
+ "fig-property",
499
593
  {
500
594
  className: "fig-palette-editor-color-row",
501
595
  "data-index": index,
596
+ leading: "drag",
597
+ trailing: this.#fixed ? null : "remove",
598
+ "show-trailing": this.#fixed ? null : true,
502
599
  },
503
- [
504
- colorInput,
505
- ...(this.#fixed ? [] : [this.#createRemoveButton(index)]),
506
- ],
600
+ colorInput,
507
601
  );
602
+ row.addEventListener("remove", (event) => {
603
+ event.stopPropagation();
604
+ this.#removeRow(row);
605
+ });
606
+ return row;
508
607
  }
509
608
 
510
609
  #handleReorder = (event) => {
@@ -594,6 +693,20 @@ class FigPaletteEditor extends HTMLElement {
594
693
  children.push(figEditorCreateElement("fig-field", {}, addButton));
595
694
  }
596
695
  this.replaceChildren(...children);
696
+ this.querySelectorAll(".fig-palette-editor-color-row").forEach(
697
+ (row, index) => {
698
+ const remove = row.querySelector(
699
+ ':scope > [data-fig-property-action="remove"]',
700
+ );
701
+ if (!remove) return;
702
+ remove.classList.add("fig-palette-editor-remove");
703
+ remove.setAttribute("aria-label", `Remove color ${index + 1}`);
704
+ remove.toggleAttribute(
705
+ "disabled",
706
+ this.#disabled || this.#colors.length <= this.#min,
707
+ );
708
+ },
709
+ );
597
710
  }
598
711
  }
599
712
  figEditorDefineElement("fig-palette-editor", FigPaletteEditor);
@@ -3460,7 +3573,6 @@ class FigFillPicker extends HTMLElement {
3460
3573
  eyedropperControl,
3461
3574
  figEditorCreateElement("fig-slider", {
3462
3575
  type: "hue",
3463
- variant: "classic",
3464
3576
  text: "false",
3465
3577
  min: "0",
3466
3578
  max: "360",
@@ -3470,7 +3582,6 @@ class FigFillPicker extends HTMLElement {
3470
3582
  showAlpha
3471
3583
  ? figEditorCreateElement("fig-slider", {
3472
3584
  type: "opacity",
3473
- variant: "classic",
3474
3585
  text: "false",
3475
3586
  min: "0",
3476
3587
  max: "100",
@@ -3929,7 +4040,14 @@ class FigFillPicker extends HTMLElement {
3929
4040
  placeholder: "FFFFFF",
3930
4041
  });
3931
4042
  content = showAlpha
3932
- ? combo([hexInput, alphaField()], "fig-fill-picker-ci-hex-row")
4043
+ ? figEditorCreateElement(
4044
+ "fig-input-combo",
4045
+ {
4046
+ className: "input-combo fig-fill-picker-ci-hex-row",
4047
+ full: true,
4048
+ },
4049
+ [hexInput, alphaField()],
4050
+ )
3933
4051
  : hexInput;
3934
4052
  }
3935
4053
  break;
@@ -4834,41 +4952,44 @@ class FigFillPicker extends HTMLElement {
4834
4952
 
4835
4953
  const rows = this.#gradient.stops.map((stop, index) =>
4836
4954
  figEditorCreateElement(
4837
- "fig-field",
4955
+ "fig-property",
4838
4956
  {
4839
4957
  className: "fig-fill-picker-gradient-stop-row",
4840
4958
  "data-index": index,
4959
+ leading: "drag",
4960
+ trailing: "remove",
4961
+ "show-leading": "hover",
4962
+ "show-trailing": true,
4841
4963
  },
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
- ],
4964
+ figEditorCreateElement(
4965
+ "fig-stack",
4966
+ {
4967
+ className: "fig-fill-picker-gradient-stop-inputs",
4968
+ slot: "input",
4969
+ direction: "horizontal",
4970
+ gap: "2",
4971
+ full: true,
4972
+ },
4973
+ [
4974
+ figEditorCreateElement("fig-input-number", {
4975
+ className: "fig-fill-picker-stop-position",
4976
+ "aria-label": "Gradient stop position",
4977
+ min: "0",
4978
+ max: "100",
4979
+ value: stop.position,
4980
+ units: "%",
4981
+ }),
4982
+ figEditorCreateElement("fig-input-color", {
4983
+ className: "fig-fill-picker-stop-color",
4984
+ "aria-label": "Gradient stop color",
4985
+ text: "true",
4986
+ alpha: "true",
4987
+ picker: "figma",
4988
+ "picker-dialog-position": "right",
4989
+ value: this.#formatStopColorValue(stop),
4990
+ }),
4991
+ ],
4992
+ ),
4872
4993
  ),
4873
4994
  );
4874
4995
  reorder.replaceChildren(...rows);
@@ -4911,16 +5032,21 @@ class FigFillPicker extends HTMLElement {
4911
5032
  stopColor.addEventListener("input", syncStopColor);
4912
5033
  stopColor.addEventListener("change", syncStopColor);
4913
5034
 
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
- });
5035
+ const remove = row.querySelector(
5036
+ ':scope > [data-fig-property-action="remove"]',
5037
+ );
5038
+ remove.classList.add("fig-fill-picker-stop-remove");
5039
+ remove.setAttribute("aria-label", "Remove gradient stop");
5040
+ remove.toggleAttribute("disabled", this.#gradient.stops.length <= 2);
5041
+ row.addEventListener("remove", (event) => {
5042
+ event.stopPropagation();
5043
+ const index = parseInt(row.dataset.index, 10);
5044
+ if (this.#gradient.stops.length > 2) {
5045
+ this.#gradient.stops.splice(index, 1);
5046
+ this.#updateGradientUI();
5047
+ this.#emitInput();
5048
+ }
5049
+ });
4924
5050
  });
4925
5051
  }
4926
5052
 
@@ -0,0 +1,116 @@
1
+ /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
+
3
+ fig-property {
4
+ --fig-property-gap: var(--spacer-2);
5
+ --fig-property-left-padding: var(--spacer-3);
6
+ --fig-property-right-padding: var(--spacer-3);
7
+ --fig-property-top-padding: var(--spacer-1);
8
+ --fig-property-bottom-padding: var(--spacer-1);
9
+ --fig-property-label-ratio: 1fr;
10
+ --fig-property-input-ratio: 2fr;
11
+ --fig-property-trailing-gap: 0;
12
+ --fig-property-trailing-size: 0;
13
+ display: grid;
14
+ grid-template-columns:
15
+ var(--fig-property-left-padding)
16
+ var(
17
+ --fig-property-label-track,
18
+ minmax(0, var(--fig-property-label-ratio))
19
+ )
20
+ minmax(0, var(--fig-property-input-ratio))
21
+ var(--fig-property-trailing-gap)
22
+ var(--fig-property-trailing-size)
23
+ var(--fig-property-right-padding);
24
+ grid-template-areas: "leading label input . trailing pad";
25
+ align-items: start;
26
+ width: 100%;
27
+ min-width: 0;
28
+ margin: 0;
29
+ padding: var(--fig-property-top-padding) 0
30
+ var(--fig-property-bottom-padding);
31
+
32
+ &[show-trailing]:not([show-trailing="hover"]):has(> fig-button[slot="trailing"][variant="ghost"]),
33
+ &[show-trailing]:not([show-trailing="hover"]):has(> [slot="trailing"] fig-button[variant="ghost"]) {
34
+ --fig-property-right-padding: var(--spacer-2);
35
+ }
36
+
37
+ > :is(label, [slot="label"]) {
38
+ grid-area: label;
39
+ display: block;
40
+ min-width: 0;
41
+ height: min(100%, var(--spacer-4));
42
+ margin-right: var(--fig-property-gap);
43
+ padding: 0;
44
+ align-content: center;
45
+ overflow: hidden;
46
+ font: inherit;
47
+ line-height: 1rem;
48
+ text-overflow: ellipsis;
49
+ white-space: nowrap;
50
+ user-select: none;
51
+ }
52
+
53
+ > [slot="leading"] {
54
+ grid-area: leading;
55
+ height: 100%;
56
+ visibility: hidden;
57
+ color: var(--figma-color-text-secondary);
58
+ }
59
+
60
+ &:has(> [slot="trailing"]) {
61
+ --fig-property-label-track: minmax(
62
+ 0,
63
+ calc(
64
+ (
65
+ 100% - var(--fig-property-left-padding) -
66
+ var(--fig-property-right-padding)
67
+ ) /
68
+ 3
69
+ )
70
+ );
71
+ }
72
+
73
+ > [slot="input"] {
74
+ grid-area: input;
75
+ width: 100%;
76
+ min-width: 0;
77
+ }
78
+
79
+ > [slot="trailing"] {
80
+ grid-area: trailing;
81
+ min-width: 0;
82
+ overflow: hidden;
83
+ visibility: hidden;
84
+ }
85
+
86
+ &:is(:hover) > [slot="leading"],
87
+ &:hover > [slot="trailing"],
88
+ &[show-leading]:not([show-leading="hover"]):not([show-leading="false"])
89
+ > [slot="leading"],
90
+ &[show-trailing]:not([show-trailing="hover"]):not([show-trailing="false"])
91
+ > [slot="trailing"] {
92
+ visibility: visible;
93
+ }
94
+
95
+ &:hover:has(> [slot="trailing"]):not([show-trailing="false"]),
96
+ &[show-trailing]:not([show-trailing="hover"]):not([show-trailing="false"])
97
+ {
98
+ --fig-property-trailing-gap: var(--fig-property-gap);
99
+ --fig-property-trailing-size: max-content;
100
+ }
101
+
102
+ &:hover > [slot="trailing"],
103
+ &[show-trailing]:not([show-trailing="hover"]):not([show-trailing="false"])
104
+ > [slot="trailing"] {
105
+ overflow: visible;
106
+ }
107
+
108
+ &[show-leading="false"] > [slot="leading"],
109
+ &[show-trailing="false"] > [slot="trailing"] {
110
+ display: none;
111
+ }
112
+
113
+ &:not(:has(> :is(label, [slot="label"]))) > [slot="input"] {
114
+ grid-column: label-start / input-end;
115
+ }
116
+ }