@rogieking/figui3 11.6.1 → 11.6.3

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,48 @@ 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.#gradientBar.addEventListener(
473
+ "keydown",
474
+ this.#handleDeleteSelectedStop,
475
+ );
476
+ this.#reorder.addEventListener("reorder", this.#handleReorder);
477
+ this.#reorder.addEventListener("keydown", this.#handleDeleteSelectedStop);
478
+ this.addEventListener("pointerdown", this.#handlePointerDown, true);
479
+ this.addEventListener(
480
+ "lostpointercapture",
481
+ this.#handlePointerEnd,
482
+ true,
483
+ );
484
+ this.#mounted = true;
430
485
  }
431
486
 
432
487
  #formatStopColor(stop) {
@@ -459,127 +514,457 @@ class FigGradientEditor extends HTMLElement {
459
514
  };
460
515
  }
461
516
 
462
- #syncRow(row) {
463
- const index = Number(row.dataset.index);
517
+ #readStopIntoGradient(row) {
518
+ const index = this.#stopRows.indexOf(row);
464
519
  if (!Number.isInteger(index) || !this.#gradient.stops[index]) return;
465
520
  this.#gradient.stops[index] = {
466
521
  ...this.#gradient.stops[index],
467
522
  ...this.#readRow(row),
468
523
  };
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
524
  }
477
525
 
478
526
  #flushRows() {
479
- this.querySelectorAll(".fig-fill-picker-gradient-stop-row").forEach((row) =>
480
- this.#syncRow(row),
481
- );
527
+ this.#stopRows.forEach((row) => this.#readStopIntoGradient(row));
482
528
  }
