@rogieking/figui3 11.5.0 → 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.
@@ -0,0 +1,598 @@
1
+ /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
+ import "./fig.js";
3
+ import "./fig-property.js";
4
+
5
+ const DEFAULT_GRADIENT = {
6
+ type: "linear",
7
+ angle: 180,
8
+ centerX: 50,
9
+ centerY: 50,
10
+ interpolationSpace: "srgb",
11
+ stops: [
12
+ { position: 0, color: "#D9D9D9", opacity: 100 },
13
+ { position: 100, color: "#737373", opacity: 100 },
14
+ ],
15
+ };
16
+
17
+ function createElement(tagName, attributes = {}, children = []) {
18
+ const element = document.createElement(tagName);
19
+ for (const [name, value] of Object.entries(attributes)) {
20
+ if (value === null || value === undefined || value === false) continue;
21
+ if (name === "className") element.className = String(value);
22
+ else if (value === true) element.setAttribute(name, "");
23
+ else element.setAttribute(name, String(value));
24
+ }
25
+ const list = Array.isArray(children) ? children : [children];
26
+ for (const child of list) {
27
+ if (child === null || child === undefined || child === false) continue;
28
+ element.append(child instanceof Node ? child : String(child));
29
+ }
30
+ return element;
31
+ }
32
+
33
+ function createIcon(name) {
34
+ return createElement("fig-icon", { name });
35
+ }
36
+
37
+ function normalizeStop(stop, index) {
38
+ return {
39
+ position: Number.isFinite(Number(stop?.position))
40
+ ? Math.max(0, Math.min(100, Number(stop.position)))
41
+ : index * 100,
42
+ color: normalizeHex(stop?.color),
43
+ opacity: Number.isFinite(Number(stop?.opacity))
44
+ ? Math.max(0, Math.min(100, Number(stop.opacity)))
45
+ : 100,
46
+ };
47
+ }
48
+
49
+ function normalizeHex(color) {
50
+ const hex = String(color || "#888888")
51
+ .replace(/^#/, "")
52
+ .slice(0, 6);
53
+ return /^[0-9A-Fa-f]{6}$/.test(hex)
54
+ ? `#${hex.toUpperCase()}`
55
+ : "#888888";
56
+ }
57
+
58
+ export function normalizeGradientEditorValue(value) {
59
+ let parsed = value;
60
+ if (typeof parsed === "string") {
61
+ try {
62
+ parsed = JSON.parse(parsed);
63
+ } catch {
64
+ parsed = {};
65
+ }
66
+ }
67
+ if (parsed?.type === "gradient" && parsed.gradient) {
68
+ parsed = parsed.gradient;
69
+ }
70
+ const source = parsed && typeof parsed === "object" ? parsed : {};
71
+ const stops = Array.isArray(source.stops)
72
+ ? source.stops.map(normalizeStop)
73
+ : DEFAULT_GRADIENT.stops.map((stop) => ({ ...stop }));
74
+ return {
75
+ ...DEFAULT_GRADIENT,
76
+ ...source,
77
+ type: ["linear", "radial", "angular"].includes(source.type)
78
+ ? source.type
79
+ : "linear",
80
+ angle: Number.isFinite(Number(source.angle))
81
+ ? Number(source.angle)
82
+ : DEFAULT_GRADIENT.angle,
83
+ centerX: Number.isFinite(Number(source.centerX))
84
+ ? Number(source.centerX)
85
+ : DEFAULT_GRADIENT.centerX,
86
+ centerY: Number.isFinite(Number(source.centerY))
87
+ ? Number(source.centerY)
88
+ : DEFAULT_GRADIENT.centerY,
89
+ interpolationSpace: "srgb",
90
+ stops:
91
+ stops.length >= 2
92
+ ? stops
93
+ : DEFAULT_GRADIENT.stops.map((stop) => ({ ...stop })),
94
+ };
95
+ }
96
+
97
+ 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);
101
+ if (colorSpace === "display-p3") {
102
+ return `color(display-p3 ${+(r / 255).toFixed(4)} ${+(g / 255).toFixed(4)} ${+(b / 255).toFixed(4)} / ${alpha})`;
103
+ }
104
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
105
+ }
106
+
107
+ export function figGradientCSS(value, { colorSpace = "srgb" } = {}) {
108
+ const gradient = normalizeGradientEditorValue(value);
109
+ const stops = gradient.stops
110
+ .map(
111
+ (stop) =>
112
+ `${colorValue(
113
+ stop.color,
114
+ (stop.opacity ?? 100) / 100,
115
+ colorSpace,
116
+ )} ${stop.position}%`,
117
+ )
118
+ .join(", ");
119
+ switch (gradient.type) {
120
+ case "radial":
121
+ return `radial-gradient(circle at ${gradient.centerX}% ${gradient.centerY}%, ${stops})`;
122
+ case "angular":
123
+ return `conic-gradient(from ${gradient.angle}deg, ${stops})`;
124
+ default:
125
+ return `linear-gradient(${gradient.angle}deg, ${stops})`;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Reusable full gradient editor used by fig-fill-picker.
131
+ *
132
+ * @element fig-gradient-editor
133
+ * @attr {string} value - JSON gradient config or full gradient fill value
134
+ * @attr {string} color-space - `srgb` or `display-p3`
135
+ * @attr {boolean} disabled - Disables all editing
136
+ * @fires input - During edits with `{ type, gradient, css }`
137
+ * @fires change - On committed edits with `{ type, gradient, css }`
138
+ */
139
+ class FigGradientEditor extends HTMLElement {
140
+ #gradient = normalizeGradientEditorValue();
141
+ #syncingValue = false;
142
+ #syncingBar = false;
143
+ #pickerAnchor = null;
144
+
145
+ static get observedAttributes() {
146
+ return ["value", "color-space", "disabled"];
147
+ }
148
+
149
+ get value() {
150
+ return structuredClone(this.#gradient);
151
+ }
152
+
153
+ set value(value) {
154
+ this.setAttribute(
155
+ "value",
156
+ typeof value === "string" ? value : JSON.stringify(value ?? {}),
157
+ );
158
+ }
159
+
160
+ get cssValue() {
161
+ return figGradientCSS(this.#gradient, {
162
+ colorSpace: this.getAttribute("color-space") || "srgb",
163
+ });
164
+ }
165
+
166
+ get pickerAnchor() {
167
+ return this.#pickerAnchor;
168
+ }
169
+
170
+ set pickerAnchor(value) {
171
+ this.#pickerAnchor = value instanceof Element ? value : null;
172
+ this.#syncNestedPickerAnchors();
173
+ }
174
+
175
+ connectedCallback() {
176
+ this.#gradient = normalizeGradientEditorValue(this.getAttribute("value"));
177
+ this.#render();
178
+ }
179
+
180
+ attributeChangedCallback(name, oldValue, newValue) {
181
+ if (oldValue === newValue || this.#syncingValue) return;
182
+ if (name === "value") {
183
+ this.#gradient = normalizeGradientEditorValue(newValue);
184
+ }
185
+ if (this.isConnected) this.#render();
186
+ }
187
+
188
+ refreshLayout() {
189
+ this.querySelector(".fig-fill-picker-gradient-bar-input")?.refreshLayout?.();
190
+ }
191
+
192
+ #disabled() {
193
+ return (
194
+ this.hasAttribute("disabled") &&
195
+ this.getAttribute("disabled") !== "false"
196
+ );
197
+ }
198
+
199
+ #reflectValue() {
200
+ this.#syncingValue = true;
201
+ this.setAttribute("value", JSON.stringify(this.#gradient));
202
+ this.#syncingValue = false;
203
+ }
204
+
205
+ #detail() {
206
+ return {
207
+ type: "gradient",
208
+ gradient: this.value,
209
+ css: this.cssValue,
210
+ };
211
+ }
212
+
213
+ #emit(type) {
214
+ this.#reflectValue();
215
+ this.dispatchEvent(
216
+ new CustomEvent(type, {
217
+ bubbles: true,
218
+ composed: true,
219
+ detail: this.#detail(),
220
+ }),
221
+ );
222
+ }
223
+
224
+ #render() {
225
+ const disabled = this.#disabled();
226
+ const gradientType = createElement(
227
+ "fig-select",
228
+ {
229
+ className: "fig-fill-picker-gradient-type",
230
+ label: "Gradient type",
231
+ value: this.#gradient.type,
232
+ variant: "ghost",
233
+ disabled,
234
+ },
235
+ createElement(
236
+ "fig-select-options",
237
+ {},
238
+ [
239
+ ["linear", "Linear"],
240
+ ["radial", "Radial"],
241
+ ["angular", "Angular"],
242
+ ].map(([value, label]) =>
243
+ createElement("fig-select-option", { value }, label),
244
+ ),
245
+ ),
246
+ );
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
+ ),
262
+ );
263
+ const header = createElement(
264
+ "fig-header",
265
+ {
266
+ className: "fig-fill-picker-gradient-header",
267
+ borderless: true,
268
+ compact: true,
269
+ },
270
+ [
271
+ gradientType,
272
+ createElement(
273
+ "fig-stack",
274
+ {
275
+ className: "fig-fill-picker-gradient-actions",
276
+ gap: "1",
277
+ },
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
+ ],
286
+ ),
287
+ ],
288
+ );
289
+ const gradientBar = createElement("fig-input-gradient", {
290
+ className: "fig-fill-picker-gradient-bar-input",
291
+ "aria-label": "Gradient stops",
292
+ edit: "true",
293
+ mode: "tip",
294
+ size: "large",
295
+ disabled,
296
+ value: JSON.stringify({
297
+ type: "gradient",
298
+ gradient: this.#gradient,
299
+ }),
300
+ });
301
+ const preview = createElement(
302
+ "fig-preview",
303
+ { className: "fig-fill-picker-gradient-preview" },
304
+ gradientBar,
305
+ );
306
+ const addButton = createElement(
307
+ "fig-button",
308
+ {
309
+ icon: true,
310
+ variant: "ghost",
311
+ className: "fig-fill-picker-gradient-add",
312
+ "aria-label": "Add gradient stop",
313
+ title: "Add stop",
314
+ disabled,
315
+ },
316
+ createIcon("add"),
317
+ );
318
+ const reorder = document.createElement("fig-reorder");
319
+ reorder.toggleAttribute("disabled", disabled);
320
+ const stops = createElement(
321
+ "div",
322
+ { className: "fig-fill-picker-gradient-stops" },
323
+ [
324
+ createElement(
325
+ "fig-header",
326
+ {
327
+ className: "fig-fill-picker-gradient-stops-header",
328
+ borderless: true,
329
+ },
330
+ [createElement("h3", {}, "Stops"), addButton],
331
+ ),
332
+ createElement(
333
+ "div",
334
+ { className: "fig-fill-picker-gradient-stops-list" },
335
+ reorder,
336
+ ),
337
+ ],
338
+ );
339
+
340
+ 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
+ },
364
+ );
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
+ },
375
+ );
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
+ },
388
+ );
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
+ });
430
+ }
431
+
432
+ #formatStopColor(stop) {
433
+ if ((stop.opacity ?? 100) >= 100) return stop.color;
434
+ return `${stop.color}${Math.round(((stop.opacity ?? 100) / 100) * 255)
435
+ .toString(16)
436
+ .padStart(2, "0")}`;
437
+ }
438
+
439
+ #readRow(row) {
440
+ const positionInput = row.querySelector(".fig-fill-picker-stop-position");
441
+ const colorInput = row.querySelector(".fig-fill-picker-stop-color");
442
+ const current = this.#gradient.stops[Number(row.dataset.index)] ?? {};
443
+ const position = Number(
444
+ positionInput?.value ?? positionInput?.getAttribute("value") ?? 0,
445
+ );
446
+ const liveColor =
447
+ colorInput?.hexOpaque ||
448
+ (typeof colorInput?.value === "string" ? colorInput.value : "") ||
449
+ colorInput?.getAttribute("value");
450
+ return {
451
+ position: Number.isFinite(position)
452
+ ? Math.max(0, Math.min(100, position))
453
+ : 0,
454
+ color: normalizeHex(String(liveColor || current.color).slice(0, 7)),
455
+ opacity:
456
+ colorInput?.rgba?.a !== undefined
457
+ ? Math.round(colorInput.rgba.a * 100)
458
+ : current.opacity ?? 100,
459
+ };
460
+ }
461
+
462
+ #syncRow(row) {
463
+ const index = Number(row.dataset.index);
464
+ if (!Number.isInteger(index) || !this.#gradient.stops[index]) return;
465
+ this.#gradient.stops[index] = {
466
+ ...this.#gradient.stops[index],
467
+ ...this.#readRow(row),
468
+ };
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
+ }
477
+
478
+ #flushRows() {
479
+ this.querySelectorAll(".fig-fill-picker-gradient-stop-row").forEach((row) =>
480
+ this.#syncRow(row),
481
+ );
482
+ }
483
+
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",
490
+ {
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,
497
+ },
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");
535
+ });
536
+ position.addEventListener("change", () => {
537
+ this.#syncRow(row);
538
+ this.#syncPreview();
539
+ this.#emit("change");
540
+ });
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
+ });
549
+ }
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");
556
+ });
557
+ return row;
558
+ });
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,
571
+ );
572
+ },
573
+ );
574
+ this.#syncNestedPickerAnchors();
575
+ }
576
+
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;
583
+ }
584
+
585
+ #syncNestedPickerAnchors() {
586
+ requestAnimationFrame(() => {
587
+ if (!this.isConnected) return;
588
+ this.querySelectorAll(".fig-fill-picker-stop-color").forEach((input) => {
589
+ const picker = input.querySelector("fig-fill-picker");
590
+ if (picker) picker.anchorElement = this.#pickerAnchor;
591
+ });
592
+ });
593
+ }
594
+ }
595
+
596
+ if (!customElements.get("fig-gradient-editor")) {
597
+ customElements.define("fig-gradient-editor", FigGradientEditor);
598
+ }
@@ -0,0 +1,8 @@
1
+ /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
+ dialog[is="fig-popup-easing"] {
3
+ width: 12rem;
4
+ min-width: 0;
5
+ padding: 0;
6
+ pointer-events: auto;
7
+ padding: 0 var(--spacer-2-5);
8
+ }