@rogieking/figui3 11.1.6 → 11.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig-editor.js CHANGED
@@ -1,6 +1,5 @@
1
1
  /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
2
  import "./fig.js";
3
- import "./fig-lab.js";
4
3
 
5
4
  function figEditorDefineElement(name, constructor) {
6
5
  if (!customElements.get(name)) {
@@ -83,6 +82,73 @@ function figEditorCreateElement(tagName, attributes, children) {
83
82
  return figEditorAppendChildren(element, children);
84
83
  }
85
84
 
85
+ function figEditorNormalizePaletteValue(value) {
86
+ let entries = value;
87
+ if (typeof entries === "string") {
88
+ const raw = entries.trim();
89
+ if (!raw) return [];
90
+ try {
91
+ entries = JSON.parse(raw);
92
+ } catch {
93
+ entries = raw.includes(",") ? raw.split(",") : [raw];
94
+ }
95
+ }
96
+ if (!Array.isArray(entries)) return [];
97
+ return entries.map((entry) => {
98
+ if (typeof entry === "string") {
99
+ const color = entry.trim();
100
+ return {
101
+ color: color.slice(0, 7) || "#D9D9D9",
102
+ alpha:
103
+ color.length > 7
104
+ ? Math.max(0, Math.min(1, parseInt(color.slice(7, 9), 16) / 255))
105
+ : 1,
106
+ };
107
+ }
108
+ if (entry && typeof entry === "object") {
109
+ const alpha =
110
+ entry.alpha !== undefined
111
+ ? Number(entry.alpha)
112
+ : entry.opacity !== undefined
113
+ ? Number(entry.opacity) / 100
114
+ : 1;
115
+ return {
116
+ color: String(entry.color || "#D9D9D9").slice(0, 7),
117
+ alpha: Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1,
118
+ };
119
+ }
120
+ return { color: "#D9D9D9", alpha: 1 };
121
+ });
122
+ }
123
+
124
+ function figEditorPaletteEntryValue(entry) {
125
+ const color = String(entry?.color || "#D9D9D9").slice(0, 7);
126
+ const alpha = Math.max(0, Math.min(1, Number(entry?.alpha ?? 1)));
127
+ if (alpha >= 1) return color;
128
+ return `${color}${Math.round(alpha * 255)
129
+ .toString(16)
130
+ .padStart(2, "0")}`;
131
+ }
132
+
133
+ function figEditorAveragePaletteEntries(first, second) {
134
+ if (!first && !second) return { color: "#D9D9D9", alpha: 1 };
135
+ if (!first || !second) return { ...(first || second) };
136
+ const firstRgb = figEditorHexToRgb(first.color);
137
+ const secondRgb = figEditorHexToRgb(second.color);
138
+ const color = `#${["r", "g", "b"]
139
+ .map((channel) =>
140
+ Math.round((firstRgb[channel] + secondRgb[channel]) / 2)
141
+ .toString(16)
142
+ .padStart(2, "0"),
143
+ )
144
+ .join("")
145
+ .toUpperCase()}`;
146
+ return {
147
+ color,
148
+ alpha: (Number(first.alpha ?? 1) + Number(second.alpha ?? 1)) / 2,
149
+ };
150
+ }
151
+
86
152
  const FIG_EDITOR_SVG_NAMESPACE = "http://www.w3.org/2000/svg";
87
153
 
88
154
  function figEditorCreateSvgElement(tagName, attributes, children) {
@@ -282,6 +348,244 @@ function figEditorScrollElementToCenter(
282
348
  }
283
349
 
284
350
 
351
+ /**
352
+ * A reusable editor for an ordered palette of solid colors.
353
+ * @attr {string} value - JSON array of hex strings or {color,alpha} objects, or comma-separated hex
354
+ * @attr {number} min - Minimum number of colors (default: 2)
355
+ * @attr {number} max - Maximum number of colors (unbounded when omitted)
356
+ * @attr {boolean} fixed - Locks palette length while preserving color editing and reordering
357
+ * @attr {boolean} disabled - Disables editing and reordering
358
+ * @fires input - During color editing or reordering (detail: normalized color array)
359
+ * @fires change - On committed color edits, add, remove, or reorder
360
+ */
361
+ class FigPaletteEditor extends HTMLElement {
362
+ #colors = [];
363
+ #syncingValueAttribute = false;
364
+
365
+ static get observedAttributes() {
366
+ return ["value", "min", "max", "fixed", "disabled"];
367
+ }
368
+
369
+ get #min() {
370
+ const value = Number.parseInt(this.getAttribute("min"), 10);
371
+ return Number.isFinite(value) ? Math.max(0, value) : 2;
372
+ }
373
+
374
+ get #max() {
375
+ const value = Number.parseInt(this.getAttribute("max"), 10);
376
+ return Number.isFinite(value) ? Math.max(this.#min, value) : Infinity;
377
+ }
378
+
379
+ get #fixed() {
380
+ return figEditorBooleanAttribute(this, "fixed");
381
+ }
382
+
383
+ get #disabled() {
384
+ return figEditorBooleanAttribute(this, "disabled");
385
+ }
386
+
387
+ get value() {
388
+ return this.#colors.map((entry) => ({ ...entry }));
389
+ }
390
+
391
+ set value(value) {
392
+ this.setAttribute(
393
+ "value",
394
+ typeof value === "string" ? value : JSON.stringify(value ?? []),
395
+ );
396
+ }
397
+
398
+ connectedCallback() {
399
+ this.#colors = figEditorNormalizePaletteValue(this.getAttribute("value"));
400
+ this.#render();
401
+ }
402
+
403
+ attributeChangedCallback(name, oldValue, newValue) {
404
+ if (oldValue === newValue || this.#syncingValueAttribute) return;
405
+ if (name === "value") {
406
+ this.#colors = figEditorNormalizePaletteValue(newValue);
407
+ }
408
+ if (this.isConnected) this.#render();
409
+ }
410
+
411
+ #reflectValue() {
412
+ this.#syncingValueAttribute = true;
413
+ this.setAttribute("value", JSON.stringify(this.#colors));
414
+ this.#syncingValueAttribute = false;
415
+ }
416
+
417
+ #emit(type) {
418
+ this.dispatchEvent(
419
+ new CustomEvent(type, {
420
+ bubbles: true,
421
+ detail: this.value,
422
+ }),
423
+ );
424
+ }
425
+
426
+ #commit(type, { rerender = false } = {}) {
427
+ this.#reflectValue();
428
+ if (rerender) this.#render();
429
+ this.#emit(type);
430
+ }
431
+
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;
463
+ }
464
+
465
+ #createRow(entry, index) {
466
+ const colorInput = figEditorCreateElement("fig-input-color", {
467
+ className: "fig-palette-editor-color",
468
+ value: figEditorPaletteEntryValue(entry),
469
+ text: true,
470
+ alpha: true,
471
+ full: true,
472
+ disabled: this.#disabled || null,
473
+ "aria-label": `Palette color ${index + 1}`,
474
+ });
475
+ const syncColor = (event, type) => {
476
+ event.stopPropagation();
477
+ const row = colorInput.closest(".fig-palette-editor-color-row");
478
+ const currentIndex = Number.parseInt(row?.dataset.index ?? "", 10);
479
+ if (!Number.isInteger(currentIndex) || !this.#colors[currentIndex]) return;
480
+ this.#colors[currentIndex] = {
481
+ color:
482
+ colorInput.hexOpaque ||
483
+ this.#colors[currentIndex]?.color ||
484
+ "#D9D9D9",
485
+ alpha:
486
+ colorInput.rgba?.a ?? this.#colors[currentIndex]?.alpha ?? 1,
487
+ };
488
+ this.#commit(type);
489
+ };
490
+ colorInput.addEventListener("input", (event) =>
491
+ syncColor(event, "input"),
492
+ );
493
+ colorInput.addEventListener("change", (event) =>
494
+ syncColor(event, "change"),
495
+ );
496
+
497
+ return figEditorCreateElement(
498
+ "fig-field",
499
+ {
500
+ className: "fig-palette-editor-color-row",
501
+ "data-index": index,
502
+ },
503
+ [
504
+ colorInput,
505
+ ...(this.#fixed ? [] : [this.#createRemoveButton(index)]),
506
+ ],
507
+ );
508
+ }
509
+
510
+ #handleReorder = (event) => {
511
+ const oldIndex = Number(event.detail?.oldIndex);
512
+ const newIndex = Number(event.detail?.newIndex);
513
+ if (
514
+ !Number.isInteger(oldIndex) ||
515
+ !Number.isInteger(newIndex) ||
516
+ oldIndex === newIndex ||
517
+ oldIndex < 0 ||
518
+ newIndex < 0 ||
519
+ oldIndex >= this.#colors.length ||
520
+ newIndex >= this.#colors.length
521
+ ) {
522
+ return;
523
+ }
524
+ const [entry] = this.#colors.splice(oldIndex, 1);
525
+ this.#colors.splice(newIndex, 0, entry);
526
+ this.querySelectorAll(".fig-palette-editor-color-row").forEach(
527
+ (row, index) => {
528
+ row.dataset.index = String(index);
529
+ row
530
+ .querySelector(".fig-palette-editor-color")
531
+ ?.setAttribute("aria-label", `Palette color ${index + 1}`);
532
+ row
533
+ .querySelector(".fig-palette-editor-remove")
534
+ ?.setAttribute("aria-label", `Remove color ${index + 1}`);
535
+ },
536
+ );
537
+ this.#reflectValue();
538
+ this.#emit("input");
539
+ this.#emit("change");
540
+ };
541
+
542
+ #render() {
543
+ const reorder = figEditorCreateElement(
544
+ "fig-reorder",
545
+ {
546
+ axis: "vertical",
547
+ items: ".fig-palette-editor-color-row",
548
+ "aria-label": "Palette colors",
549
+ disabled: this.#disabled || null,
550
+ },
551
+ this.#colors.map((entry, index) => this.#createRow(entry, index)),
552
+ );
553
+ reorder.addEventListener("reorder", this.#handleReorder);
554
+
555
+ const children = [reorder];
556
+ if (!this.#fixed) {
557
+ const addButton = figEditorCreateElement(
558
+ "fig-button",
559
+ {
560
+ className: "fig-palette-editor-add",
561
+ variant: "secondary",
562
+ full: true,
563
+ disabled:
564
+ this.#disabled || this.#colors.length >= this.#max || null,
565
+ },
566
+ "Add color",
567
+ );
568
+ addButton.addEventListener("click", () => {
569
+ if (this.#disabled || this.#colors.length >= this.#max) return;
570
+ this.#colors.push(
571
+ figEditorAveragePaletteEntries(
572
+ this.#colors.at(-2),
573
+ this.#colors.at(-1),
574
+ ),
575
+ );
576
+ this.#reflectValue();
577
+ this.#render();
578
+ this.#emit("input");
579
+ this.#emit("change");
580
+ });
581
+ children.push(figEditorCreateElement("fig-field", {}, addButton));
582
+ }
583
+ this.replaceChildren(...children);
584
+ }
585
+ }
586
+ figEditorDefineElement("fig-palette-editor", FigPaletteEditor);
587
+
588
+
285
589
  /* Select — dropdown-styled trigger + fig-popup listbox */