483
529
 
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",
530
+ #createStopRow() {
531
+ const position = createElement("fig-input-number", {
532
+ className: "fig-fill-picker-stop-position",
533
+ "aria-label": "Gradient stop position",
534
+ min: "0",
535
+ max: "100",
536
+ units: "%",
537
+ });
538
+ const color = createElement("fig-input-color", {
539
+ className: "fig-fill-picker-stop-color",
540
+ "aria-label": "Gradient stop color",
541
+ text: "true",
542
+ alpha: "true",
543
+ picker: "figma",
544
+ "picker-dialog-position": "right",
545
+ });
546
+ const row = createElement(
547
+ "fig-property",
548
+ {
549
+ className: "fig-fill-picker-gradient-stop-row",
550
+ leading: "drag",
551
+ trailing: "remove",
552
+ "show-leading": "hover",
553
+ "show-trailing": true,
554
+ },
555
+ createElement(
556
+ "fig-stack",
490
557
  {
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,
558
+ className: "fig-fill-picker-gradient-stop-inputs",
559
+ slot: "input",
560
+ direction: "horizontal",
561
+ gap: "2",
562
+ full: true,
497
563
  },
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");
564
+ [position, color],
565
+ ),
566
+ );
567
+ position.addEventListener("input", (event) => {
568
+ event.stopPropagation();
569
+ this.#handleRowControl(row, "input");
570
+ });
571
+ position.addEventListener("change", (event) => {
572
+ event.stopPropagation();
573
+ this.#handleRowControl(row, "change");
574
+ });
575
+ for (const type of ["input", "change"]) {
576
+ color.addEventListener(type, (event) => {
577
+ event.stopPropagation();
578
+ this.#handleRowControl(row, type);
535
579
  });
536
- position.addEventListener("change", () => {
537
- this.#syncRow(row);
538
- this.#syncPreview();
539
- this.#emit("change");
580
+ }
581
+ row.addEventListener("remove", (event) => {
582
+ event.stopPropagation();
583
+ this.#removeStopRow(row);
584
+ });
585
+ row.addEventListener("pointerdown", (event) => {
586
+ if (event.button === 0) this.#selectStopRow(row);
587
+ });
588
+ row.addEventListener("click", (event) => {
589
+ this.#selectStopRow(row);
590
+ const fromEditableControl = event.composedPath().some(
591
+ (target) =>
592
+ target instanceof Element &&
593
+ target.matches(
594
+ 'input, textarea, select, fig-input-number, fig-input-color, fig-input-text, [contenteditable]:not([contenteditable="false"])',
595
+ ),
596
+ );
597
+ if (!fromEditableControl && row.isConnected) {
598
+ row.focus({ preventScroll: true });
599
+ }
600
+ });
601
+ row.addEventListener("focusin", () => this.#selectStopRow(row));
602
+ return row;
603
+ }
604
+
605
+ #selectStopRow(row) {
606
+ if (!row || !this.#stopRows.includes(row)) return;
607
+ this.#selectedStopRow = row;
608
+ this.#stopRows.forEach((stopRow) => {
609
+ stopRow.toggleAttribute("selected", stopRow === row);
610
+ });
611
+ this.#syncSelectedHandle();
612
+ row.scrollIntoView({ block: "nearest" });
613
+ }
614
+
615
+ #syncSelectedHandle() {
616
+ const selectedIndex = this.#stopRows.indexOf(this.#selectedStopRow);
617
+ this.#gradientBar
618
+ ?.querySelectorAll(
619
+ ".fig-input-gradient-track fig-handle:not(.fig-input-gradient-ghost)",
620
+ )
621
+ .forEach((handle, index) => {
622
+ if (index === selectedIndex) handle.select?.();
623
+ else handle.deselect?.();
540
624
  });
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
- });
625
+ }
626
+
627
+ #reconcileStopRows() {
628
+ if (!this.#reorder) return;
629
+ const desiredStops = this.#gradient.stops;
630
+ const previousRows = this.#stopRows.filter((row) => row.isConnected);
631
+ let nextRows;
632
+
633
+ if (previousRows.length === desiredStops.length) {
634
+ nextRows = [...previousRows];
635
+ } else {
636
+ nextRows = Array(desiredStops.length).fill(null);
637
+ const matches = matchUnchangedStops(this.#renderedStops, desiredStops);
638
+ const usedPrevious = new Set();
639
+ for (const [previousIndex, nextIndex] of matches) {
640
+ const row = previousRows[previousIndex];
641
+ if (!row) continue;
642
+ nextRows[nextIndex] = row;
643
+ usedPrevious.add(previousIndex);
549
644
  }
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");
645
+ const reusable = previousRows.filter(
646
+ (_, index) => !usedPrevious.has(index),
647
+ );
648
+ nextRows = nextRows.map((row) => row ?? reusable.shift() ?? this.#createStopRow());
649
+ const retained = new Set(nextRows);
650
+ previousRows.forEach((row) => {
651
+ if (!retained.has(row)) row.remove();
556
652
  });
557
- return row;
653
+ }
654
+
655
+ let cursor = this.#reorder.firstElementChild;
656
+ for (const row of nextRows) {
657
+ if (row === cursor) {
658
+ cursor = cursor.nextElementSibling;
659
+ } else {
660
+ this.#reorder.insertBefore(row, cursor);
661
+ }
662
+ }
663
+ this.#stopRows = nextRows;
664
+ this.#stopRows.forEach((row, index) => {
665
+ this.#syncStopRow(row, desiredStops[index], index);
558
666
  });
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,
667
+ if (!this.#stopRows.includes(this.#selectedStopRow)) {
668
+ this.#selectedStopRow = this.#stopRows[0] ?? null;
669
+ }
670
+ this.#stopRows.forEach((row) => {
671
+ row.toggleAttribute("selected", row === this.#selectedStopRow);
672
+ });
673
+ this.#syncSelectedHandle();
674
+ this.#renderedStops = cloneStops(desiredStops);
675
+ this.#syncNestedPickerAnchors();
676
+ }
677
+
678
+ #syncStopRow(row, stop, index) {
679
+ const disabled = this.#disabled();
680
+ row.dataset.index = String(index);
681
+ const position = row.querySelector(".fig-fill-picker-stop-position");
682
+ const color = row.querySelector(".fig-fill-picker-stop-color");
683
+ this.#setControlValue(position, stop.position);
684
+ this.#setControlValue(color, this.#formatStopColor(stop));
685
+ position?.toggleAttribute("disabled", disabled);
686
+ color?.toggleAttribute("disabled", disabled);
687
+ const remove = row.querySelector(
688
+ ':scope > [data-fig-property-action="remove"]',
689
+ );
690
+ if (remove) {
691
+ remove.classList.add("fig-fill-picker-stop-remove");
692
+ remove.setAttribute("aria-label", "Remove gradient stop");
693
+ remove.toggleAttribute(
694
+ "disabled",
695
+ disabled || this.#gradient.stops.length <= 2,
696
+ );
697
+ }
698
+ }
699
+
700
+ #setControlValue(control, value) {
701
+ if (!control) return;
702
+ const nextValue = String(value);
703
+ if (control.getAttribute("value") === nextValue) return;
704
+ const active = control.contains(document.activeElement)
705
+ ? document.activeElement
706
+ : null;
707
+ const selection =
708
+ active &&
709
+ "selectionStart" in active &&
710
+ typeof active.selectionStart === "number"
711
+ ? {
712
+ start: active.selectionStart,
713
+ end: active.selectionEnd,
714
+ direction: active.selectionDirection,
715
+ }
716
+ : null;
717
+ control.setAttribute("value", nextValue);
718
+ if (selection && active === document.activeElement) {
719
+ try {
720
+ active.setSelectionRange(
721
+ selection.start,
722
+ selection.end,
723
+ selection.direction,
571
724
  );
725
+ } catch {
726
+ // Selection APIs are not available for every input type.
727
+ }
728
+ }
729
+ }
730
+
731
+ #sync() {
732
+ if (!this.#mounted) return;
733
+ const disabled = this.#disabled();
734
+ const fade = this.#overflowMode() === "fade";
735
+ this.#reorder.classList.toggle("fig-overflow-fade", fade);
736
+ const showRows = this.#showRows();
737
+ if (showRows === null) {
738
+ this.#reorder.style.removeProperty(
739
+ "--fig-gradient-editor-overflow-show-rows",
740
+ );
741
+ } else {
742
+ this.#reorder.style.setProperty(
743
+ "--fig-gradient-editor-overflow-show-rows",
744
+ String(showRows),
745
+ );
746
+ }
747
+ this.#setControlValue(this.#gradientType, this.#gradient.type);
748
+ this.#gradientType.toggleAttribute("disabled", disabled);
749
+ this.#flipButton.toggleAttribute("disabled", disabled);
750
+ this.#rotateButton.toggleAttribute("disabled", disabled);
751
+ this.#addButton.toggleAttribute("disabled", disabled);
752
+ this.#reorder.toggleAttribute("disabled", disabled);
753
+ this.#gradientBar.toggleAttribute("disabled", disabled);
754
+ const barValue = normalizeGradientEditorValue(this.#gradientBar.value);
755
+ if (!gradientsSemanticallyEqual(barValue, this.#gradient)) {
756
+ this.#syncingBar = true;
757
+ this.#gradientBar.value = {
758
+ type: "gradient",
759
+ gradient: this.#gradient,
760
+ };
761
+ this.#syncingBar = false;
762
+ }
763
+ this.#reconcileStopRows();
764
+ }
765
+
766
+ #applyInternalGradient(value, eventType, { focusIndex = null } = {}) {
767
+ const next = normalizeGradientEditorValue(value);
768
+ const changed = !gradientsSemanticallyEqual(this.#gradient, next);
769
+ if (changed) {
770
+ this.#gradient = next;
771
+ this.#sync();
772
+ }
773
+ if (eventType === "change" || changed) this.#emit(eventType);
774
+ if (focusIndex !== null) this.#focusStopValue(focusIndex);
775
+ }
776
+
777
+ #focusStopValue(index) {
778
+ requestAnimationFrame(() => {
779
+ if (!this.isConnected) return;
780
+ const row = this.querySelector(
781
+ `.fig-fill-picker-gradient-stop-row[data-index="${index}"]`,
782
+ );
783
+ const input = row?.querySelector(
784
+ ".fig-fill-picker-stop-color fig-input-text input",
785
+ );
786
+ this.#selectStopRow(row);
787
+ input?.focus();
788
+ input?.select();
789
+ });
790
+ }
791
+
792
+ #handleTypeChange = (event) => {
793
+ event.stopPropagation();
794
+ const value =
795
+ typeof event.detail === "string" ? event.detail : event.target?.value;
796
+ if (!["linear", "radial", "angular"].includes(value)) return;
797
+ this.#applyInternalGradient({ ...this.#gradient, type: value }, "input");
798
+ };
799
+
800
+ #handleRotate = () => {
801
+ this.#applyInternalGradient(
802
+ {
803
+ ...this.#gradient,
804
+ angle: (Number(this.#gradient.angle) + 90) % 360,
572
805
  },
806
+ "input",
573
807
  );
574
- this.#syncNestedPickerAnchors();
808
+ };
809
+
810
+ #handleFlip = () => {
811
+ const stops = this.#gradient.stops
812
+ .map((stop) => ({ ...stop, position: 100 - stop.position }))
813
+ .sort((a, b) => a.position - b.position);
814
+ this.#applyInternalGradient({ ...this.#gradient, stops }, "input");
815
+ };
816
+
817
+ #handleAdd = () => {
818
+ const stops = cloneStops(this.#gradient.stops);
819
+ const selectedIndex = Math.max(
820
+ 0,
821
+ this.#stopRows.indexOf(this.#selectedStopRow),
822
+ );
823
+ const selectedPosition = Number(stops[selectedIndex]?.position) || 0;
824
+ const nextPosition = Number(
825
+ stops[selectedIndex + 1]?.position ?? selectedPosition,
826
+ );
827
+ const addedStop = {
828
+ position: (selectedPosition + nextPosition) / 2,
829
+ color: "#888888",
830
+ opacity: 100,
831
+ };
832
+ const addedIndex = selectedIndex + 1;
833
+ stops.splice(addedIndex, 0, addedStop);
834
+ this.#applyInternalGradient(
835
+ { ...this.#gradient, stops },
836
+ "input",
837
+ { focusIndex: addedIndex },
838
+ );
839
+ };
840
+
841
+ #handleBarEvent = (event) => {
842
+ event.stopPropagation();
843
+ if (this.#syncingBar || !event.detail?.gradient) return;
844
+ this.#applyInternalGradient(
845
+ { ...this.#gradient, ...event.detail.gradient },
846
+ event.type,
847
+ );
848
+ };
849
+
850
+ #handleBarStopSelection = (event) => {
851
+ const handle = event.target?.closest?.(
852
+ "fig-handle:not(.fig-input-gradient-ghost)",
853
+ );
854
+ const index = Number(handle?.dataset.stopIndex);
855
+ if (!Number.isInteger(index)) return;
856
+ this.#selectStopRow(this.#stopRows[index]);
857
+ };
858
+
859
+ #handleDeleteSelectedStop = (event) => {
860
+ if (
861
+ event.defaultPrevented ||
862
+ !["Delete", "Backspace"].includes(event.key) ||
863
+ this.#disabled() ||
864
+ this.#gradient.stops.length <= 2
865
+ ) {
866
+ return;
867
+ }
868
+ const handle = event.target?.closest?.(
869
+ "fig-handle[selected]:not(.fig-input-gradient-ghost)",
870
+ );
871
+ const row = event.target?.closest?.(
872
+ ".fig-fill-picker-gradient-stop-row[selected]",
873
+ );
874
+ const fromSelectedHandle =
875
+ handle && this.#gradientBar.contains(handle);
876
+ const fromEditableControl = event.composedPath().some(
877
+ (target) =>
878
+ target instanceof Element &&
879
+ target.matches(
880
+ 'input, textarea, select, fig-input-number, fig-input-color, fig-input-text, [contenteditable]:not([contenteditable="false"])',
881
+ ),
882
+ );
883
+ const fromSelectedRow =
884
+ row === this.#selectedStopRow && !fromEditableControl;
885
+ if (!fromSelectedHandle && !fromSelectedRow) return;
886
+ event.preventDefault();
887
+ this.#removeStopRow(this.#selectedStopRow);
888
+ requestAnimationFrame(() => {
889
+ const selectedIndex = this.#stopRows.indexOf(this.#selectedStopRow);
890
+ this.#gradientBar
891
+ ?.querySelectorAll(
892
+ ".fig-input-gradient-track fig-handle:not(.fig-input-gradient-ghost)",
893
+ )
894
+ [selectedIndex]?.focus();
895
+ });
896
+ };
897
+
898
+ #handleReorder = (event) => {
899
+ event.stopPropagation();
900
+ const { oldIndex, newIndex } = event.detail ?? {};
901
+ if (
902
+ !Number.isInteger(oldIndex) ||
903
+ !Number.isInteger(newIndex) ||
904
+ oldIndex === newIndex
905
+ ) {
906
+ return;
907
+ }
908
+ this.#flushRows();
909
+ const stops = cloneStops(this.#gradient.stops);
910
+ const [stop] = stops.splice(oldIndex, 1);
911
+ stops.splice(newIndex, 0, stop);
912
+ const [row] = this.#stopRows.splice(oldIndex, 1);
913
+ this.#stopRows.splice(newIndex, 0, row);
914
+ this.#applyInternalGradient({ ...this.#gradient, stops }, "input");
915
+ };
916
+
917
+ #handleRowControl(row, eventType) {
918
+ const index = this.#stopRows.indexOf(row);
919
+ if (index < 0) return;
920
+ const stops = cloneStops(this.#gradient.stops);
921
+ stops[index] = { ...stops[index], ...this.#readRow(row) };
922
+ this.#applyInternalGradient({ ...this.#gradient, stops }, eventType);
923
+ }
924
+
925
+ #removeStopRow(row) {
926
+ if (this.#gradient.stops.length <= 2) return;
927
+ const index = this.#stopRows.indexOf(row);
928
+ if (index < 0) return;
929
+ if (row.contains(document.activeElement)) this.#addButton.focus();
930
+ if (row === this.#selectedStopRow) {
931
+ this.#selectedStopRow =
932
+ this.#stopRows[index + 1] ?? this.#stopRows[index - 1] ?? null;
933
+ }
934
+ const stops = cloneStops(this.#gradient.stops);
935
+ stops.splice(index, 1);
936
+ this.#applyInternalGradient({ ...this.#gradient, stops }, "input");
575
937
  }
576
938
 
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;
939
+ #handlePointerDown = (event) => {
940
+ if (event.button !== 0 || this.#pointerInteractionActive) return;
941
+ this.#pointerInteractionActive = true;
942
+ window.addEventListener("pointerup", this.#handlePointerEnd, true);
943
+ window.addEventListener("pointercancel", this.#handlePointerEnd, true);
944
+ window.addEventListener("blur", this.#handlePointerEnd, true);
945
+ };
946
+
947
+ #handlePointerEnd = () => {
948
+ window.removeEventListener("pointerup", this.#handlePointerEnd, true);
949
+ window.removeEventListener("pointercancel", this.#handlePointerEnd, true);
950
+ window.removeEventListener("blur", this.#handlePointerEnd, true);
951
+ queueMicrotask(() =>
952
+ this.#endPointerInteraction({ reconcile: true }),
953
+ );
954
+ };
955
+
956
+ #endPointerInteraction({ reconcile }) {
957
+ window.removeEventListener("pointerup", this.#handlePointerEnd, true);
958
+ window.removeEventListener("pointercancel", this.#handlePointerEnd, true);
959
+ window.removeEventListener("blur", this.#handlePointerEnd, true);
960
+ const wasActive = this.#pointerInteractionActive;
961
+ this.#pointerInteractionActive = false;
962
+ if (!reconcile || !wasActive || !this.#deferredExternalGradient) return;
963
+ const deferred = this.#deferredExternalGradient;
964
+ this.#deferredExternalGradient = null;
965
+ this.#gradient = deferred;
966
+ this.#reflectValue();
967
+ this.#sync();
583
968
  }
584
969
 
585
970
  #syncNestedPickerAnchors() {