@rogieking/figui3 11.6.0 → 11.6.2

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.
@@ -1,6 +1,7 @@
1
1
  /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
2
  import "./fig.js";
3
3
  import "./fig-property.js";
4
+ import { figConvertRGB, figHexToRGB } from "./fig-color.js";
4
5
 
5
6
  const DEFAULT_GRADIENT = {
6
7
  type: "linear",
@@ -55,6 +56,68 @@ function normalizeHex(color) {
55
56
  : "#888888";
56
57
  }
57
58
 
59
+ function stopsSemanticallyEqual(a, b) {
60
+ return (
61
+ Number(a?.position) === Number(b?.position) &&
62
+ normalizeHex(a?.color) === normalizeHex(b?.color) &&
63
+ Number(a?.opacity ?? 100) === Number(b?.opacity ?? 100)
64
+ );
65
+ }
66
+
67
+ function stableSemanticValue(value) {
68
+ if (Array.isArray(value)) return value.map(stableSemanticValue);
69
+ if (!value || typeof value !== "object") return value;
70
+ return Object.fromEntries(
71
+ Object.keys(value)
72
+ .filter((key) => value[key] !== undefined)
73
+ .sort()
74
+ .map((key) => [key, stableSemanticValue(value[key])]),
75
+ );
76
+ }
77
+
78
+ function gradientsSemanticallyEqual(a, b) {
79
+ return (
80
+ !!a &&
81
+ !!b &&
82
+ JSON.stringify(stableSemanticValue(a)) ===
83
+ JSON.stringify(stableSemanticValue(b))
84
+ );
85
+ }
86
+
87
+ function cloneStops(stops) {
88
+ return stops.map((stop) => ({ ...stop }));
89
+ }
90
+
91
+ function matchUnchangedStops(previous, next) {
92
+ const rows = previous.length;
93
+ const columns = next.length;
94
+ const lengths = Array.from({ length: rows + 1 }, () =>
95
+ Array(columns + 1).fill(0),
96
+ );
97
+ for (let i = rows - 1; i >= 0; i -= 1) {
98
+ for (let j = columns - 1; j >= 0; j -= 1) {
99
+ lengths[i][j] = stopsSemanticallyEqual(previous[i], next[j])
100
+ ? lengths[i + 1][j + 1] + 1
101
+ : Math.max(lengths[i + 1][j], lengths[i][j + 1]);
102
+ }
103
+ }
104
+ const matches = [];
105
+ let i = 0;
106
+ let j = 0;
107
+ while (i < rows && j < columns) {
108
+ if (stopsSemanticallyEqual(previous[i], next[j])) {
109
+ matches.push([i, j]);
110
+ i += 1;
111
+ j += 1;
112
+ } else if (lengths[i + 1][j] >= lengths[i][j + 1]) {
113
+ i += 1;
114
+ } else {
115
+ j += 1;
116
+ }
117
+ }
118
+ return matches;
119
+ }
120
+
58
121
  export function normalizeGradientEditorValue(value) {
59
122
  let parsed = value;
60
123
  if (typeof parsed === "string") {
@@ -95,11 +158,13 @@ export function normalizeGradientEditorValue(value) {
95
158
  }
96
159
 
97
160
  function colorValue(hex, alpha, colorSpace) {
98
- const r = parseInt(hex.slice(1, 3), 16);
99
- const g = parseInt(hex.slice(3, 5), 16);
100
- const b = parseInt(hex.slice(5, 7), 16);
161
+ const { r, g, b } = figHexToRGB(hex);
101
162
  if (colorSpace === "display-p3") {
102
- return `color(display-p3 ${+(r / 255).toFixed(4)} ${+(g / 255).toFixed(4)} ${+(b / 255).toFixed(4)} / ${alpha})`;
163
+ const p3 = figConvertRGB(r, g, b, {
164
+ from: "srgb",
165
+ to: "display-p3",
166
+ });
167
+ return `color(display-p3 ${+(p3.r / 255).toFixed(4)} ${+(p3.g / 255).toFixed(4)} ${+(p3.b / 255).toFixed(4)} / ${alpha})`;
103
168
  }
104
169
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
105
170
  }
@@ -133,6 +198,8 @@ export function figGradientCSS(value, { colorSpace = "srgb" } = {}) {
133
198
  * @attr {string} value - JSON gradient config or full gradient fill value
134
199
  * @attr {string} color-space - `srgb` or `display-p3`
135
200
  * @attr {boolean} disabled - Disables all editing
201
+ * @attr {"fade"|"visible"} overflow - Stop-list overflow mode (default: fade)
202
+ * @attr {number} show-rows - Visible stop rows before overflow (default: 6.5)
136
203
  * @fires input - During edits with `{ type, gradient, css }`
137
204
  * @fires change - On committed edits with `{ type, gradient, css }`
138
205
  */
@@ -141,9 +208,21 @@ class FigGradientEditor extends HTMLElement {
141
208
  #syncingValue = false;
142
209
  #syncingBar = false;
143
210
  #pickerAnchor = null;
211
+ #mounted = false;
212
+ #gradientType = null;
213
+ #gradientBar = null;
214
+ #reorder = null;
215
+ #flipButton = null;
216
+ #rotateButton = null;
217
+ #addButton = null;
218
+ #stopRows = [];
219
+ #renderedStops = [];
220
+ #selectedStopRow = null;
221
+ #pointerInteractionActive = false;
222
+ #deferredExternalGradient = null;
144
223
 
145
224
  static get observedAttributes() {
146
- return ["value", "color-space", "disabled"];
225
+ return ["value", "color-space", "disabled", "overflow", "show-rows"];
147
226
  }
148
227
 
149
228
  get value() {
@@ -173,20 +252,45 @@ class FigGradientEditor extends HTMLElement {
173
252
  }
174
253
 
175
254
  connectedCallback() {
176
- this.#gradient = normalizeGradientEditorValue(this.getAttribute("value"));
177
- this.#render();
255
+ const incoming = normalizeGradientEditorValue(this.getAttribute("value"));
256
+ if (!gradientsSemanticallyEqual(this.#gradient, incoming)) {
257
+ this.#gradient = incoming;
258
+ }
259
+ this.#mount();
260
+ this.#sync();
178
261
  }
179
262
 
180
263
  attributeChangedCallback(name, oldValue, newValue) {
181
264
  if (oldValue === newValue || this.#syncingValue) return;
182
265
  if (name === "value") {
183
- this.#gradient = normalizeGradientEditorValue(newValue);
266
+ const incoming = normalizeGradientEditorValue(newValue);
267
+ if (gradientsSemanticallyEqual(this.#gradient, incoming)) {
268
+ if (this.#pointerInteractionActive) {
269
+ this.#deferredExternalGradient = null;
270
+ }
271
+ return;
272
+ }
273
+ if (this.#pointerInteractionActive) {
274
+ this.#deferredExternalGradient = incoming;
275
+ return;
276
+ }
277
+ this.#deferredExternalGradient = null;
278
+ this.#gradient = incoming;
279
+ }
280
+ if (this.isConnected && this.#mounted) this.#sync();
281
+ }
282
+
283
+ disconnectedCallback() {
284
+ this.#endPointerInteraction({ reconcile: false });
285
+ if (this.#deferredExternalGradient) {
286
+ this.#gradient = this.#deferredExternalGradient;
287
+ this.#deferredExternalGradient = null;
288
+ this.#reflectValue();
184
289
  }
185
- if (this.isConnected) this.#render();
186
290
  }
187
291
 
188
292
  refreshLayout() {
189
- this.querySelector(".fig-fill-picker-gradient-bar-input")?.refreshLayout?.();
293
+ this.#gradientBar?.refreshLayout?.();
190
294
  }
191
295
 
192
296
  #disabled() {
@@ -196,6 +300,15 @@ class FigGradientEditor extends HTMLElement {
196
300
  );
197
301
  }
198
302
 
303
+ #overflowMode() {
304
+ return this.getAttribute("overflow") === "visible" ? "visible" : "fade";
305
+ }
306
+
307
+ #showRows() {
308
+ const value = Number(this.getAttribute("show-rows"));
309
+ return Number.isFinite(value) && value > 0 ? value : null;
310
+ }
311
+
199
312
  #reflectValue() {
200
313
  this.#syncingValue = true;
201
314
  this.setAttribute("value", JSON.stringify(this.#gradient));
@@ -221,16 +334,14 @@ class FigGradientEditor extends HTMLElement {
221
334
  );
222
335
  }
223
336
 
224
- #render() {
225
- const disabled = this.#disabled();
226
- const gradientType = createElement(
337
+ #mount() {
338
+ if (this.#mounted) return;
339
+ this.#gradientType = createElement(
227
340
  "fig-select",
228
341
  {
229
342
  className: "fig-fill-picker-gradient-type",
230
343
  label: "Gradient type",
231
- value: this.#gradient.type,
232
344
  variant: "ghost",
233
- disabled,
234
345
  },
235
346
  createElement(
236
347
  "fig-select-options",
@@ -244,22 +355,38 @@ class FigGradientEditor extends HTMLElement {
244
355
  ),
245
356
  ),
246
357
  );
247
- const action = (label, className, icon) =>
248
- createElement(
249
- "fig-tooltip",
250
- { text: label },
251
- createElement(
252
- "fig-button",
253
- {
254
- icon: true,
255
- variant: "ghost",
256
- className,
257
- "aria-label": label,
258
- disabled,
259
- },
260
- createIcon(icon),
261
- ),
358
+ const action = (label, className, icon) => {
359
+ const button = createElement(
360
+ "fig-button",
361
+ {
362
+ icon: true,
363
+ variant: "ghost",
364
+ className,
365
+ "aria-label": label,
366
+ },
367
+ createIcon(icon),
262
368
  );
369
+ return {
370
+ button,
371
+ tooltip: createElement(
372
+ "fig-tooltip",
373
+ { text: label },
374
+ button,
375
+ ),
376
+ };
377
+ };
378
+ const flipAction = action(
379
+ "Flip gradient",
380
+ "fig-fill-picker-gradient-flip",
381
+ "swap",
382
+ );
383
+ const rotateAction = action(
384
+ "Rotate gradient",
385
+ "fig-fill-picker-gradient-rotate",
386
+ "rotate",
387
+ );
388
+ this.#flipButton = flipAction.button;
389
+ this.#rotateButton = rotateAction.button;
263
390
  const header = createElement(
264
391
  "fig-header",
265
392
  {
@@ -268,42 +395,30 @@ class FigGradientEditor extends HTMLElement {
268
395
  compact: true,
269
396
  },
270
397
  [
271
- gradientType,
398
+ this.#gradientType,
272
399
  createElement(
273
400
  "fig-stack",
274
401
  {
275
402
  className: "fig-fill-picker-gradient-actions",
276
403
  gap: "1",
277
404
  },
278
- [
279
- action("Flip gradient", "fig-fill-picker-gradient-flip", "swap"),
280
- action(
281
- "Rotate gradient",
282
- "fig-fill-picker-gradient-rotate",
283
- "rotate",
284
- ),
285
- ],
405
+ [flipAction.tooltip, rotateAction.tooltip],
286
406
  ),
287
407
  ],
288
408
  );
289
- const gradientBar = createElement("fig-input-gradient", {
409
+ this.#gradientBar = createElement("fig-input-gradient", {
290
410
  className: "fig-fill-picker-gradient-bar-input",
291
411
  "aria-label": "Gradient stops",
292
412
  edit: "true",
293
413
  mode: "tip",
294
414
  size: "large",
295
- disabled,
296
- value: JSON.stringify({
297
- type: "gradient",
298
- gradient: this.#gradient,
299
- }),
300
415
  });
301
416
  const preview = createElement(
302
417
  "fig-preview",
303
418
  { className: "fig-fill-picker-gradient-preview" },
304
- gradientBar,
419
+ this.#gradientBar,
305
420
  );
306
- const addButton = createElement(
421
+ this.#addButton = createElement(
307
422
  "fig-button",
308
423
  {
309
424
  icon: true,
@@ -311,12 +426,10 @@ class FigGradientEditor extends HTMLElement {
311
426
  className: "fig-fill-picker-gradient-add",
312
427
  "aria-label": "Add gradient stop",
313
428
  title: "Add stop",
314
- disabled,
315
429
  },
316
430
  createIcon("add"),
317
431
  );
318
- const reorder = document.createElement("fig-reorder");
319
- reorder.toggleAttribute("disabled", disabled);
432
+ this.#reorder = document.createElement("fig-reorder");
320
433
  const stops = createElement(
321
434
  "div",
322
435
  { className: "fig-fill-picker-gradient-stops" },
@@ -327,106 +440,43 @@ class FigGradientEditor extends HTMLElement {
327
440
  className: "fig-fill-picker-gradient-stops-header",
328
441
  borderless: true,
329
442
  },
330
- [createElement("h3", {}, "Stops"), addButton],
443
+ [createElement("h3", {}, "Stops"), this.#addButton],
331
444
  ),
332
445
  createElement(
333
446
  "div",
334
447
  { className: "fig-fill-picker-gradient-stops-list" },
335
- reorder,
448
+ this.#reorder,
336
449
  ),
337
450
  ],
338
451
  );
339
452
 
340
453
  this.replaceChildren(header, preview, stops);
341
- this.#renderStopRows(reorder);
342
- this.#setupEvents();
343
- }
344
-
345
- #setupEvents() {
346
- const select = this.querySelector(".fig-fill-picker-gradient-type");
347
- select?.addEventListener("change", (event) => {
348
- event.stopPropagation();
349
- const value =
350
- typeof event.detail === "string" ? event.detail : event.target?.value;
351
- if (!["linear", "radial", "angular"].includes(value)) return;
352
- this.#gradient.type = value;
353
- this.#syncPreview();
354
- this.#emit("input");
355
- });
356
-
357
- this.querySelector(".fig-fill-picker-gradient-rotate")?.addEventListener(
358
- "click",
359
- () => {
360
- this.#gradient.angle = (Number(this.#gradient.angle) + 90) % 360;
361
- this.#syncPreview();
362
- this.#emit("input");
363
- },
454
+ this.#gradientType.addEventListener("change", this.#handleTypeChange);
455
+ this.#gradientType.addEventListener("optionhover", (event) =>
456
+ event.stopPropagation(),
364
457
  );
365
- this.querySelector(".fig-fill-picker-gradient-flip")?.addEventListener(
366
- "click",
367
- () => {
368
- this.#gradient.stops.forEach((stop) => {
369
- stop.position = 100 - stop.position;
370
- });
371
- this.#gradient.stops.sort((a, b) => a.position - b.position);
372
- this.#render();
373
- this.#emit("input");
374
- },
458
+ this.#rotateButton.addEventListener("click", this.#handleRotate);
459
+ this.#flipButton.addEventListener("click", this.#handleFlip);
460
+ this.#addButton.addEventListener("click", this.#handleAdd);
461
+ this.#gradientBar.addEventListener("input", this.#handleBarEvent);
462
+ this.#gradientBar.addEventListener("change", this.#handleBarEvent);
463
+ this.#gradientBar.addEventListener(
464
+ "pointerdown",
465
+ this.#handleBarStopSelection,
375
466
  );
376
- this.querySelector(".fig-fill-picker-gradient-add")?.addEventListener(
377
- "click",
378
- () => {
379
- this.#gradient.stops.push({
380
- position: 50,
381
- color: "#888888",
382
- opacity: 100,
383
- });
384
- this.#gradient.stops.sort((a, b) => a.position - b.position);
385
- this.#render();
386
- this.#emit("input");
387
- },
467
+ this.#gradientBar.addEventListener(
468
+ "focusin",
469
+ this.#handleBarStopSelection,
388
470
  );
389
-
390
- const bar = this.querySelector(".fig-fill-picker-gradient-bar-input");
391
- const syncBar = (event) => {
392
- event.stopPropagation();
393
- if (this.#syncingBar || !event.detail?.gradient) return false;
394
- this.#gradient = normalizeGradientEditorValue({
395
- ...this.#gradient,
396
- ...event.detail.gradient,
397
- });
398
- this.#renderStopRows(
399
- this.querySelector(".fig-fill-picker-gradient-stops-list fig-reorder"),
400
- );
401
- return true;
402
- };
403
- bar?.addEventListener("input", (event) => {
404
- if (syncBar(event)) this.#emit("input");
405
- });
406
- bar?.addEventListener("change", (event) => {
407
- if (syncBar(event)) this.#emit("change");
408
- });
409
-
410
- this.querySelector("fig-reorder")?.addEventListener("reorder", (event) => {
411
- const { oldIndex, newIndex } = event.detail ?? {};
412
- if (
413
- !Number.isInteger(oldIndex) ||
414
- !Number.isInteger(newIndex) ||
415
- oldIndex === newIndex
416
- ) {
417
- return;
418
- }
419
- this.#flushRows();
420
- const [stop] = this.#gradient.stops.splice(oldIndex, 1);
421
- this.#gradient.stops.splice(newIndex, 0, stop);
422
- this.querySelectorAll(".fig-fill-picker-gradient-stop-row").forEach(
423
- (row, index) => {
424
- row.dataset.index = String(index);
425
- },
426
- );
427
- this.#syncPreview();
428
- this.#emit("input");
429
- });
471
+ this.#gradientBar.addEventListener("click", this.#handleBarStopSelection);
472
+ this.#reorder.addEventListener("reorder", this.#handleReorder);
473
+ this.addEventListener("pointerdown", this.#handlePointerDown, true);
474
+ this.addEventListener(
475
+ "lostpointercapture",
476
+ this.#handlePointerEnd,
477
+ true,
478
+ );
479
+ this.#mounted = true;
430
480
  }
431
481
 
432
482
  #formatStopColor(stop) {
@@ -459,127 +509,406 @@ class FigGradientEditor extends HTMLElement {
459
509
  };
460
510
  }
461
511
 
462
- #syncRow(row) {
463
- const index = Number(row.dataset.index);
512
+ #readStopIntoGradient(row) {
513
+ const index = this.#stopRows.indexOf(row);
464
514
  if (!Number.isInteger(index) || !this.#gradient.stops[index]) return;
465
515
  this.#gradient.stops[index] = {
466
516
  ...this.#gradient.stops[index],
467
517
  ...this.#readRow(row),
468
518
  };
469
- const stop = this.#gradient.stops[index];
470
- row
471
- .querySelector(".fig-fill-picker-stop-position")
472
- ?.setAttribute("value", String(stop.position));
473
- row
474
- .querySelector(".fig-fill-picker-stop-color")
475
- ?.setAttribute("value", this.#formatStopColor(stop));
476
519
  }
477
520
 
478
521
  #flushRows() {
479
- this.querySelectorAll(".fig-fill-picker-gradient-stop-row").forEach((row) =>
480
- this.#syncRow(row),
481
- );
522
+ this.#stopRows.forEach((row) => this.#readStopIntoGradient(row));
482
523
  }
483
524
 
484
- #renderStopRows(reorder) {
485
- if (!reorder) return;
486
- const disabled = this.#disabled();
487
- const rows = this.#gradient.stops.map((stop, index) => {
488
- const row = createElement(
489
- "fig-property",
525
+ #createStopRow() {
526
+ const position = createElement("fig-input-number", {
527
+ className: "fig-fill-picker-stop-position",
528
+ "aria-label": "Gradient stop position",
529
+ min: "0",
530
+ max: "100",
531
+ units: "%",
532
+ });
533
+ const color = createElement("fig-input-color", {
534
+ className: "fig-fill-picker-stop-color",
535
+ "aria-label": "Gradient stop color",
536
+ text: "true",
537
+ alpha: "true",
538
+ picker: "figma",
539
+ "picker-dialog-position": "right",
540
+ });
541
+ const row = createElement(
542
+ "fig-property",
543
+ {
544
+ className: "fig-fill-picker-gradient-stop-row",
545
+ leading: "drag",
546
+ trailing: "remove",
547
+ "show-leading": "hover",
548
+ "show-trailing": true,
549
+ },
550
+ createElement(
551
+ "fig-stack",
490
552
  {
491
- className: "fig-fill-picker-gradient-stop-row",
492
- "data-index": index,
493
- leading: "drag",
494
- trailing: "remove",
495
- "show-leading": "hover",
496
- "show-trailing": true,
553
+ className: "fig-fill-picker-gradient-stop-inputs",
554
+ slot: "input",
555
+ direction: "horizontal",
556
+ gap: "2",
557
+ full: true,
497
558
  },
498
- createElement(
499
- "fig-stack",
500
- {
501
- className: "fig-fill-picker-gradient-stop-inputs",
502
- slot: "input",
503
- direction: "horizontal",
504
- gap: "2",
505
- full: true,
506
- },
507
- [
508
- createElement("fig-input-number", {
509
- className: "fig-fill-picker-stop-position",
510
- "aria-label": "Gradient stop position",
511
- min: "0",
512
- max: "100",
513
- value: stop.position,
514
- units: "%",
515
- disabled,
516
- }),
517
- createElement("fig-input-color", {
518
- className: "fig-fill-picker-stop-color",
519
- "aria-label": "Gradient stop color",
520
- text: "true",
521
- alpha: "true",
522
- picker: "figma",
523
- "picker-dialog-position": "right",
524
- value: this.#formatStopColor(stop),
525
- disabled,
526
- }),
527
- ],
528
- ),
529
- );
530
- const position = row.querySelector(".fig-fill-picker-stop-position");
531
- position.addEventListener("input", () => {
532
- this.#syncRow(row);
533
- this.#syncPreview();
534
- this.#emit("input");
559
+ [position, color],
560
+ ),
561
+ );
562
+ position.addEventListener("input", (event) => {
563
+ event.stopPropagation();
564
+ this.#handleRowControl(row, "input");
565
+ });
566
+ position.addEventListener("change", (event) => {
567
+ event.stopPropagation();
568
+ this.#handleRowControl(row, "change");
569
+ });
570
+ for (const type of ["input", "change"]) {
571
+ color.addEventListener(type, (event) => {
572
+ event.stopPropagation();
573
+ this.#handleRowControl(row, type);
535
574
  });
536
- position.addEventListener("change", () => {
537
- this.#syncRow(row);
538
- this.#syncPreview();
539
- this.#emit("change");
575
+ }
576
+ row.addEventListener("remove", (event) => {
577
+ event.stopPropagation();
578
+ this.#removeStopRow(row);
579
+ });
580
+ row.addEventListener("pointerdown", (event) => {
581
+ if (event.button === 0) this.#selectStopRow(row);
582
+ });
583
+ row.addEventListener("click", () => this.#selectStopRow(row));
584
+ row.addEventListener("focusin", () => this.#selectStopRow(row));
585
+ return row;
586
+ }
587
+
588
+ #selectStopRow(row) {
589
+ if (!row || !this.#stopRows.includes(row)) return;
590
+ this.#selectedStopRow = row;
591
+ this.#stopRows.forEach((stopRow) => {
592
+ stopRow.toggleAttribute("selected", stopRow === row);
593
+ });
594
+ this.#syncSelectedHandle();
595
+ row.scrollIntoView({ block: "nearest" });
596
+ }
597
+
598
+ #syncSelectedHandle() {
599
+ const selectedIndex = this.#stopRows.indexOf(this.#selectedStopRow);
600
+ this.#gradientBar
601
+ ?.querySelectorAll(
602
+ ".fig-input-gradient-track fig-handle:not(.fig-input-gradient-ghost)",
603
+ )
604
+ .forEach((handle, index) => {
605
+ if (index === selectedIndex) handle.select?.();
606
+ else handle.deselect?.();
540
607
  });
541
- const color = row.querySelector(".fig-fill-picker-stop-color");
542
- for (const type of ["input", "change"]) {
543
- color.addEventListener(type, (event) => {
544
- event.stopPropagation();
545
- this.#syncRow(row);
546
- this.#syncPreview();
547
- this.#emit(type);
548
- });
608
+ }
609
+
610
+ #reconcileStopRows() {
611
+ if (!this.#reorder) return;
612
+ const desiredStops = this.#gradient.stops;
613
+ const previousRows = this.#stopRows.filter((row) => row.isConnected);
614
+ let nextRows;
615
+
616
+ if (previousRows.length === desiredStops.length) {
617
+ nextRows = [...previousRows];
618
+ } else {
619
+ nextRows = Array(desiredStops.length).fill(null);
620
+ const matches = matchUnchangedStops(this.#renderedStops, desiredStops);
621
+ const usedPrevious = new Set();
622
+ for (const [previousIndex, nextIndex] of matches) {
623
+ const row = previousRows[previousIndex];
624
+ if (!row) continue;
625
+ nextRows[nextIndex] = row;
626
+ usedPrevious.add(previousIndex);
549
627
  }
550
- row.addEventListener("remove", (event) => {
551
- event.stopPropagation();
552
- if (this.#gradient.stops.length <= 2) return;
553
- this.#gradient.stops.splice(Number(row.dataset.index), 1);
554
- this.#render();
555
- this.#emit("input");
628
+ const reusable = previousRows.filter(
629
+ (_, index) => !usedPrevious.has(index),
630
+ );
631
+ nextRows = nextRows.map((row) => row ?? reusable.shift() ?? this.#createStopRow());
632
+ const retained = new Set(nextRows);
633
+ previousRows.forEach((row) => {
634
+ if (!retained.has(row)) row.remove();
556
635
  });
557
- return row;
636
+ }
637
+
638
+ let cursor = this.#reorder.firstElementChild;
639
+ for (const row of nextRows) {
640
+ if (row === cursor) {
641
+ cursor = cursor.nextElementSibling;
642
+ } else {
643
+ this.#reorder.insertBefore(row, cursor);
644
+ }
645
+ }
646
+ this.#stopRows = nextRows;
647
+ this.#stopRows.forEach((row, index) => {
648
+ this.#syncStopRow(row, desiredStops[index], index);
558
649
  });
559
- reorder.replaceChildren(...rows);
560
- reorder.querySelectorAll(".fig-fill-picker-gradient-stop-row").forEach(
561
- (row) => {
562
- const remove = row.querySelector(
563
- ':scope > [data-fig-property-action="remove"]',
564
- );
565
- if (!remove) return;
566
- remove.classList.add("fig-fill-picker-stop-remove");
567
- remove.setAttribute("aria-label", "Remove gradient stop");
568
- remove.toggleAttribute(
569
- "disabled",
570
- disabled || this.#gradient.stops.length <= 2,
650
+ if (!this.#stopRows.includes(this.#selectedStopRow)) {
651
+ this.#selectedStopRow = this.#stopRows[0] ?? null;
652
+ }
653
+ this.#stopRows.forEach((row) => {
654
+ row.toggleAttribute("selected", row === this.#selectedStopRow);
655
+ });
656
+ this.#syncSelectedHandle();
657
+ this.#renderedStops = cloneStops(desiredStops);
658
+ this.#syncNestedPickerAnchors();
659
+ }
660
+
661
+ #syncStopRow(row, stop, index) {
662
+ const disabled = this.#disabled();
663
+ row.dataset.index = String(index);
664
+ const position = row.querySelector(".fig-fill-picker-stop-position");
665
+ const color = row.querySelector(".fig-fill-picker-stop-color");
666
+ this.#setControlValue(position, stop.position);
667
+ this.#setControlValue(color, this.#formatStopColor(stop));
668
+ position?.toggleAttribute("disabled", disabled);
669
+ color?.toggleAttribute("disabled", disabled);
670
+ const remove = row.querySelector(
671
+ ':scope > [data-fig-property-action="remove"]',
672
+ );
673
+ if (remove) {
674
+ remove.classList.add("fig-fill-picker-stop-remove");
675
+ remove.setAttribute("aria-label", "Remove gradient stop");
676
+ remove.toggleAttribute(
677
+ "disabled",
678
+ disabled || this.#gradient.stops.length <= 2,
679
+ );
680
+ }
681
+ }
682
+
683
+ #setControlValue(control, value) {
684
+ if (!control) return;
685
+ const nextValue = String(value);
686
+ if (control.getAttribute("value") === nextValue) return;
687
+ const active = control.contains(document.activeElement)
688
+ ? document.activeElement
689
+ : null;
690
+ const selection =
691
+ active &&
692
+ "selectionStart" in active &&
693
+ typeof active.selectionStart === "number"
694
+ ? {
695
+ start: active.selectionStart,
696
+ end: active.selectionEnd,
697
+ direction: active.selectionDirection,
698
+ }
699
+ : null;
700
+ control.setAttribute("value", nextValue);
701
+ if (selection && active === document.activeElement) {
702
+ try {
703
+ active.setSelectionRange(
704
+ selection.start,
705
+ selection.end,
706
+ selection.direction,
571
707
  );
708
+ } catch {
709
+ // Selection APIs are not available for every input type.
710
+ }
711
+ }
712
+ }
713
+
714
+ #sync() {
715
+ if (!this.#mounted) return;
716
+ const disabled = this.#disabled();
717
+ const fade = this.#overflowMode() === "fade";
718
+ this.#reorder.classList.toggle("fig-overflow-fade", fade);
719
+ const showRows = this.#showRows();
720
+ if (showRows === null) {
721
+ this.#reorder.style.removeProperty(
722
+ "--fig-gradient-editor-overflow-show-rows",
723
+ );
724
+ } else {
725
+ this.#reorder.style.setProperty(
726
+ "--fig-gradient-editor-overflow-show-rows",
727
+ String(showRows),
728
+ );
729
+ }
730
+ this.#setControlValue(this.#gradientType, this.#gradient.type);
731
+ this.#gradientType.toggleAttribute("disabled", disabled);
732
+ this.#flipButton.toggleAttribute("disabled", disabled);
733
+ this.#rotateButton.toggleAttribute("disabled", disabled);
734
+ this.#addButton.toggleAttribute("disabled", disabled);
735
+ this.#reorder.toggleAttribute("disabled", disabled);
736
+ this.#gradientBar.toggleAttribute("disabled", disabled);
737
+ const barValue = normalizeGradientEditorValue(this.#gradientBar.value);
738
+ if (!gradientsSemanticallyEqual(barValue, this.#gradient)) {
739
+ this.#syncingBar = true;
740
+ this.#gradientBar.value = {
741
+ type: "gradient",
742
+ gradient: this.#gradient,
743
+ };
744
+ this.#syncingBar = false;
745
+ }
746
+ this.#reconcileStopRows();
747
+ }
748
+
749
+ #applyInternalGradient(value, eventType, { focusIndex = null } = {}) {
750
+ const next = normalizeGradientEditorValue(value);
751
+ const changed = !gradientsSemanticallyEqual(this.#gradient, next);
752
+ if (changed) {
753
+ this.#gradient = next;
754
+ this.#sync();
755
+ }
756
+ if (eventType === "change" || changed) this.#emit(eventType);
757
+ if (focusIndex !== null) this.#focusStopValue(focusIndex);
758
+ }
759
+
760
+ #focusStopValue(index) {
761
+ requestAnimationFrame(() => {
762
+ if (!this.isConnected) return;
763
+ const row = this.querySelector(
764
+ `.fig-fill-picker-gradient-stop-row[data-index="${index}"]`,
765
+ );
766
+ const input = row?.querySelector(
767
+ ".fig-fill-picker-stop-color fig-input-text input",
768
+ );
769
+ this.#selectStopRow(row);
770
+ input?.focus();
771
+ input?.select();
772
+ });
773
+ }
774
+
775
+ #handleTypeChange = (event) => {
776
+ event.stopPropagation();
777
+ const value =
778
+ typeof event.detail === "string" ? event.detail : event.target?.value;
779
+ if (!["linear", "radial", "angular"].includes(value)) return;
780
+ this.#applyInternalGradient({ ...this.#gradient, type: value }, "input");
781
+ };
782
+
783
+ #handleRotate = () => {
784
+ this.#applyInternalGradient(
785
+ {
786
+ ...this.#gradient,
787
+ angle: (Number(this.#gradient.angle) + 90) % 360,
572
788
  },
789
+ "input",
573
790
  );
574
- this.#syncNestedPickerAnchors();
791
+ };
792
+
793
+ #handleFlip = () => {
794
+ const stops = this.#gradient.stops
795
+ .map((stop) => ({ ...stop, position: 100 - stop.position }))
796
+ .sort((a, b) => a.position - b.position);
797
+ this.#applyInternalGradient({ ...this.#gradient, stops }, "input");
798
+ };
799
+
800
+ #handleAdd = () => {
801
+ const stops = cloneStops(this.#gradient.stops);
802
+ const selectedIndex = Math.max(
803
+ 0,
804
+ this.#stopRows.indexOf(this.#selectedStopRow),
805
+ );
806
+ const selectedPosition = Number(stops[selectedIndex]?.position) || 0;
807
+ const nextPosition = Number(
808
+ stops[selectedIndex + 1]?.position ?? selectedPosition,
809
+ );
810
+ const addedStop = {
811
+ position: (selectedPosition + nextPosition) / 2,
812
+ color: "#888888",
813
+ opacity: 100,
814
+ };
815
+ const addedIndex = selectedIndex + 1;
816
+ stops.splice(addedIndex, 0, addedStop);
817
+ this.#applyInternalGradient(
818
+ { ...this.#gradient, stops },
819
+ "input",
820
+ { focusIndex: addedIndex },
821
+ );
822
+ };
823
+
824
+ #handleBarEvent = (event) => {
825
+ event.stopPropagation();
826
+ if (this.#syncingBar || !event.detail?.gradient) return;
827
+ this.#applyInternalGradient(
828
+ { ...this.#gradient, ...event.detail.gradient },
829
+ event.type,
830
+ );
831
+ };
832
+
833
+ #handleBarStopSelection = (event) => {
834
+ const handle = event.target?.closest?.(
835
+ "fig-handle:not(.fig-input-gradient-ghost)",
836
+ );
837
+ const index = Number(handle?.dataset.stopIndex);
838
+ if (!Number.isInteger(index)) return;
839
+ this.#selectStopRow(this.#stopRows[index]);
840
+ };
841
+
842
+ #handleReorder = (event) => {
843
+ event.stopPropagation();
844
+ const { oldIndex, newIndex } = event.detail ?? {};
845
+ if (
846
+ !Number.isInteger(oldIndex) ||
847
+ !Number.isInteger(newIndex) ||
848
+ oldIndex === newIndex
849
+ ) {
850
+ return;
851
+ }
852
+ this.#flushRows();
853
+ const stops = cloneStops(this.#gradient.stops);
854
+ const [stop] = stops.splice(oldIndex, 1);
855
+ stops.splice(newIndex, 0, stop);
856
+ const [row] = this.#stopRows.splice(oldIndex, 1);
857
+ this.#stopRows.splice(newIndex, 0, row);
858
+ this.#applyInternalGradient({ ...this.#gradient, stops }, "input");
859
+ };
860
+
861
+ #handleRowControl(row, eventType) {
862
+ const index = this.#stopRows.indexOf(row);
863
+ if (index < 0) return;
864
+ const stops = cloneStops(this.#gradient.stops);
865
+ stops[index] = { ...stops[index], ...this.#readRow(row) };
866
+ this.#applyInternalGradient({ ...this.#gradient, stops }, eventType);
575
867
  }
576
868
 
577
- #syncPreview() {
578
- const bar = this.querySelector(".fig-fill-picker-gradient-bar-input");
579
- if (!bar) return;
580
- this.#syncingBar = true;
581
- bar.value = { type: "gradient", gradient: this.#gradient };
582
- this.#syncingBar = false;
869
+ #removeStopRow(row) {
870
+ if (this.#gradient.stops.length <= 2) return;
871
+ const index = this.#stopRows.indexOf(row);
872
+ if (index < 0) return;
873
+ if (row.contains(document.activeElement)) this.#addButton.focus();
874
+ if (row === this.#selectedStopRow) {
875
+ this.#selectedStopRow =
876
+ this.#stopRows[index + 1] ?? this.#stopRows[index - 1] ?? null;
877
+ }
878
+ const stops = cloneStops(this.#gradient.stops);
879
+ stops.splice(index, 1);
880
+ this.#applyInternalGradient({ ...this.#gradient, stops }, "input");
881
+ }
882
+
883
+ #handlePointerDown = (event) => {
884
+ if (event.button !== 0 || this.#pointerInteractionActive) return;
885
+ this.#pointerInteractionActive = true;
886
+ window.addEventListener("pointerup", this.#handlePointerEnd, true);
887
+ window.addEventListener("pointercancel", this.#handlePointerEnd, true);
888
+ window.addEventListener("blur", this.#handlePointerEnd, true);
889
+ };
890
+
891
+ #handlePointerEnd = () => {
892
+ window.removeEventListener("pointerup", this.#handlePointerEnd, true);
893
+ window.removeEventListener("pointercancel", this.#handlePointerEnd, true);
894
+ window.removeEventListener("blur", this.#handlePointerEnd, true);
895
+ queueMicrotask(() =>
896
+ this.#endPointerInteraction({ reconcile: true }),
897
+ );
898
+ };
899
+
900
+ #endPointerInteraction({ reconcile }) {
901
+ window.removeEventListener("pointerup", this.#handlePointerEnd, true);
902
+ window.removeEventListener("pointercancel", this.#handlePointerEnd, true);
903
+ window.removeEventListener("blur", this.#handlePointerEnd, true);
904
+ const wasActive = this.#pointerInteractionActive;
905
+ this.#pointerInteractionActive = false;
906
+ if (!reconcile || !wasActive || !this.#deferredExternalGradient) return;
907
+ const deferred = this.#deferredExternalGradient;
908
+ this.#deferredExternalGradient = null;
909
+ this.#gradient = deferred;
910
+ this.#reflectValue();
911
+ this.#sync();
583
912
  }
584
913
 
585
914
  #syncNestedPickerAnchors() {