286
590
  /** Parse options attr — same formats as fig-options. */
287
591
  function figSelectParseOptionsAttribute(raw) {
@@ -3842,8 +4146,11 @@ class FigFillPicker extends HTMLElement {
3842
4146
  ),
3843
4147
  );
3844
4148
  const actions = figEditorCreateElement(
3845
- "div",
3846
- { className: "fig-fill-picker-gradient-actions" },
4149
+ "fig-stack",
4150
+ {
4151
+ className: "fig-fill-picker-gradient-actions",
4152
+ gap: "1",
4153
+ },
3847
4154
  [
3848
4155
  createAction(
3849
4156
  "Flip gradient",
@@ -3858,8 +4165,12 @@ class FigFillPicker extends HTMLElement {
3858
4165
  ],
3859
4166
  );
3860
4167
  const header = figEditorCreateElement(
3861
- "fig-field",
3862
- { className: "fig-fill-picker-gradient-header" },
4168
+ "fig-header",
4169
+ {
4170
+ className: "fig-fill-picker-gradient-header",
4171
+ borderless: true,
4172
+ compact: true,
4173
+ },
3863
4174
  [gradientType, actions],
3864
4175
  );
3865
4176
  const gradientBar = figEditorCreateElement("fig-input-gradient", {
@@ -3972,13 +4283,10 @@ class FigFillPicker extends HTMLElement {
3972
4283
  if (!gradientBarInput) return;
3973
4284
  this.#syncingGradientBar = true;
3974
4285
  try {
3975
- gradientBarInput.setAttribute(
3976
- "value",
3977
- JSON.stringify({
3978
- type: "gradient",
3979
- gradient: gradientToValueShape(gradient),
3980
- }),
3981
- );
4286
+ gradientBarInput.value = {
4287
+ type: "gradient",
4288
+ gradient: gradientToValueShape(gradient),
4289
+ };
3982
4290
  } finally {
3983
4291
  this.#syncingGradientBar = false;
3984
4292
  }
@@ -4461,13 +4769,10 @@ class FigFillPicker extends HTMLElement {
4461
4769
  );
4462
4770
  if (barInput) {
4463
4771
  this.#syncingGradientBar = true;
4464
- barInput.setAttribute(
4465
- "value",
4466
- JSON.stringify({
4467
- type: "gradient",
4468
- gradient: gradientToValueShape(this.#gradient),
4469
- }),
4470
- );
4772
+ barInput.value = {
4773
+ type: "gradient",
4774
+ gradient: gradientToValueShape(this.#gradient),
4775
+ };
4471
4776
  this.#syncingGradientBar = false;
4472
4777
  }
4473
4778
 
@@ -6107,3 +6412,695 @@ class FigInterpolationSwatch extends HTMLElement {
6107
6412
  }
6108
6413
  }
6109
6414
  figEditorDefineElement("fig-interpolation-swatch", FigInterpolationSwatch);
6415
+
6416
+ /* Reorder wrapper */
6417
+ /**
6418
+ * @attr {string} axis - Reorder direction: "vertical" (default) or "horizontal"
6419
+ * @attr {string} handle - Selector for the drag handle within each item
6420
+ * @attr {string} items - Selector limiting which direct children are reorderable
6421
+ * @attr {string} indicator - Drop indicator style: "default" or "ring"
6422
+ * @attr {boolean} disabled - Disable reordering
6423
+ */
6424
+ class FigReorder extends HTMLElement {
6425
+ static observedAttributes = [
6426
+ "axis",
6427
+ "handle",
6428
+ "items",
6429
+ "indicator",
6430
+ "disabled",
6431
+ ];
6432
+
6433
+ static #DRAG_THRESHOLD = 6;
6434
+
6435
+ static #NESTED_DRAG_SELECTORS = [
6436
+ '[draggable]:not([draggable="false"])',
6437
+ 'img:not([draggable="false"])',
6438
+ 'a[href]:not([draggable="false"])',
6439
+ 'input[type="range"]',
6440
+ "fig-handle",
6441
+ "fig-slider",
6442
+ "fig-input-number",
6443
+ "fig-input-gradient",
6444
+ "fig-easing-curve",
6445
+ "fig-angle",
6446
+ "fig-input-audio",
6447
+ "fig-input-wheel",
6448
+ "fig-joystick",
6449
+ "fig-origin-grid",
6450
+ "fig-chooser",
6451
+ "fig-canvas-control",
6452
+ ];
6453
+
6454
+ static #OWNED_ATTRIBUTES = new Set([
6455
+ "data-reorder-item",
6456
+ "data-reorder-generated-role",
6457
+ "data-reorder-handle",
6458
+ "role",
6459
+ "tabindex",
6460
+ "aria-label",
6461
+ "aria-roledescription",
6462
+ ]);
6463
+
6464
+ #childObserver = null;
6465
+ #attributeObserver = null;
6466
+ #attributeSyncQueued = false;
6467
+ #bindings = new Map();
6468
+ #drag = null;
6469
+ #indicator = null;
6470
+ #liveRegion = null;
6471
+
6472
+ connectedCallback() {
6473
+ this.style.display = "contents";
6474
+ if (!this.hasAttribute("role")) this.setAttribute("role", "list");
6475
+ if (!this.hasAttribute("aria-label")) {
6476
+ this.setAttribute("aria-label", "Reorderable list");
6477
+ }
6478
+ this.#ensureLiveRegion();
6479
+ this.#syncChildren();
6480
+ this.#childObserver = new MutationObserver(() => this.#syncChildren());
6481
+ this.#childObserver.observe(this, { childList: true });
6482
+ this.#syncAttributeObserver();
6483
+ }
6484
+
6485
+ disconnectedCallback() {
6486
+ this.#childObserver?.disconnect();
6487
+ this.#childObserver = null;
6488
+ this.#attributeObserver?.disconnect();
6489
+ this.#attributeObserver = null;
6490
+ this.#unbindAll();
6491
+ this.#cancelDrag();
6492
+ this.#removeIndicator();
6493
+ this.#liveRegion?.remove();
6494
+ this.#liveRegion = null;
6495
+ }
6496
+
6497
+ attributeChangedCallback(name) {
6498
+ if (this.isConnected) {
6499
+ if (this.#disabled) this.#cancelDrag();
6500
+ if (name === "indicator") this.#syncIndicatorStyle();
6501
+ this.#syncAttributeObserver();
6502
+ this.#syncChildren();
6503
+ }
6504
+ }
6505
+
6506
+ refresh() {
6507
+ if (this.isConnected) this.#syncChildren();
6508
+ }
6509
+
6510
+ #syncAttributeObserver() {
6511
+ if (!this.#itemsSelector) {
6512
+ this.#attributeObserver?.disconnect();
6513
+ this.#attributeObserver = null;
6514
+ return;
6515
+ }
6516
+ if (this.#attributeObserver) return;
6517
+ this.#attributeObserver = new MutationObserver((records) => {
6518
+ const relevant = records.some(
6519
+ (record) =>
6520
+ record.target.parentElement === this &&
6521
+ !FigReorder.#OWNED_ATTRIBUTES.has(record.attributeName) &&
6522
+ !(record.attributeName === "class" && record.target === this.#drag?.item),
6523
+ );
6524
+ if (!relevant || this.#attributeSyncQueued) return;
6525
+ this.#attributeSyncQueued = true;
6526
+ queueMicrotask(() => {
6527
+ this.#attributeSyncQueued = false;
6528
+ if (this.isConnected) this.#syncChildren();
6529
+ });
6530
+ });
6531
+ this.#attributeObserver.observe(this, { subtree: true, attributes: true });
6532
+ }
6533
+
6534
+ get #disabled() {
6535
+ return (
6536
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false"
6537
+ );
6538
+ }
6539
+
6540
+ get #axis() {
6541
+ const value = (this.getAttribute("axis") || "vertical").trim().toLowerCase();
6542
+ return value === "horizontal" ? "horizontal" : "vertical";
6543
+ }
6544
+
6545
+ get #handleSelector() {
6546
+ return (this.getAttribute("handle") || "").trim();
6547
+ }
6548
+
6549
+ get #itemsSelector() {
6550
+ return (this.getAttribute("items") || "").trim();
6551
+ }
6552
+
6553
+ get #indicatorStyle() {
6554
+ return (this.getAttribute("indicator") || "default").trim().toLowerCase();
6555
+ }
6556
+
6557
+ #getAllElementChildren() {
6558
+ return [...this.children].filter(
6559
+ (node) =>
6560
+ node.nodeType === Node.ELEMENT_NODE &&
6561
+ !node.hasAttribute("data-reorder-live"),
6562
+ );
6563
+ }
6564
+
6565
+ #getElementChildren() {
6566
+ const children = this.#getAllElementChildren();
6567
+ const selector = this.#itemsSelector;
6568
+ if (!selector) return children;
6569
+ try {
6570
+ return children.filter((child) => child.matches(selector));
6571
+ } catch {
6572
+ return [];
6573
+ }
6574
+ }
6575
+
6576
+ #syncChildren() {
6577
+ const allChildren = this.#getAllElementChildren();
6578
+ const children = this.#getElementChildren();
6579
+ const childSet = new Set(children);
6580
+
6581
+ for (const [child, binding] of this.#bindings) {
6582
+ if (!childSet.has(child)) {
6583
+ binding.target.removeEventListener(
6584
+ "pointerdown",
6585
+ binding.onPointerDown,
6586
+ true,
6587
+ );
6588
+ binding.target.removeEventListener("keydown", binding.onKeyDown);
6589
+ this.#restoreKeyboardAttrs(binding);
6590
+ this.#bindings.delete(child);
6591
+ }
6592
+ }
6593
+
6594
+ this.#clearHandleMarks(allChildren);
6595
+ this.#clearReorderItemMarks(allChildren);
6596
+ this.#markNonItems(allChildren.filter((child) => !childSet.has(child)));
6597
+ const reorderable = !this.#disabled && children.length >= 2;
6598
+ this.#markReorderItems(children, reorderable);
6599
+
6600
+ if (!reorderable) {
6601
+ this.#unbindAll();
6602
+ return;
6603
+ }
6604
+
6605
+ for (const child of children) {
6606
+ const target = this.#getDragTarget(child);
6607
+ const existing = this.#bindings.get(child);
6608
+ if (existing && existing.target === target) continue;
6609
+ if (existing) {
6610
+ existing.target.removeEventListener(
6611
+ "pointerdown",
6612
+ existing.onPointerDown,
6613
+ true,
6614
+ );
6615
+ this.#bindings.delete(child);
6616
+ }
6617
+ if (!target) continue;
6618
+ this.#bindChild(child, target);
6619
+ }
6620
+
6621
+ this.#markHandles(children);
6622
+ }
6623
+
6624
+ #clearReorderItemMarks(children) {
6625
+ for (const child of children) {
6626
+ child.removeAttribute("data-reorder-item");
6627
+ if (child.hasAttribute("data-reorder-generated-role")) {
6628
+ child.removeAttribute("role");
6629
+ child.removeAttribute("data-reorder-generated-role");
6630
+ }
6631
+ }
6632
+ }
6633
+
6634
+ #markReorderItems(children, reorderable = true) {
6635
+ for (const child of children) {
6636
+ if (reorderable) child.setAttribute("data-reorder-item", "");
6637
+ if (!child.hasAttribute("role")) {
6638
+ child.setAttribute("role", "listitem");
6639
+ child.setAttribute("data-reorder-generated-role", "");
6640
+ }
6641
+ }
6642
+ }
6643
+
6644
+ #markNonItems(children) {
6645
+ for (const child of children) {
6646
+ if (!child.hasAttribute("role")) {
6647
+ child.setAttribute("role", "none");
6648
+ child.setAttribute("data-reorder-generated-role", "");
6649
+ }
6650
+ }
6651
+ }
6652
+
6653
+ #clearHandleMarks(children) {
6654
+ for (const child of children) {
6655
+ child
6656
+ .querySelectorAll("[data-reorder-handle]")
6657
+ .forEach((node) => {
6658
+ node.removeAttribute("data-reorder-handle");
6659
+ node.removeAttribute("aria-roledescription");
6660
+ });
6661
+ }
6662
+ }
6663
+
6664
+ #markHandles(children) {
6665
+ if (!this.#handleSelector) return;
6666
+ for (const child of children) {
6667
+ const handle = child.querySelector(this.#handleSelector);
6668
+ if (handle) {
6669
+ handle.setAttribute("data-reorder-handle", "");
6670
+ handle.setAttribute("aria-roledescription", "reorder handle");
6671
+ }
6672
+ }
6673
+ }
6674
+
6675
+ #getDragTarget(child) {
6676
+ if (this.#handleSelector) {
6677
+ return child.querySelector(this.#handleSelector);
6678
+ }
6679
+ return child;
6680
+ }
6681
+
6682
+ #bindChild(child, target) {
6683
+ const onPointerDown = (event) => {
6684
+ if (this.#disabled || this.#getElementChildren().length < 2) return;
6685
+ if (event.button !== 0) return;
6686
+ if (
6687
+ !this.#handleSelector &&
6688
+ this.#isNestedDragTarget(event.target, child)
6689
+ ) {
6690
+ return;
6691
+ }
6692
+ this.#startPendingDrag(event, child, target);
6693
+ };
6694
+ const originalTabIndex = target.getAttribute("tabindex");
6695
+ const originalAriaLabel = target.getAttribute("aria-label");
6696
+ const originalRole = target.getAttribute("role");
6697
+ const itemName =
6698
+ child.getAttribute("aria-label") ||
6699
+ child.textContent?.trim().replace(/\s+/g, " ").slice(0, 80) ||
6700
+ "item";
6701
+ target.setAttribute("tabindex", "0");
6702
+ target.setAttribute("aria-label", `Move ${itemName}`);
6703
+ if (target !== child && !target.hasAttribute("role")) {
6704
+ target.setAttribute("role", "button");
6705
+ }
6706
+ const onKeyDown = (event) => {
6707
+ if (this.#disabled) return;
6708
+ const horizontal = this.#axis === "horizontal";
6709
+ const previousKey = horizontal ? "ArrowLeft" : "ArrowUp";
6710
+ const nextKey = horizontal ? "ArrowRight" : "ArrowDown";
6711
+ if (
6712
+ event.key !== previousKey &&
6713
+ event.key !== nextKey &&
6714
+ event.key !== "Home" &&
6715
+ event.key !== "End"
6716
+ ) {
6717
+ return;
6718
+ }
6719
+ event.preventDefault();
6720
+ event.stopPropagation();
6721
+ const items = this.#getElementChildren();
6722
+ const oldIndex = items.indexOf(child);
6723
+ let newIndex = oldIndex;
6724
+ if (event.key === previousKey) newIndex = Math.max(0, oldIndex - 1);
6725
+ else if (event.key === nextKey) {
6726
+ newIndex = Math.min(items.length - 1, oldIndex + 1);
6727
+ } else if (event.key === "Home") newIndex = 0;
6728
+ else if (event.key === "End") newIndex = items.length - 1;
6729
+ if (newIndex === oldIndex) {
6730
+ this.#announce(`${itemName}, position ${oldIndex + 1} of ${items.length}`);
6731
+ return;
6732
+ }
6733
+ this.#moveItemToFinalIndex(child, newIndex);
6734
+ this.#syncChildren();
6735
+ this.#getDragTarget(child)?.focus();
6736
+ this.dispatchEvent(
6737
+ new CustomEvent("reorder", {
6738
+ bubbles: true,
6739
+ detail: { oldIndex, newIndex, item: child },
6740
+ }),
6741
+ );
6742
+ this.#announce(`${itemName}, position ${newIndex + 1} of ${items.length}`);
6743
+ };
6744
+
6745
+ target.addEventListener("pointerdown", onPointerDown, true);
6746
+ target.addEventListener("keydown", onKeyDown);
6747
+ this.#bindings.set(child, {
6748
+ target,
6749
+ onPointerDown,
6750
+ onKeyDown,
6751
+ originalTabIndex,
6752
+ originalAriaLabel,
6753
+ originalRole,
6754
+ });
6755
+ }
6756
+
6757
+ #restoreKeyboardAttrs(binding) {
6758
+ const { target, originalTabIndex, originalAriaLabel, originalRole } = binding;
6759
+ if (originalTabIndex === null) target.removeAttribute("tabindex");
6760
+ else target.setAttribute("tabindex", originalTabIndex);
6761
+ if (originalAriaLabel === null) target.removeAttribute("aria-label");
6762
+ else target.setAttribute("aria-label", originalAriaLabel);
6763
+ if (originalRole === null) target.removeAttribute("role");
6764
+ else target.setAttribute("role", originalRole);
6765
+ }
6766
+
6767
+ #ensureLiveRegion() {
6768
+ if (this.#liveRegion?.isConnected) return;
6769
+ const region = document.createElement("span");
6770
+ region.setAttribute("data-reorder-live", "");
6771
+ region.setAttribute("role", "status");
6772
+ region.setAttribute("aria-live", "polite");
6773
+ region.setAttribute("aria-atomic", "true");
6774
+ Object.assign(region.style, {
6775
+ position: "absolute",
6776
+ width: "1px",
6777
+ height: "1px",
6778
+ padding: "0",
6779
+ margin: "-1px",
6780
+ overflow: "hidden",
6781
+ clip: "rect(0, 0, 0, 0)",
6782
+ whiteSpace: "nowrap",
6783
+ border: "0",
6784
+ });
6785
+ this.#liveRegion = region;
6786
+ document.body.appendChild(region);
6787
+ }
6788
+
6789
+ #announce(message) {
6790
+ if (!this.#liveRegion) return;
6791
+ this.#liveRegion.textContent = "";
6792
+ requestAnimationFrame(() => {
6793
+ if (this.#liveRegion) this.#liveRegion.textContent = message;
6794
+ });
6795
+ }
6796
+
6797
+ #isNestedDragTarget(target, child) {
6798
+ let node = target;
6799
+ while (node && node !== child) {
6800
+ if (node instanceof Element) {
6801
+ for (const selector of FigReorder.#NESTED_DRAG_SELECTORS) {
6802
+ if (node.matches(selector)) return true;
6803
+ }
6804
+ }
6805
+ node = node.parentElement;
6806
+ }
6807
+ return false;
6808
+ }
6809
+
6810
+ static #DRAGGING_BODY_CLASS = "fig-reorder-dragging";
6811
+
6812
+ static #setDocumentDragging(active) {
6813
+ document.body.classList.toggle(FigReorder.#DRAGGING_BODY_CLASS, active);
6814
+ }
6815
+
6816
+ #startPendingDrag(event, item, target) {
6817
+ this.#cancelDrag();
6818
+
6819
+ const state = {
6820
+ item,
6821
+ target,
6822
+ pointerId: event.pointerId,
6823
+ oldIndex: this.#getElementChildren().indexOf(item),
6824
+ targetIndex: this.#getElementChildren().indexOf(item),
6825
+ startX: event.clientX,
6826
+ startY: event.clientY,
6827
+ active: false,
6828
+ onMove: null,
6829
+ onUp: null,
6830
+ onKeyDown: null,
6831
+ };
6832
+
6833
+ state.onMove = (moveEvent) => {
6834
+ if (moveEvent.pointerId !== state.pointerId) return;
6835
+
6836
+ const dx = moveEvent.clientX - state.startX;
6837
+ const dy = moveEvent.clientY - state.startY;
6838
+
6839
+ if (!state.active) {
6840
+ if (dx * dx + dy * dy < FigReorder.#DRAG_THRESHOLD * FigReorder.#DRAG_THRESHOLD) {
6841
+ return;
6842
+ }
6843
+ state.active = true;
6844
+ event.preventDefault();
6845
+ event.stopPropagation();
6846
+ item.classList.add("dragging");
6847
+ FigReorder.#setDocumentDragging(true);
6848
+ try {
6849
+ target.setPointerCapture(state.pointerId);
6850
+ } catch {}
6851
+ }
6852
+
6853
+ moveEvent.preventDefault();
6854
+ const pointer =
6855
+ this.#axis === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
6856
+ const index = this.#getInsertIndex(pointer);
6857
+ state.targetIndex = index;
6858
+ this.#updateIndicator(index, item);
6859
+ };
6860
+
6861
+ state.onKeyDown = (keyEvent) => {
6862
+ if (keyEvent.key !== "Escape" || !state.active) return;
6863
+ keyEvent.preventDefault();
6864
+ this.#finishDrag(state, true);
6865
+ };
6866
+
6867
+ state.onUp = (upEvent) => {
6868
+ if (upEvent.pointerId !== state.pointerId) return;
6869
+ this.#finishDrag(state, upEvent.type === "pointercancel");
6870
+ };
6871
+
6872
+ this.#drag = state;
6873
+ window.addEventListener("pointermove", state.onMove);
6874
+ window.addEventListener("pointerup", state.onUp);
6875
+ window.addEventListener("pointercancel", state.onUp);
6876
+ window.addEventListener("keydown", state.onKeyDown);
6877
+ }
6878
+
6879
+ #getInsertIndex(pointer) {
6880
+ const items = this.#getElementChildren();
6881
+ const horizontal = this.#axis === "horizontal";
6882
+
6883
+ for (let i = 0; i < items.length; i++) {
6884
+ const rect = items[i].getBoundingClientRect();
6885
+ const midpoint = horizontal
6886
+ ? rect.left + rect.width / 2
6887
+ : rect.top + rect.height / 2;
6888
+ if (pointer < midpoint) return i;
6889
+ }
6890
+
6891
+ return items.length;
6892
+ }
6893
+
6894
+ #shouldShowIndicator(item, index) {
6895
+ const items = this.#getElementChildren();
6896
+ const currentIndex = items.indexOf(item);
6897
+ if (currentIndex === -1) return false;
6898
+
6899
+ const clamped = Math.max(0, Math.min(index, items.length));
6900
+
6901
+ if (index === currentIndex) return false;
6902
+
6903
+ // Hide only the redundant bottom line when the dragged item is already last.
6904
+ if (clamped >= items.length && currentIndex === items.length - 1) {
6905
+ return false;
6906
+ }
6907
+
6908
+ return true;
6909
+ }
6910
+
6911
+ #getReorderBounds(items) {
6912
+ if (!items.length) return null;
6913
+
6914
+ let left = Infinity;
6915
+ let right = -Infinity;
6916
+ let top = Infinity;
6917
+ let bottom = -Infinity;
6918
+
6919
+ for (const item of items) {
6920
+ const rect = item.getBoundingClientRect();
6921
+ left = Math.min(left, rect.left);
6922
+ right = Math.max(right, rect.right);
6923
+ top = Math.min(top, rect.top);
6924
+ bottom = Math.max(bottom, rect.bottom);
6925
+ }
6926
+
6927
+ return {
6928
+ left,
6929
+ top,
6930
+ width: right - left,
6931
+ height: bottom - top,
6932
+ };
6933
+ }
6934
+
6935
+ #getIndicatorRoot() {
6936
+ const topLayerSelectors = [":popover-open", ":modal", ":fullscreen"];
6937
+ let ancestor = this.parentElement;
6938
+
6939
+ while (ancestor) {
6940
+ const isTopLayer = topLayerSelectors.some((selector) => {
6941
+ try {
6942
+ return ancestor.matches(selector);
6943
+ } catch {
6944
+ return false;
6945
+ }
6946
+ });
6947
+ if (isTopLayer) return ancestor;
6948
+ ancestor = ancestor.parentElement;
6949
+ }
6950
+
6951
+ return document.body;
6952
+ }
6953
+
6954
+ #ensureIndicator() {
6955
+ if (this.#indicator) return this.#indicator;
6956
+
6957
+ const indicator = document.createElement("div");
6958
+ indicator.className = "fig-reorder-indicator";
6959
+ indicator.setAttribute("data-axis", this.#axis);
6960
+ indicator.classList.toggle(
6961
+ "fig-reorder-indicator-ring",
6962
+ this.#indicatorStyle === "ring",
6963
+ );
6964
+ this.#getIndicatorRoot().appendChild(indicator);
6965
+ this.#indicator = indicator;
6966
+ return indicator;
6967
+ }
6968
+
6969
+ #syncIndicatorStyle() {
6970
+ this.#indicator?.classList.toggle(
6971
+ "fig-reorder-indicator-ring",
6972
+ this.#indicatorStyle === "ring",
6973
+ );
6974
+ }
6975
+
6976
+ #updateIndicator(index, item) {
6977
+ const items = this.#getElementChildren();
6978
+ if (!this.#shouldShowIndicator(item, index)) {
6979
+ this.#removeIndicator();
6980
+ return;
6981
+ }
6982
+
6983
+ const bounds = this.#getReorderBounds(items);
6984
+ if (!bounds) {
6985
+ this.#removeIndicator();
6986
+ return;
6987
+ }
6988
+
6989
+ const indicator = this.#ensureIndicator();
6990
+ indicator.setAttribute("data-axis", this.#axis);
6991
+
6992
+ if (this.#axis === "horizontal") {
6993
+ let x;
6994
+ if (index <= 0) {
6995
+ x = items[0].getBoundingClientRect().left;
6996
+ } else if (index >= items.length) {
6997
+ x = items[items.length - 1].getBoundingClientRect().right;
6998
+ } else {
6999
+ x = items[index].getBoundingClientRect().left;
7000
+ }
7001
+
7002
+ indicator.style.left = `${x - 1}px`;
7003
+ indicator.style.top = `${bounds.top}px`;
7004
+ indicator.style.width = "2px";
7005
+ indicator.style.height = `${bounds.height}px`;
7006
+ return;
7007
+ }
7008
+
7009
+ let y;
7010
+ if (index <= 0) {
7011
+ y = items[0].getBoundingClientRect().top;
7012
+ } else if (index >= items.length) {
7013
+ y = items[items.length - 1].getBoundingClientRect().bottom;
7014
+ } else {
7015
+ y = items[index].getBoundingClientRect().top;
7016
+ }
7017
+
7018
+ indicator.style.left = `${bounds.left}px`;
7019
+ indicator.style.top = `${y - 1}px`;
7020
+ indicator.style.width = `${bounds.width}px`;
7021
+ indicator.style.height = "2px";
7022
+ }
7023
+
7024
+ #removeIndicator() {
7025
+ this.#indicator?.remove();
7026
+ this.#indicator = null;
7027
+ }
7028
+
7029
+ #moveItemToIndex(item, index) {
7030
+ const items = this.#getElementChildren();
7031
+ const clamped = Math.max(0, Math.min(index, items.length));
7032
+
7033
+ if (clamped >= items.length) {
7034
+ const last = items[items.length - 1];
7035
+ if (last && last !== item) last.after(item);
7036
+ return;
7037
+ }
7038
+
7039
+ const ref = items[clamped];
7040
+ if (ref !== item) this.insertBefore(item, ref);
7041
+ }
7042
+
7043
+ #moveItemToFinalIndex(item, newIndex) {
7044
+ const items = this.#getElementChildren().filter((candidate) => candidate !== item);
7045
+ const ref = items[newIndex] ?? null;
7046
+ if (ref) this.insertBefore(item, ref);
7047
+ else if (items.length) items[items.length - 1].after(item);
7048
+ else this.appendChild(item);
7049
+ }
7050
+
7051
+ #finishDrag(state, revert) {
7052
+ const { item, oldIndex, active, onMove, onUp, onKeyDown } = state;
7053
+
7054
+ window.removeEventListener("pointermove", onMove);
7055
+ window.removeEventListener("pointerup", onUp);
7056
+ window.removeEventListener("pointercancel", onUp);
7057
+ window.removeEventListener("keydown", onKeyDown);
7058
+
7059
+ if (active) {
7060
+ if (!revert) {
7061
+ this.#moveItemToIndex(item, state.targetIndex);
7062
+ const newIndex = this.#getElementChildren().indexOf(item);
7063
+ if (newIndex !== -1 && newIndex !== oldIndex) {
7064
+ this.dispatchEvent(
7065
+ new CustomEvent("reorder", {
7066
+ bubbles: true,
7067
+ detail: { oldIndex, newIndex, item },
7068
+ }),
7069
+ );
7070
+ const name =
7071
+ item.getAttribute("aria-label") ||
7072
+ item.textContent?.trim().replace(/\s+/g, " ").slice(0, 80) ||
7073
+ "item";
7074
+ this.#announce(
7075
+ `${name}, position ${newIndex + 1} of ${this.#getElementChildren().length}`,
7076
+ );
7077
+ }
7078
+ }
7079
+ }
7080
+
7081
+ item.classList.remove("dragging");
7082
+ FigReorder.#setDocumentDragging(false);
7083
+ this.#removeIndicator();
7084
+ if (this.#drag === state) this.#drag = null;
7085
+ }
7086
+
7087
+ #unbindAll() {
7088
+ for (const [, binding] of this.#bindings) {
7089
+ binding.target.removeEventListener(
7090
+ "pointerdown",
7091
+ binding.onPointerDown,
7092
+ true,
7093
+ );
7094
+ binding.target.removeEventListener("keydown", binding.onKeyDown);
7095
+ this.#restoreKeyboardAttrs(binding);
7096
+ }
7097
+ this.#bindings.clear();
7098
+ }
7099
+
7100
+ #cancelDrag() {
7101
+ if (!this.#drag) return;
7102
+ this.#finishDrag(this.#drag, true);
7103
+ }
7104
+ }
7105
+
7106
+ figEditorDefineElement("fig-reorder", FigReorder);