@rogieking/figui3 3.3.0 → 3.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.js CHANGED
@@ -10402,3 +10402,554 @@ class FigFillPicker extends HTMLElement {
10402
10402
  }
10403
10403
  }
10404
10404
  customElements.define("fig-fill-picker", FigFillPicker);
10405
+
10406
+ /* Choice */
10407
+ /**
10408
+ * A generic choice container for use within FigChooser.
10409
+ * @attr {string} value - Identifier for this choice
10410
+ * @attr {boolean} selected - Whether this choice is currently selected
10411
+ * @attr {boolean} disabled - Whether this choice is disabled
10412
+ */
10413
+ class FigChoice extends HTMLElement {
10414
+ static get observedAttributes() {
10415
+ return ["selected", "disabled"];
10416
+ }
10417
+
10418
+ connectedCallback() {
10419
+ this.setAttribute("role", "option");
10420
+ if (!this.hasAttribute("tabindex")) {
10421
+ this.setAttribute("tabindex", "0");
10422
+ }
10423
+ this.setAttribute(
10424
+ "aria-selected",
10425
+ this.hasAttribute("selected") ? "true" : "false",
10426
+ );
10427
+ if (this.hasAttribute("disabled")) {
10428
+ this.setAttribute("aria-disabled", "true");
10429
+ }
10430
+ }
10431
+
10432
+ attributeChangedCallback(name) {
10433
+ if (name === "selected") {
10434
+ this.setAttribute(
10435
+ "aria-selected",
10436
+ this.hasAttribute("selected") ? "true" : "false",
10437
+ );
10438
+ }
10439
+ if (name === "disabled") {
10440
+ const isDisabled =
10441
+ this.hasAttribute("disabled") &&
10442
+ this.getAttribute("disabled") !== "false";
10443
+ if (isDisabled) {
10444
+ this.setAttribute("aria-disabled", "true");
10445
+ this.setAttribute("tabindex", "-1");
10446
+ } else {
10447
+ this.removeAttribute("aria-disabled");
10448
+ this.setAttribute("tabindex", "0");
10449
+ }
10450
+ }
10451
+ }
10452
+ }
10453
+ customElements.define("fig-choice", FigChoice);
10454
+
10455
+ /* Chooser */
10456
+ /**
10457
+ * A selection controller for a list of choice elements.
10458
+ * @attr {string} choice-element - CSS selector for child choices (default: "fig-choice")
10459
+ * @attr {string} layout - Layout mode: "vertical" (default), "horizontal", "grid"
10460
+ * @attr {string} value - Value of the currently selected choice
10461
+ * @attr {boolean} disabled - Whether the chooser is disabled
10462
+ */
10463
+ class FigChooser extends HTMLElement {
10464
+ #selectedChoice = null;
10465
+ #boundHandleClick = this.#handleClick.bind(this);
10466
+ #boundHandleKeyDown = this.#handleKeyDown.bind(this);
10467
+ #boundSyncOverflow = this.#syncOverflow.bind(this);
10468
+ #mutationObserver = null;
10469
+ #resizeObserver = null;
10470
+ #navStart = null;
10471
+ #navEnd = null;
10472
+ #dragState = null;
10473
+
10474
+ constructor() {
10475
+ super();
10476
+ }
10477
+
10478
+ static get observedAttributes() {
10479
+ return ["value", "disabled", "choice-element", "drag", "overflow", "loop"];
10480
+ }
10481
+
10482
+ get #overflowMode() {
10483
+ return this.getAttribute("overflow") === "scrollbar" ? "scrollbar" : "buttons";
10484
+ }
10485
+
10486
+ get #dragEnabled() {
10487
+ const attr = this.getAttribute("drag");
10488
+ return attr === null || attr !== "false";
10489
+ }
10490
+
10491
+ get #choiceSelector() {
10492
+ return this.getAttribute("choice-element") || "fig-choice";
10493
+ }
10494
+
10495
+ get choices() {
10496
+ return Array.from(this.querySelectorAll(this.#choiceSelector));
10497
+ }
10498
+
10499
+ get selectedChoice() {
10500
+ return this.#selectedChoice;
10501
+ }
10502
+
10503
+ set selectedChoice(element) {
10504
+ if (element && !this.contains(element)) return;
10505
+ const choices = this.choices;
10506
+ for (const choice of choices) {
10507
+ const shouldSelect = choice === element;
10508
+ const isSelected = choice.hasAttribute("selected");
10509
+ if (shouldSelect && !isSelected) {
10510
+ choice.setAttribute("selected", "");
10511
+ } else if (!shouldSelect && isSelected) {
10512
+ choice.removeAttribute("selected");
10513
+ }
10514
+ }
10515
+ this.#selectedChoice = element;
10516
+ const val = element?.getAttribute("value") ?? "";
10517
+ if (this.getAttribute("value") !== val) {
10518
+ if (val) {
10519
+ this.setAttribute("value", val);
10520
+ }
10521
+ }
10522
+ this.#scrollToChoice(element);
10523
+ }
10524
+
10525
+ get value() {
10526
+ return this.#selectedChoice?.getAttribute("value") ?? "";
10527
+ }
10528
+
10529
+ set value(val) {
10530
+ if (val === null || val === undefined || val === "") return;
10531
+ this.setAttribute("value", String(val));
10532
+ }
10533
+
10534
+ connectedCallback() {
10535
+ this.setAttribute("role", "listbox");
10536
+ this.addEventListener("click", this.#boundHandleClick);
10537
+ this.addEventListener("keydown", this.#boundHandleKeyDown);
10538
+ this.addEventListener("scroll", this.#boundSyncOverflow);
10539
+ this.#applyOverflowMode();
10540
+ this.#setupDrag();
10541
+ this.#startObserver();
10542
+ this.#startResizeObserver();
10543
+
10544
+ requestAnimationFrame(() => {
10545
+ this.#syncSelection();
10546
+ this.#syncOverflow();
10547
+ });
10548
+ }
10549
+
10550
+ disconnectedCallback() {
10551
+ this.removeEventListener("click", this.#boundHandleClick);
10552
+ this.removeEventListener("keydown", this.#boundHandleKeyDown);
10553
+ this.removeEventListener("scroll", this.#boundSyncOverflow);
10554
+ this.#teardownDrag();
10555
+ this.#mutationObserver?.disconnect();
10556
+ this.#mutationObserver = null;
10557
+ this.#resizeObserver?.disconnect();
10558
+ this.#resizeObserver = null;
10559
+ this.#removeNavButtons();
10560
+ }
10561
+
10562
+ attributeChangedCallback(name, oldValue, newValue) {
10563
+ if (name === "value" && newValue !== oldValue && newValue) {
10564
+ this.#selectByValue(newValue);
10565
+ }
10566
+ if (name === "disabled") {
10567
+ const isDisabled = newValue !== null && newValue !== "false";
10568
+ const choices = this.choices;
10569
+ for (const choice of choices) {
10570
+ if (isDisabled) {
10571
+ choice.setAttribute("aria-disabled", "true");
10572
+ choice.setAttribute("tabindex", "-1");
10573
+ } else {
10574
+ choice.removeAttribute("aria-disabled");
10575
+ choice.setAttribute("tabindex", "0");
10576
+ }
10577
+ }
10578
+ }
10579
+ if (name === "choice-element") {
10580
+ requestAnimationFrame(() => this.#syncSelection());
10581
+ }
10582
+ if (name === "drag") {
10583
+ if (this.#dragEnabled) {
10584
+ this.#setupDrag();
10585
+ } else {
10586
+ this.#teardownDrag();
10587
+ }
10588
+ }
10589
+ if (name === "overflow") {
10590
+ this.#applyOverflowMode();
10591
+ }
10592
+ }
10593
+
10594
+ #syncSelection() {
10595
+ const choices = this.choices;
10596
+ if (!choices.length) {
10597
+ this.#selectedChoice = null;
10598
+ return;
10599
+ }
10600
+
10601
+ const valueAttr = this.getAttribute("value");
10602
+ if (valueAttr && this.#selectByValue(valueAttr)) return;
10603
+
10604
+ const alreadySelected = choices.find((c) => c.hasAttribute("selected"));
10605
+ if (alreadySelected) {
10606
+ this.selectedChoice = alreadySelected;
10607
+ return;
10608
+ }
10609
+
10610
+ this.selectedChoice = choices[0];
10611
+ }
10612
+
10613
+ #selectByValue(value) {
10614
+ const choices = this.choices;
10615
+ for (const choice of choices) {
10616
+ if (choice.getAttribute("value") === value) {
10617
+ this.selectedChoice = choice;
10618
+ return true;
10619
+ }
10620
+ }
10621
+ return false;
10622
+ }
10623
+
10624
+ #findChoiceFromTarget(target) {
10625
+ const selector = this.#choiceSelector;
10626
+ let el = target;
10627
+ while (el && el !== this) {
10628
+ if (el.matches(selector)) return el;
10629
+ el = el.parentElement;
10630
+ }
10631
+ return null;
10632
+ }
10633
+
10634
+ #handleClick(event) {
10635
+ if (
10636
+ this.hasAttribute("disabled") &&
10637
+ this.getAttribute("disabled") !== "false"
10638
+ )
10639
+ return;
10640
+ const choice = this.#findChoiceFromTarget(event.target);
10641
+ if (!choice) return;
10642
+ if (
10643
+ choice.hasAttribute("disabled") &&
10644
+ choice.getAttribute("disabled") !== "false"
10645
+ )
10646
+ return;
10647
+ this.selectedChoice = choice;
10648
+ this.#emitEvents();
10649
+ }
10650
+
10651
+ #handleKeyDown(event) {
10652
+ if (
10653
+ this.hasAttribute("disabled") &&
10654
+ this.getAttribute("disabled") !== "false"
10655
+ )
10656
+ return;
10657
+ const choices = this.choices.filter(
10658
+ (c) =>
10659
+ !c.hasAttribute("disabled") || c.getAttribute("disabled") === "false",
10660
+ );
10661
+ if (!choices.length) return;
10662
+ const currentIndex = choices.indexOf(this.#selectedChoice);
10663
+ let nextIndex = currentIndex;
10664
+
10665
+ const loop = this.hasAttribute("loop");
10666
+
10667
+ switch (event.key) {
10668
+ case "ArrowDown":
10669
+ case "ArrowRight":
10670
+ event.preventDefault();
10671
+ if (currentIndex < choices.length - 1) {
10672
+ nextIndex = currentIndex + 1;
10673
+ } else if (loop) {
10674
+ nextIndex = 0;
10675
+ }
10676
+ break;
10677
+ case "ArrowUp":
10678
+ case "ArrowLeft":
10679
+ event.preventDefault();
10680
+ if (currentIndex > 0) {
10681
+ nextIndex = currentIndex - 1;
10682
+ } else if (loop) {
10683
+ nextIndex = choices.length - 1;
10684
+ }
10685
+ break;
10686
+ case "Home":
10687
+ event.preventDefault();
10688
+ nextIndex = 0;
10689
+ break;
10690
+ case "End":
10691
+ event.preventDefault();
10692
+ nextIndex = choices.length - 1;
10693
+ break;
10694
+ case "Enter":
10695
+ case " ":
10696
+ event.preventDefault();
10697
+ if (document.activeElement?.matches(this.#choiceSelector)) {
10698
+ const focused = this.#findChoiceFromTarget(document.activeElement);
10699
+ if (focused && focused !== this.#selectedChoice) {
10700
+ this.selectedChoice = focused;
10701
+ this.#emitEvents();
10702
+ }
10703
+ }
10704
+ return;
10705
+ default:
10706
+ return;
10707
+ }
10708
+
10709
+ if (nextIndex !== currentIndex && choices[nextIndex]) {
10710
+ this.selectedChoice = choices[nextIndex];
10711
+ choices[nextIndex].focus();
10712
+ this.#emitEvents();
10713
+ }
10714
+ }
10715
+
10716
+ #emitEvents() {
10717
+ const val = this.value;
10718
+ this.dispatchEvent(
10719
+ new CustomEvent("input", { detail: val, bubbles: true }),
10720
+ );
10721
+ this.dispatchEvent(
10722
+ new CustomEvent("change", { detail: val, bubbles: true }),
10723
+ );
10724
+ }
10725
+
10726
+ #syncOverflow() {
10727
+ if (this.#overflowMode === "scrollbar") return;
10728
+ const isHorizontal = this.getAttribute("layout") === "horizontal";
10729
+ const threshold = 2;
10730
+
10731
+ if (isHorizontal) {
10732
+ const atStart = this.scrollLeft <= threshold;
10733
+ const atEnd = this.scrollLeft + this.clientWidth >= this.scrollWidth - threshold;
10734
+ this.classList.toggle("overflow-start", !atStart);
10735
+ this.classList.toggle("overflow-end", !atEnd);
10736
+ } else {
10737
+ const atStart = this.scrollTop <= threshold;
10738
+ const atEnd = this.scrollTop + this.clientHeight >= this.scrollHeight - threshold;
10739
+ this.classList.toggle("overflow-start", !atStart);
10740
+ this.classList.toggle("overflow-end", !atEnd);
10741
+ }
10742
+ }
10743
+
10744
+ #startResizeObserver() {
10745
+ this.#resizeObserver?.disconnect();
10746
+ this.#resizeObserver = new ResizeObserver(() => {
10747
+ this.#syncOverflow();
10748
+ });
10749
+ this.#resizeObserver.observe(this);
10750
+ }
10751
+
10752
+ #setupDrag() {
10753
+ if (this.#dragState?.bound) return;
10754
+ if (!this.#dragEnabled) return;
10755
+
10756
+ const onPointerDown = (e) => {
10757
+ if (e.button !== 0) return;
10758
+ const isHorizontal = this.getAttribute("layout") === "horizontal";
10759
+ const hasOverflow = isHorizontal
10760
+ ? this.scrollWidth > this.clientWidth
10761
+ : this.scrollHeight > this.clientHeight;
10762
+ if (!hasOverflow) return;
10763
+
10764
+ this.#dragState.active = true;
10765
+ this.#dragState.didDrag = false;
10766
+ this.#dragState.startX = e.clientX;
10767
+ this.#dragState.startY = e.clientY;
10768
+ this.#dragState.scrollLeft = this.scrollLeft;
10769
+ this.#dragState.scrollTop = this.scrollTop;
10770
+ this.style.cursor = "grab";
10771
+ this.style.userSelect = "none";
10772
+ };
10773
+
10774
+ const onPointerMove = (e) => {
10775
+ if (!this.#dragState.active) return;
10776
+ const isHorizontal = this.getAttribute("layout") === "horizontal";
10777
+ const dx = e.clientX - this.#dragState.startX;
10778
+ const dy = e.clientY - this.#dragState.startY;
10779
+
10780
+ if (!this.#dragState.didDrag && Math.abs(isHorizontal ? dx : dy) > 3) {
10781
+ this.#dragState.didDrag = true;
10782
+ this.style.cursor = "grabbing";
10783
+ this.setPointerCapture(e.pointerId);
10784
+ }
10785
+
10786
+ if (!this.#dragState.didDrag) return;
10787
+
10788
+ if (isHorizontal) {
10789
+ this.scrollLeft = this.#dragState.scrollLeft - dx;
10790
+ } else {
10791
+ this.scrollTop = this.#dragState.scrollTop - dy;
10792
+ }
10793
+ };
10794
+
10795
+ const onPointerUp = (e) => {
10796
+ if (!this.#dragState.active) return;
10797
+ const wasDrag = this.#dragState.didDrag;
10798
+ this.#dragState.active = false;
10799
+ this.#dragState.didDrag = false;
10800
+ this.style.cursor = "";
10801
+ this.style.userSelect = "";
10802
+ if (e.pointerId !== undefined) {
10803
+ try { this.releasePointerCapture(e.pointerId); } catch {}
10804
+ }
10805
+ if (wasDrag) {
10806
+ e.preventDefault();
10807
+ e.stopPropagation();
10808
+ }
10809
+ };
10810
+
10811
+ const onClick = (e) => {
10812
+ if (this.#dragState?.suppressClick) {
10813
+ e.stopPropagation();
10814
+ e.preventDefault();
10815
+ this.#dragState.suppressClick = false;
10816
+ }
10817
+ };
10818
+
10819
+ const onPointerUpCapture = (e) => {
10820
+ if (this.#dragState?.didDrag) {
10821
+ this.#dragState.suppressClick = true;
10822
+ setTimeout(() => {
10823
+ if (this.#dragState) this.#dragState.suppressClick = false;
10824
+ }, 0);
10825
+ }
10826
+ };
10827
+
10828
+ this.#dragState = {
10829
+ active: false,
10830
+ didDrag: false,
10831
+ suppressClick: false,
10832
+ startX: 0,
10833
+ startY: 0,
10834
+ scrollLeft: 0,
10835
+ scrollTop: 0,
10836
+ bound: true,
10837
+ onPointerDown,
10838
+ onPointerMove,
10839
+ onPointerUp,
10840
+ onClick,
10841
+ onPointerUpCapture,
10842
+ };
10843
+
10844
+ this.addEventListener("pointerdown", onPointerDown);
10845
+ window.addEventListener("pointermove", onPointerMove);
10846
+ window.addEventListener("pointerup", onPointerUp);
10847
+ this.addEventListener("pointerup", onPointerUpCapture, true);
10848
+ this.addEventListener("click", onClick, true);
10849
+ }
10850
+
10851
+ #teardownDrag() {
10852
+ if (!this.#dragState?.bound) return;
10853
+ this.removeEventListener("pointerdown", this.#dragState.onPointerDown);
10854
+ window.removeEventListener("pointermove", this.#dragState.onPointerMove);
10855
+ window.removeEventListener("pointerup", this.#dragState.onPointerUp);
10856
+ this.removeEventListener("pointerup", this.#dragState.onPointerUpCapture, true);
10857
+ this.removeEventListener("click", this.#dragState.onClick, true);
10858
+ this.style.cursor = "";
10859
+ this.style.userSelect = "";
10860
+ this.#dragState = null;
10861
+ }
10862
+
10863
+ #applyOverflowMode() {
10864
+ if (this.#overflowMode === "scrollbar") {
10865
+ this.#removeNavButtons();
10866
+ } else {
10867
+ this.#createNavButtons();
10868
+ }
10869
+ }
10870
+
10871
+ #removeNavButtons() {
10872
+ this.#navStart?.remove();
10873
+ this.#navEnd?.remove();
10874
+ this.#navStart = null;
10875
+ this.#navEnd = null;
10876
+ this.classList.remove("overflow-start", "overflow-end");
10877
+ }
10878
+
10879
+ #createNavButtons() {
10880
+ if (this.#navStart) return;
10881
+
10882
+ this.#navStart = document.createElement("button");
10883
+ this.#navStart.className = "fig-chooser-nav-start";
10884
+ this.#navStart.setAttribute("tabindex", "-1");
10885
+ this.#navStart.setAttribute("aria-label", "Scroll back");
10886
+
10887
+ this.#navEnd = document.createElement("button");
10888
+ this.#navEnd.className = "fig-chooser-nav-end";
10889
+ this.#navEnd.setAttribute("tabindex", "-1");
10890
+ this.#navEnd.setAttribute("aria-label", "Scroll forward");
10891
+
10892
+ this.#navStart.addEventListener("pointerdown", (e) => {
10893
+ e.stopPropagation();
10894
+ this.#scrollByPage(-1);
10895
+ });
10896
+
10897
+ this.#navEnd.addEventListener("pointerdown", (e) => {
10898
+ e.stopPropagation();
10899
+ this.#scrollByPage(1);
10900
+ });
10901
+
10902
+ this.prepend(this.#navStart);
10903
+ this.append(this.#navEnd);
10904
+ }
10905
+
10906
+ #scrollByPage(direction) {
10907
+ const isHorizontal =
10908
+ this.getAttribute("layout") === "horizontal";
10909
+ const pageSize = isHorizontal ? this.clientWidth : this.clientHeight;
10910
+ const scrollAmount = pageSize * 0.8 * direction;
10911
+
10912
+ this.scrollBy({
10913
+ [isHorizontal ? "left" : "top"]: scrollAmount,
10914
+ behavior: "smooth",
10915
+ });
10916
+ }
10917
+
10918
+ #scrollToChoice(el) {
10919
+ if (!el) return;
10920
+ requestAnimationFrame(() => {
10921
+ const overflowY = this.scrollHeight > this.clientHeight;
10922
+ const overflowX = this.scrollWidth > this.clientWidth;
10923
+ if (!overflowX && !overflowY) return;
10924
+
10925
+ const options = { behavior: "smooth" };
10926
+
10927
+ if (overflowY) {
10928
+ const target = el.offsetTop - this.clientHeight / 2 + el.offsetHeight / 2;
10929
+ options.top = target;
10930
+ }
10931
+
10932
+ if (overflowX) {
10933
+ const target = el.offsetLeft - this.clientWidth / 2 + el.offsetWidth / 2;
10934
+ options.left = target;
10935
+ }
10936
+
10937
+ this.scrollTo(options);
10938
+ });
10939
+ }
10940
+
10941
+ #startObserver() {
10942
+ this.#mutationObserver?.disconnect();
10943
+ this.#mutationObserver = new MutationObserver(() => {
10944
+ const choices = this.choices;
10945
+ if (this.#selectedChoice && !choices.includes(this.#selectedChoice)) {
10946
+ this.#selectedChoice = null;
10947
+ this.#syncSelection();
10948
+ } else if (!this.#selectedChoice && choices.length) {
10949
+ this.#syncSelection();
10950
+ }
10951
+ });
10952
+ this.#mutationObserver.observe(this, { childList: true, subtree: true });
10953
+ }
10954
+ }
10955
+ customElements.define("fig-chooser", FigChooser);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "MIT",