@rogieking/figui3 9.2.3 → 9.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig-lab.css CHANGED
@@ -217,6 +217,121 @@ fig-chat-message {
217
217
  }
218
218
  }
219
219
 
220
+ fig-input-audio {
221
+ --fig-input-audio-waveform-fade: 1rem;
222
+ --fig-input-audio-waveform-opacity: 0.5;
223
+ --fig-input-audio-waveform-opacity-min: 0.1;
224
+ --fig-input-audio-waveform-stroke-width: 1.5px;
225
+
226
+ position: relative;
227
+ display: inline-flex;
228
+ box-sizing: border-box;
229
+ max-width: 100%;
230
+
231
+ &[full]:not([full="false"]) {
232
+ width: 100%;
233
+ }
234
+
235
+ .fig-input-audio-waveform {
236
+ display: none;
237
+ }
238
+
239
+ &[data-has-audio] {
240
+ width: 18rem;
241
+ height: var(--control-height);
242
+ border-radius: var(--radius-medium);
243
+ background-color: var(--figma-color-bg-secondary);
244
+
245
+ &[full]:not([full="false"]) {
246
+ width: 100%;
247
+ }
248
+
249
+ .fig-input-audio-waveform {
250
+ position: absolute;
251
+ inset: 0;
252
+ display: block;
253
+ overflow: hidden;
254
+ border-radius: inherit;
255
+ color: var(--figma-color-icon-secondary);
256
+ pointer-events: none;
257
+ }
258
+
259
+ .fig-input-audio-waveform > svg {
260
+ display: block;
261
+ width: calc(100% - var(--spacer-3));
262
+ height: 100%;
263
+ margin-inline: var(--spacer-2);
264
+ overflow: visible;
265
+ mask-image: linear-gradient(
266
+ to right,
267
+ rgb(0 0 0 / var(--fig-input-audio-waveform-opacity-min)) 0,
268
+ rgb(0 0 0 / var(--fig-input-audio-waveform-opacity))
269
+ var(--fig-input-audio-waveform-fade),
270
+ rgb(0 0 0 / var(--fig-input-audio-waveform-opacity))
271
+ calc(100% - var(--fig-input-audio-waveform-fade)),
272
+ rgb(0 0 0 / var(--fig-input-audio-waveform-opacity-min)) 100%
273
+ );
274
+ }
275
+
276
+ .fig-input-audio-waveform path {
277
+ fill: currentColor;
278
+ stroke: none;
279
+ }
280
+
281
+ .fig-input-audio-file {
282
+ position: absolute;
283
+ inset: 0;
284
+ z-index: 1;
285
+ display: flex;
286
+ width: 100%;
287
+ height: 100%;
288
+ }
289
+
290
+ .fig-input-audio-file > fig-tooltip:has(.fig-input-file-filename) {
291
+ width: 100%;
292
+ height: 100%;
293
+ }
294
+
295
+ .fig-input-audio-file .fig-input-file-filename {
296
+ width: 100%;
297
+ height: 100%;
298
+ opacity: 0;
299
+ }
300
+
301
+ .fig-input-audio-file .fig-input-file-clear {
302
+ visibility: hidden;
303
+ pointer-events: none;
304
+ }
305
+
306
+ &:hover .fig-input-file-clear,
307
+ &:focus-within .fig-input-file-clear {
308
+ visibility: visible;
309
+ pointer-events: auto;
310
+ }
311
+
312
+ &:hover .fig-input-file-clear {
313
+ background-color: var(--figma-color-bg-secondary);
314
+ }
315
+
316
+ &:has(.fig-input-audio-file input[type="file"]:focus-visible)
317
+ .fig-input-audio-waveform,
318
+ &:has(.fig-input-file-clear:focus) .fig-input-audio-waveform {
319
+ outline: var(--figma-focus-outline);
320
+ outline-offset: var(--figma-focus-outline-offset);
321
+ }
322
+
323
+ &:has(.fig-input-audio-file[dragover]) .fig-input-audio-waveform {
324
+ outline: 1px dashed var(--figma-color-border-brand);
325
+ outline-offset: -1px;
326
+ }
327
+ }
328
+
329
+ &[disabled]:not([disabled="false"]) {
330
+ pointer-events: none;
331
+ opacity: 0.5;
332
+ }
333
+ }
334
+
220
335
  fig-input-wheel {
221
336
  --fig-input-wheel-handle-drag-max: 0.5rem;
222
337
  --fig-input-wheel-handle-drag-offset: 0px;
@@ -504,7 +619,7 @@ fig-reorder:not([handle]):is(:not([disabled]), [disabled="false"]) > [data-reord
504
619
  }
505
620
 
506
621
  fig-reorder > .dragging {
507
- background-color: var(--figma-color-bg-selected);
622
+ background-color: var(--figma-color-bg-secondary);
508
623
  }
509
624
 
510
625
  [data-reorder-handle] {
package/fig-lab.js CHANGED
@@ -288,6 +288,375 @@ class FigAttachments extends HTMLElement {
288
288
  }
289
289
  }
290
290
  figLabDefineElement("fig-attachments", FigAttachments);
291
+
292
+ /**
293
+ * Audio file input with a responsive waveform preview.
294
+ *
295
+ * @attr {string} url - Audio URL to preview when CORS permits.
296
+ * @attr {string} filename - Display filename for URL-backed audio.
297
+ * @attr {string} label - Empty-state upload label. Defaults to "Upload audio".
298
+ * @attr {string} accepts - Accepted file types. Defaults to "audio/*".
299
+ * @attr {string} variant - Forwarded fig-input-file button variant.
300
+ * @attr {boolean|string} disabled - Disables upload, replacement, and removal.
301
+ * @attr {boolean|string} full - Makes the input fill its container.
302
+ * @fires input - Composed file input event.
303
+ * @fires change - Composed file change event.
304
+ */
305
+ class FigInputAudio extends HTMLElement {
306
+ static observedAttributes = [
307
+ "url",
308
+ "filename",
309
+ "label",
310
+ "accepts",
311
+ "variant",
312
+ "disabled",
313
+ "full",
314
+ ];
315
+
316
+ #fileInput = null;
317
+ #surface = null;
318
+ #svg = null;
319
+ #waveformPath = null;
320
+ #resizeObserver = null;
321
+ #abortController = null;
322
+ #audioContext = null;
323
+ #peaks = [];
324
+ #generation = 0;
325
+ #layoutFrame = 0;
326
+
327
+ get files() {
328
+ return this.#fileInput?.files ?? null;
329
+ }
330
+
331
+ get value() {
332
+ return this.#fileInput?.value ?? "";
333
+ }
334
+
335
+ connectedCallback() {
336
+ if (!this.#fileInput) this.#render();
337
+ this.#observeWaveform();
338
+ this.#syncAttributes();
339
+ this.#syncSource();
340
+ }
341
+
342
+ disconnectedCallback() {
343
+ this.#cancelDecode();
344
+ this.#resizeObserver?.disconnect();
345
+ this.#resizeObserver = null;
346
+ if (this.#layoutFrame) cancelAnimationFrame(this.#layoutFrame);
347
+ this.#layoutFrame = 0;
348
+ }
349
+
350
+ attributeChangedCallback(name, oldValue, newValue) {
351
+ if (oldValue === newValue || !this.#fileInput) return;
352
+ this.#syncAttributes();
353
+ if (name === "url") this.#syncSource();
354
+ }
355
+
356
+ clear() {
357
+ this.removeAttribute("url");
358
+ this.removeAttribute("filename");
359
+ this.#fileInput?.clear();
360
+ }
361
+
362
+ #render() {
363
+ this.#surface = document.createElement("div");
364
+ this.#surface.className = "fig-input-audio-waveform";
365
+ this.#surface.setAttribute("aria-hidden", "true");
366
+
367
+ this.#svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
368
+ this.#svg.setAttribute("aria-hidden", "true");
369
+ this.#svg.setAttribute("preserveAspectRatio", "none");
370
+ this.#waveformPath = document.createElementNS(
371
+ "http://www.w3.org/2000/svg",
372
+ "path",
373
+ );
374
+ this.#svg.appendChild(this.#waveformPath);
375
+ this.#surface.appendChild(this.#svg);
376
+
377
+ this.#fileInput = document.createElement("fig-input-file");
378
+ this.#fileInput.className = "fig-input-audio-file";
379
+ this.#fileInput.addEventListener("input", this.#onFileEvent);
380
+ this.#fileInput.addEventListener("change", this.#onFileEvent);
381
+
382
+ this.replaceChildren(this.#surface, this.#fileInput);
383
+
384
+ this.#observeWaveform();
385
+ }
386
+
387
+ #observeWaveform() {
388
+ if (this.#resizeObserver || !this.#svg) return;
389
+ this.#resizeObserver = new ResizeObserver(() => this.#queueWaveformLayout());
390
+ this.#resizeObserver.observe(this.#svg);
391
+ }
392
+
393
+ #syncAttributes() {
394
+ const forwarded = ["url", "filename", "variant"];
395
+ for (const name of forwarded) {
396
+ const value = this.getAttribute(name);
397
+ if (value === null) this.#fileInput.removeAttribute(name);
398
+ else this.#fileInput.setAttribute(name, value);
399
+ }
400
+
401
+ this.#fileInput.setAttribute("accepts", this.getAttribute("accepts") || "audio/*");
402
+ this.#fileInput.setAttribute("label", this.getAttribute("label") || "Upload audio");
403
+ this.#toggleBooleanAttribute(this.#fileInput, "disabled", this.#isDisabled());
404
+ this.#toggleBooleanAttribute(this.#fileInput, "full", this.#isTruthyAttribute("full"));
405
+ this.#syncInteractiveLabels();
406
+ this.#syncLoadedState();
407
+ }
408
+
409
+ #toggleBooleanAttribute(element, name, enabled) {
410
+ if (enabled) element.setAttribute(name, "");
411
+ else element.removeAttribute(name);
412
+ }
413
+
414
+ #isTruthyAttribute(name) {
415
+ return this.hasAttribute(name) && this.getAttribute(name) !== "false";
416
+ }
417
+
418
+ #isDisabled() {
419
+ return this.#isTruthyAttribute("disabled");
420
+ }
421
+
422
+ #syncLoadedState() {
423
+ const hasAudio =
424
+ (this.#fileInput?.files && this.#fileInput.files.length > 0) ||
425
+ !!this.getAttribute("url");
426
+ this.toggleAttribute("data-has-audio", hasAudio);
427
+ this.#syncInteractiveLabels();
428
+ }
429
+
430
+ #syncInteractiveLabels() {
431
+ const nativeInput = this.#fileInput?.querySelector('input[type="file"]');
432
+ if (nativeInput) {
433
+ nativeInput.setAttribute(
434
+ "aria-label",
435
+ this.hasAttribute("data-has-audio") ? "Replace audio" : "Upload audio",
436
+ );
437
+ }
438
+ const clearButton = this.#fileInput?.querySelector(".fig-input-file-clear");
439
+ clearButton?.setAttribute("aria-label", "Remove audio");
440
+ }
441
+
442
+ #onFileEvent = (event) => {
443
+ event.stopPropagation();
444
+ if (event.target !== this.#fileInput) return;
445
+ const detail = event.detail || { files: this.#fileInput.files };
446
+
447
+ if (event.type === "input") {
448
+ const file = this.#fileInput.files?.[0] || null;
449
+ if (file) {
450
+ const replacedUrl = this.hasAttribute("url");
451
+ if (replacedUrl) this.removeAttribute("url");
452
+ this.#syncLoadedState();
453
+ if (!replacedUrl) this.#decodeSource(() => file.arrayBuffer());
454
+ } else {
455
+ if (detail.cleared) {
456
+ this.removeAttribute("url");
457
+ this.removeAttribute("filename");
458
+ }
459
+ this.#clearWaveform();
460
+ this.#syncLoadedState();
461
+ }
462
+ this.#syncInteractiveLabels();
463
+ }
464
+
465
+ this.dispatchEvent(
466
+ new CustomEvent(event.type, {
467
+ bubbles: true,
468
+ composed: true,
469
+ detail,
470
+ }),
471
+ );
472
+ };
473
+
474
+ #syncSource() {
475
+ const file = this.#fileInput?.files?.[0];
476
+ if (file) {
477
+ this.#syncLoadedState();
478
+ this.#decodeSource(() => file.arrayBuffer());
479
+ return;
480
+ }
481
+
482
+ const url = this.getAttribute("url");
483
+ this.#syncLoadedState();
484
+ if (!url) {
485
+ this.#clearWaveform();
486
+ return;
487
+ }
488
+
489
+ this.#decodeSource(async (signal) => {
490
+ const response = await fetch(url, { signal });
491
+ if (!response.ok) throw new Error(`Audio request failed: ${response.status}`);
492
+ return response.arrayBuffer();
493
+ });
494
+ }
495
+
496
+ async #decodeSource(loadArrayBuffer) {
497
+ this.#cancelDecode();
498
+ const generation = ++this.#generation;
499
+ const controller = new AbortController();
500
+ this.#abortController = controller;
501
+ this.setAttribute("data-waveform-state", "loading");
502
+ this.setAttribute("aria-busy", "true");
503
+ this.#peaks = [];
504
+ this.#queueWaveformLayout();
505
+
506
+ let context = null;
507
+ try {
508
+ const arrayBuffer = await loadArrayBuffer(controller.signal);
509
+ if (generation !== this.#generation || !this.isConnected) return;
510
+
511
+ const AudioContextConstructor =
512
+ window.AudioContext || window.webkitAudioContext;
513
+ if (!AudioContextConstructor) throw new Error("Web Audio is unavailable");
514
+ context = new AudioContextConstructor();
515
+ this.#audioContext = context;
516
+ const audioBuffer = await context.decodeAudioData(arrayBuffer);
517
+ if (generation !== this.#generation || !this.isConnected) return;
518
+
519
+ this.#peaks = this.#extractPeaks(audioBuffer);
520
+ this.setAttribute("data-waveform-state", "ready");
521
+ this.#queueWaveformLayout();
522
+ } catch (error) {
523
+ if (generation !== this.#generation || error?.name === "AbortError") return;
524
+ this.#peaks = new Array(256).fill(0);
525
+ this.setAttribute("data-waveform-state", "unavailable");
526
+ this.#queueWaveformLayout();
527
+ } finally {
528
+ if (context) {
529
+ try {
530
+ await context.close();
531
+ } catch {
532
+ // Closing is best-effort in browsers with suspended audio contexts.
533
+ }
534
+ }
535
+ if (this.#audioContext === context) this.#audioContext = null;
536
+ if (this.#abortController === controller) this.#abortController = null;
537
+ if (generation === this.#generation) this.removeAttribute("aria-busy");
538
+ }
539
+ }
540
+
541
+ #extractPeaks(audioBuffer) {
542
+ const binCount = 256;
543
+ const peaks = new Array(binCount).fill(0);
544
+ const channelCount = audioBuffer.numberOfChannels;
545
+ const frameCount = audioBuffer.length;
546
+ const sampleStride = Math.max(1, Math.ceil(frameCount / 500000));
547
+ let maximum = 0;
548
+
549
+ for (let channel = 0; channel < channelCount; channel += 1) {
550
+ const samples = audioBuffer.getChannelData(channel);
551
+ for (let bin = 0; bin < binCount; bin += 1) {
552
+ const start = Math.floor((bin / binCount) * frameCount);
553
+ const end = Math.max(start + 1, Math.floor(((bin + 1) / binCount) * frameCount));
554
+ let peak = peaks[bin];
555
+ for (let index = start; index < end; index += sampleStride) {
556
+ const magnitude = Math.abs(samples[index] || 0);
557
+ if (Number.isFinite(magnitude)) peak = Math.max(peak, magnitude);
558
+ }
559
+ peaks[bin] = peak;
560
+ maximum = Math.max(maximum, peak);
561
+ }
562
+ }
563
+
564
+ if (!Number.isFinite(maximum) || maximum <= 0) return peaks.fill(0);
565
+ return peaks.map((peak) => {
566
+ const normalized = Math.sqrt(peak / maximum);
567
+ return Number.isFinite(normalized) ? normalized : 0;
568
+ });
569
+ }
570
+
571
+ #clearWaveform() {
572
+ this.#cancelDecode();
573
+ this.#peaks = [];
574
+ this.removeAttribute("data-waveform-state");
575
+ this.removeAttribute("aria-busy");
576
+ this.#queueWaveformLayout();
577
+ }
578
+
579
+ #cancelDecode() {
580
+ this.#generation += 1;
581
+ this.#abortController?.abort();
582
+ this.#abortController = null;
583
+ if (this.#audioContext) {
584
+ try {
585
+ this.#audioContext.close();
586
+ } catch {
587
+ // Closing is best-effort during teardown and source replacement.
588
+ }
589
+ this.#audioContext = null;
590
+ }
591
+ }
592
+
593
+ #queueWaveformLayout() {
594
+ if (this.#layoutFrame) return;
595
+ this.#layoutFrame = requestAnimationFrame(() => {
596
+ this.#layoutFrame = 0;
597
+ this.#layoutWaveform();
598
+ });
599
+ }
600
+
601
+ #layoutWaveform() {
602
+ if (!this.#svg || !this.#waveformPath) return;
603
+ const bounds = this.#svg.getBoundingClientRect();
604
+ const width = Math.max(1, Math.round(bounds.width));
605
+ const height = Math.max(1, Math.round(bounds.height));
606
+ this.#svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
607
+
608
+ if (this.#peaks.length === 0) {
609
+ this.#waveformPath.setAttribute("d", "");
610
+ return;
611
+ }
612
+
613
+ const barCount = Math.min(
614
+ this.#peaks.length,
615
+ Math.max(12, Math.floor(width / 4)),
616
+ );
617
+ const cellWidth = width / barCount;
618
+ const configuredStrokeWidth = Number.parseFloat(
619
+ getComputedStyle(this).getPropertyValue(
620
+ "--fig-input-audio-waveform-stroke-width",
621
+ ),
622
+ );
623
+ const strokeWidth =
624
+ Number.isFinite(configuredStrokeWidth) && configuredStrokeWidth > 0
625
+ ? configuredStrokeWidth
626
+ : 2;
627
+ const barWidth = Math.max(0.5, Math.min(strokeWidth, cellWidth * 0.75));
628
+ const halfWidth = barWidth / 2;
629
+ const centerY = height / 2;
630
+ const maxBarHeight = Math.max(2, height - 12);
631
+ const commands = [];
632
+
633
+ for (let index = 0; index < barCount; index += 1) {
634
+ const peakIndex = Math.min(
635
+ this.#peaks.length - 1,
636
+ Math.floor((index / barCount) * this.#peaks.length),
637
+ );
638
+ const barHeight = Math.max(barWidth, this.#peaks[peakIndex] * maxBarHeight);
639
+ const x = (index + 0.5) * cellWidth;
640
+ const top = centerY - barHeight / 2;
641
+ const bottom = centerY + barHeight / 2;
642
+ const left = x - halfWidth;
643
+ const right = x + halfWidth;
644
+ commands.push(
645
+ `M ${x} ${top}` +
646
+ ` Q ${right} ${top} ${right} ${top + halfWidth}` +
647
+ ` V ${bottom - halfWidth}` +
648
+ ` Q ${right} ${bottom} ${x} ${bottom}` +
649
+ ` Q ${left} ${bottom} ${left} ${bottom - halfWidth}` +
650
+ ` V ${top + halfWidth}` +
651
+ ` Q ${left} ${top} ${x} ${top} Z`,
652
+ );
653
+ }
654
+
655
+ this.#waveformPath.setAttribute("d", commands.join(" "));
656
+ }
657
+ }
658
+ figLabDefineElement("fig-input-audio", FigInputAudio);
659
+
291
660
  /**
292
661
  * Standalone interactive numeric wheel.
293
662
  *
@@ -2535,7 +2904,7 @@ class FigCanvasControl extends HTMLElement {
2535
2904
  figLabDefineElement("fig-canvas-control", FigCanvasControl);
2536
2905
  /* Reorder wrapper */
2537
2906
  class FigReorder extends HTMLElement {
2538
- static observedAttributes = ["axis", "handle", "disabled"];
2907
+ static observedAttributes = ["axis", "handle", "items", "disabled"];
2539
2908
 
2540
2909
  static #DRAG_THRESHOLD = 6;
2541
2910
 
@@ -2550,6 +2919,7 @@ class FigReorder extends HTMLElement {
2550
2919
  "fig-input-gradient",
2551
2920
  "fig-easing-curve",
2552
2921
  "fig-angle",
2922
+ "fig-input-audio",
2553
2923
  "fig-input-wheel",
2554
2924
  "fig-joystick",
2555
2925
  "fig-origin-grid",
@@ -2557,7 +2927,19 @@ class FigReorder extends HTMLElement {
2557
2927
  "fig-canvas-control",
2558
2928
  ];
2559
2929
 
2930
+ static #OWNED_ATTRIBUTES = new Set([
2931
+ "data-reorder-item",
2932
+ "data-reorder-generated-role",
2933
+ "data-reorder-handle",
2934
+ "role",
2935
+ "tabindex",
2936
+ "aria-label",
2937
+ "aria-roledescription",
2938
+ ]);
2939
+
2560
2940
  #childObserver = null;
2941
+ #attributeObserver = null;
2942
+ #attributeSyncQueued = false;
2561
2943
  #bindings = new Map();
2562
2944
  #drag = null;
2563
2945
  #indicator = null;
@@ -2573,11 +2955,14 @@ class FigReorder extends HTMLElement {
2573
2955
  this.#syncChildren();
2574
2956
  this.#childObserver = new MutationObserver(() => this.#syncChildren());
2575
2957
  this.#childObserver.observe(this, { childList: true });
2958
+ this.#syncAttributeObserver();
2576
2959
  }
2577
2960
 
2578
2961
  disconnectedCallback() {
2579
2962
  this.#childObserver?.disconnect();
2580
2963
  this.#childObserver = null;
2964
+ this.#attributeObserver?.disconnect();
2965
+ this.#attributeObserver = null;
2581
2966
  this.#unbindAll();
2582
2967
  this.#cancelDrag();
2583
2968
  this.#removeIndicator();
@@ -2588,10 +2973,39 @@ class FigReorder extends HTMLElement {
2588
2973
  attributeChangedCallback() {
2589
2974
  if (this.isConnected) {
2590
2975
  if (this.#disabled) this.#cancelDrag();
2976
+ this.#syncAttributeObserver();
2591
2977
  this.#syncChildren();
2592
2978
  }
2593
2979
  }
2594
2980
 
2981
+ refresh() {
2982
+ if (this.isConnected) this.#syncChildren();
2983
+ }
2984
+
2985
+ #syncAttributeObserver() {
2986
+ if (!this.#itemsSelector) {
2987
+ this.#attributeObserver?.disconnect();
2988
+ this.#attributeObserver = null;
2989
+ return;
2990
+ }
2991
+ if (this.#attributeObserver) return;
2992
+ this.#attributeObserver = new MutationObserver((records) => {
2993
+ const relevant = records.some(
2994
+ (record) =>
2995
+ record.target.parentElement === this &&
2996
+ !FigReorder.#OWNED_ATTRIBUTES.has(record.attributeName) &&
2997
+ !(record.attributeName === "class" && record.target === this.#drag?.item),
2998
+ );
2999
+ if (!relevant || this.#attributeSyncQueued) return;
3000
+ this.#attributeSyncQueued = true;
3001
+ queueMicrotask(() => {
3002
+ this.#attributeSyncQueued = false;
3003
+ if (this.isConnected) this.#syncChildren();
3004
+ });
3005
+ });
3006
+ this.#attributeObserver.observe(this, { subtree: true, attributes: true });
3007
+ }
3008
+
2595
3009
  get #disabled() {
2596
3010
  return (
2597
3011
  this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false"
@@ -2607,7 +3021,11 @@ class FigReorder extends HTMLElement {
2607
3021
  return (this.getAttribute("handle") || "").trim();
2608
3022
  }
2609
3023
 
2610
- #getElementChildren() {
3024
+ get #itemsSelector() {
3025
+ return (this.getAttribute("items") || "").trim();
3026
+ }
3027
+
3028
+ #getAllElementChildren() {
2611
3029
  return [...this.children].filter(
2612
3030
  (node) =>
2613
3031
  node.nodeType === Node.ELEMENT_NODE &&
@@ -2615,7 +3033,19 @@ class FigReorder extends HTMLElement {
2615
3033
  );
2616
3034
  }
2617
3035
 
3036
+ #getElementChildren() {
3037
+ const children = this.#getAllElementChildren();
3038
+ const selector = this.#itemsSelector;
3039
+ if (!selector) return children;
3040
+ try {
3041
+ return children.filter((child) => child.matches(selector));
3042
+ } catch {
3043
+ return [];
3044
+ }
3045
+ }
3046
+
2618
3047
  #syncChildren() {
3048
+ const allChildren = this.#getAllElementChildren();
2619
3049
  const children = this.#getElementChildren();
2620
3050
  const childSet = new Set(children);
2621
3051
 
@@ -2632,8 +3062,9 @@ class FigReorder extends HTMLElement {
2632
3062
  }
2633
3063
  }
2634
3064
 
2635
- this.#clearHandleMarks(children);
2636
- this.#clearReorderItemMarks(children);
3065
+ this.#clearHandleMarks(allChildren);
3066
+ this.#clearReorderItemMarks(allChildren);
3067
+ this.#markNonItems(allChildren.filter((child) => !childSet.has(child)));
2637
3068
 
2638
3069
  if (this.#disabled || children.length < 2) {
2639
3070
  this.#unbindAll();
@@ -2680,6 +3111,15 @@ class FigReorder extends HTMLElement {
2680
3111
  }
2681
3112
  }
2682
3113
 
3114
+ #markNonItems(children) {
3115
+ for (const child of children) {
3116
+ if (!child.hasAttribute("role")) {
3117
+ child.setAttribute("role", "none");
3118
+ child.setAttribute("data-reorder-generated-role", "");
3119
+ }
3120
+ }
3121
+ }
3122
+
2683
3123
  #clearHandleMarks(children) {
2684
3124
  for (const child of children) {
2685
3125
  child
@@ -3031,7 +3471,8 @@ class FigReorder extends HTMLElement {
3031
3471
  const clamped = Math.max(0, Math.min(index, items.length));
3032
3472
 
3033
3473
  if (clamped >= items.length) {
3034
- if (items[items.length - 1] !== item) this.appendChild(item);
3474
+ const last = items[items.length - 1];
3475
+ if (last && last !== item) last.after(item);
3035
3476
  return;
3036
3477
  }
3037
3478
 
@@ -3043,6 +3484,7 @@ class FigReorder extends HTMLElement {
3043
3484
  const items = this.#getElementChildren().filter((candidate) => candidate !== item);
3044
3485
  const ref = items[newIndex] ?? null;
3045
3486
  if (ref) this.insertBefore(item, ref);
3487
+ else if (items.length) items[items.length - 1].after(item);
3046
3488
  else this.appendChild(item);
3047
3489
  }
3048
3490