@rogieking/figui3 6.16.0 → 6.18.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/README.md +8 -8
- package/components.css +45 -2
- package/dist/components.css +1 -1
- package/dist/fig-editor.css +1 -1
- package/dist/fig-lab.css +1 -1
- package/dist/fig-lab.js +91 -13
- package/dist/fig.css +1 -1
- package/dist/fig.js +22 -22
- package/fig-lab.css +280 -8
- package/fig-lab.js +1077 -26
- package/fig.js +65 -8
- package/package.json +1 -1
package/fig-lab.js
CHANGED
|
@@ -455,7 +455,7 @@ class PropskitSelect extends HTMLElement {
|
|
|
455
455
|
#boundHandleClick = this.#handleClick.bind(this);
|
|
456
456
|
|
|
457
457
|
static get observedAttributes() {
|
|
458
|
-
return ["label", "direction", "aria-label"];
|
|
458
|
+
return ["label", "direction", "aria-label", "options", "value"];
|
|
459
459
|
}
|
|
460
460
|
|
|
461
461
|
connectedCallback() {
|
|
@@ -473,10 +473,11 @@ class PropskitSelect extends HTMLElement {
|
|
|
473
473
|
|
|
474
474
|
for (const mutation of mutations) {
|
|
475
475
|
if (mutation.type !== "attributes") continue;
|
|
476
|
+
const name = mutation.attributeName;
|
|
476
477
|
if (
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
478
|
+
name === "label" ||
|
|
479
|
+
name === "direction" ||
|
|
480
|
+
name === "aria-label"
|
|
480
481
|
) {
|
|
481
482
|
syncField = true;
|
|
482
483
|
} else {
|
|
@@ -502,24 +503,18 @@ class PropskitSelect extends HTMLElement {
|
|
|
502
503
|
if (oldValue === newValue || !this.#field) return;
|
|
503
504
|
if (name === "label" || name === "direction" || name === "aria-label") {
|
|
504
505
|
this.#syncField();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (name === "options" || name === "value") {
|
|
509
|
+
this.#syncSelectAttributes();
|
|
505
510
|
}
|
|
506
511
|
}
|
|
507
512
|
|
|
508
513
|
#initialize() {
|
|
509
|
-
const
|
|
510
|
-
(node) =>
|
|
511
|
-
node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
|
|
512
|
-
);
|
|
513
|
-
const customLabel = initialChildren.find(
|
|
514
|
-
(node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
|
|
515
|
-
);
|
|
514
|
+
const customLabel = this.querySelector(":scope > label");
|
|
516
515
|
const field = document.createElement("fig-field");
|
|
517
516
|
const label = customLabel || document.createElement("label");
|
|
518
|
-
const select = document.createElement("fig-
|
|
519
|
-
|
|
520
|
-
for (const node of initialChildren) {
|
|
521
|
-
if (node !== customLabel) select.appendChild(node);
|
|
522
|
-
}
|
|
517
|
+
const select = document.createElement("fig-select");
|
|
523
518
|
field.append(label, select);
|
|
524
519
|
this.#field = field;
|
|
525
520
|
this.#label = label;
|
|
@@ -576,7 +571,14 @@ class PropskitSelect extends HTMLElement {
|
|
|
576
571
|
|
|
577
572
|
#syncSelectAttributes() {
|
|
578
573
|
if (!this.#select) return;
|
|
579
|
-
const selectAttrs = this.#getForwardedSelectAttrNames()
|
|
574
|
+
const selectAttrs = this.#getForwardedSelectAttrNames().sort((a, b) => {
|
|
575
|
+
// Build options before applying value so fig-select can resolve selection.
|
|
576
|
+
if (a === "options") return -1;
|
|
577
|
+
if (b === "options") return 1;
|
|
578
|
+
if (a === "value") return 1;
|
|
579
|
+
if (b === "value") return -1;
|
|
580
|
+
return 0;
|
|
581
|
+
});
|
|
580
582
|
const nextManaged = new Set(selectAttrs);
|
|
581
583
|
|
|
582
584
|
for (const attrName of this.#managedSelectAttrs) {
|
|
@@ -608,6 +610,7 @@ class PropskitSelect extends HTMLElement {
|
|
|
608
610
|
}
|
|
609
611
|
|
|
610
612
|
#forwardSelectEvent(type, event) {
|
|
613
|
+
if (event.target !== this.#select) return;
|
|
611
614
|
event.stopImmediatePropagation();
|
|
612
615
|
const value = this.#select?.value ?? "";
|
|
613
616
|
this.setAttribute("value", String(value));
|
|
@@ -626,17 +629,12 @@ class PropskitSelect extends HTMLElement {
|
|
|
626
629
|
}
|
|
627
630
|
|
|
628
631
|
#handleClick(event) {
|
|
629
|
-
if (event.target instanceof Element && event.target.closest("fig-
|
|
632
|
+
if (event.target instanceof Element && event.target.closest("fig-select")) {
|
|
630
633
|
return;
|
|
631
634
|
}
|
|
632
|
-
|
|
633
|
-
select
|
|
634
|
-
|
|
635
|
-
try {
|
|
636
|
-
select.showPicker();
|
|
637
|
-
} catch {
|
|
638
|
-
// Browser may reject showPicker when no user activation is available.
|
|
639
|
-
}
|
|
635
|
+
this.#select?.focus();
|
|
636
|
+
if (this.#select && !figLabBooleanAttribute(this.#select, "disabled")) {
|
|
637
|
+
this.#select.open = true;
|
|
640
638
|
}
|
|
641
639
|
}
|
|
642
640
|
|
|
@@ -4729,3 +4727,1056 @@ class FigReorder extends HTMLElement {
|
|
|
4729
4727
|
}
|
|
4730
4728
|
|
|
4731
4729
|
customElements.define("fig-reorder", FigReorder);
|
|
4730
|
+
|
|
4731
|
+
/* Select — dropdown-styled trigger + fig-popup listbox */
|
|
4732
|
+
let figLabSelectId = 0;
|
|
4733
|
+
function figLabUniqueId(prefix = "fig-select") {
|
|
4734
|
+
figLabSelectId += 1;
|
|
4735
|
+
return `${prefix}-${figLabSelectId}`;
|
|
4736
|
+
}
|
|
4737
|
+
|
|
4738
|
+
/** Parse options attr — same formats as fig-options / propskit-select. */
|
|
4739
|
+
function figLabParseOptionsAttribute(raw) {
|
|
4740
|
+
const text = raw || "";
|
|
4741
|
+
if (text.startsWith("[")) {
|
|
4742
|
+
try {
|
|
4743
|
+
const parsed = JSON.parse(text);
|
|
4744
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
4745
|
+
} catch {
|
|
4746
|
+
/* fall through */
|
|
4747
|
+
}
|
|
4748
|
+
}
|
|
4749
|
+
const delimiter = text.includes("\n") ? "\n" : ",";
|
|
4750
|
+
return text
|
|
4751
|
+
.split(delimiter)
|
|
4752
|
+
.map((s) => s.trim())
|
|
4753
|
+
.filter(Boolean);
|
|
4754
|
+
}
|
|
4755
|
+
|
|
4756
|
+
function figLabOptionEntryValue(opt) {
|
|
4757
|
+
if (opt && typeof opt === "object") {
|
|
4758
|
+
return String(opt.value ?? opt.label ?? "");
|
|
4759
|
+
}
|
|
4760
|
+
return String(opt ?? "");
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4763
|
+
function figLabOptionEntryLabel(opt) {
|
|
4764
|
+
if (opt && typeof opt === "object") {
|
|
4765
|
+
return String(opt.label ?? opt.value ?? "");
|
|
4766
|
+
}
|
|
4767
|
+
return String(opt ?? "");
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
class FigSelectOption extends HTMLElement {
|
|
4771
|
+
static get observedAttributes() {
|
|
4772
|
+
return ["value", "disabled", "selected"];
|
|
4773
|
+
}
|
|
4774
|
+
|
|
4775
|
+
get value() {
|
|
4776
|
+
const attr = this.getAttribute("value");
|
|
4777
|
+
if (attr !== null) return attr;
|
|
4778
|
+
return (this.textContent || "").trim();
|
|
4779
|
+
}
|
|
4780
|
+
|
|
4781
|
+
set value(val) {
|
|
4782
|
+
if (val === null || val === undefined) {
|
|
4783
|
+
this.removeAttribute("value");
|
|
4784
|
+
} else {
|
|
4785
|
+
this.setAttribute("value", String(val));
|
|
4786
|
+
}
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4789
|
+
get disabled() {
|
|
4790
|
+
return figLabBooleanAttribute(this, "disabled");
|
|
4791
|
+
}
|
|
4792
|
+
|
|
4793
|
+
set disabled(val) {
|
|
4794
|
+
if (val) this.setAttribute("disabled", "");
|
|
4795
|
+
else this.removeAttribute("disabled");
|
|
4796
|
+
}
|
|
4797
|
+
|
|
4798
|
+
get selected() {
|
|
4799
|
+
return figLabBooleanAttribute(this, "selected");
|
|
4800
|
+
}
|
|
4801
|
+
|
|
4802
|
+
set selected(val) {
|
|
4803
|
+
if (val) this.setAttribute("selected", "");
|
|
4804
|
+
else this.removeAttribute("selected");
|
|
4805
|
+
}
|
|
4806
|
+
|
|
4807
|
+
connectedCallback() {
|
|
4808
|
+
if (!this.hasAttribute("role")) this.setAttribute("role", "option");
|
|
4809
|
+
if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
|
|
4810
|
+
this.#syncDisabled();
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
4814
|
+
if (oldValue === newValue) return;
|
|
4815
|
+
if (name === "disabled") this.#syncDisabled();
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4818
|
+
#syncDisabled() {
|
|
4819
|
+
const disabled = this.disabled;
|
|
4820
|
+
if (disabled) {
|
|
4821
|
+
this.setAttribute("aria-disabled", "true");
|
|
4822
|
+
this.setAttribute("tabindex", "-1");
|
|
4823
|
+
} else {
|
|
4824
|
+
this.removeAttribute("aria-disabled");
|
|
4825
|
+
if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
|
|
4826
|
+
}
|
|
4827
|
+
}
|
|
4828
|
+
}
|
|
4829
|
+
customElements.define("fig-select-option", FigSelectOption);
|
|
4830
|
+
|
|
4831
|
+
function figLabSyncOverflowState(host, scrollEl, threshold = 2) {
|
|
4832
|
+
if (!host || !scrollEl) return false;
|
|
4833
|
+
const scrollable = scrollEl.scrollHeight - scrollEl.clientHeight > threshold;
|
|
4834
|
+
const atStart = !scrollable || scrollEl.scrollTop <= threshold;
|
|
4835
|
+
const atEnd =
|
|
4836
|
+
!scrollable ||
|
|
4837
|
+
scrollEl.scrollTop + scrollEl.clientHeight >=
|
|
4838
|
+
scrollEl.scrollHeight - threshold;
|
|
4839
|
+
host.classList.toggle("overflow-start", !atStart);
|
|
4840
|
+
host.classList.toggle("overflow-end", !atEnd);
|
|
4841
|
+
return scrollable;
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4844
|
+
function figLabScrollOverflowPage(scrollEl, direction = 1) {
|
|
4845
|
+
if (!scrollEl) return;
|
|
4846
|
+
scrollEl.scrollBy({
|
|
4847
|
+
top: scrollEl.clientHeight * 0.8 * direction,
|
|
4848
|
+
behavior: "smooth",
|
|
4849
|
+
});
|
|
4850
|
+
}
|
|
4851
|
+
|
|
4852
|
+
function figLabCreateOverflowButtons({ onStart, onEnd } = {}) {
|
|
4853
|
+
const makeButton = (direction, onClick) => {
|
|
4854
|
+
const button = document.createElement("button");
|
|
4855
|
+
button.type = "button";
|
|
4856
|
+
button.className = `fig-overflow fig-overflow-${direction}`;
|
|
4857
|
+
button.dataset.figOverflow = direction;
|
|
4858
|
+
button.setAttribute("data-fig-select-nav", direction);
|
|
4859
|
+
button.setAttribute("tabindex", "-1");
|
|
4860
|
+
button.setAttribute(
|
|
4861
|
+
"aria-label",
|
|
4862
|
+
direction === "start" ? "Scroll up" : "Scroll down",
|
|
4863
|
+
);
|
|
4864
|
+
const icon = document.createElement("fig-icon");
|
|
4865
|
+
icon.setAttribute("name", "chevron");
|
|
4866
|
+
icon.setAttribute("size", "small");
|
|
4867
|
+
icon.className = "fig-overflow-chevron";
|
|
4868
|
+
button.appendChild(icon);
|
|
4869
|
+
button.addEventListener("click", (event) => {
|
|
4870
|
+
event.preventDefault();
|
|
4871
|
+
event.stopPropagation();
|
|
4872
|
+
onClick?.(event);
|
|
4873
|
+
});
|
|
4874
|
+
return button;
|
|
4875
|
+
};
|
|
4876
|
+
return {
|
|
4877
|
+
start: makeButton("start", onStart),
|
|
4878
|
+
end: makeButton("end", onEnd),
|
|
4879
|
+
};
|
|
4880
|
+
}
|
|
4881
|
+
|
|
4882
|
+
/** Light-DOM panel wrapper projected into fig-select's popup; owns overflow buttons. */
|
|
4883
|
+
class FigSelectOptions extends HTMLElement {
|
|
4884
|
+
#navStart = null;
|
|
4885
|
+
#navEnd = null;
|
|
4886
|
+
#resizeObserver = null;
|
|
4887
|
+
#boundSyncOverflow = this.syncOverflow.bind(this);
|
|
4888
|
+
|
|
4889
|
+
connectedCallback() {
|
|
4890
|
+
if (!this.hasAttribute("slot")) this.setAttribute("slot", "panel");
|
|
4891
|
+
this.#unwrapLegacyChooser();
|
|
4892
|
+
this.#ensureNavButtons();
|
|
4893
|
+
this.addEventListener("scroll", this.#boundSyncOverflow, { passive: true });
|
|
4894
|
+
this.#resizeObserver?.disconnect();
|
|
4895
|
+
this.#resizeObserver = new ResizeObserver(() => this.syncOverflow());
|
|
4896
|
+
this.#resizeObserver.observe(this);
|
|
4897
|
+
requestAnimationFrame(() => this.syncOverflow());
|
|
4898
|
+
}
|
|
4899
|
+
|
|
4900
|
+
disconnectedCallback() {
|
|
4901
|
+
this.removeEventListener("scroll", this.#boundSyncOverflow);
|
|
4902
|
+
this.#resizeObserver?.disconnect();
|
|
4903
|
+
this.#resizeObserver = null;
|
|
4904
|
+
this.#removeNavButtons();
|
|
4905
|
+
}
|
|
4906
|
+
|
|
4907
|
+
syncOverflow() {
|
|
4908
|
+
return figLabSyncOverflowState(this, this);
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4911
|
+
scrollToOption(option, behavior = "auto") {
|
|
4912
|
+
if (!option || !this.contains(option)) return;
|
|
4913
|
+
requestAnimationFrame(() => {
|
|
4914
|
+
if (!option.isConnected) return;
|
|
4915
|
+
if (this.scrollHeight <= this.clientHeight + 1) {
|
|
4916
|
+
this.syncOverflow();
|
|
4917
|
+
return;
|
|
4918
|
+
}
|
|
4919
|
+
const optionRect = option.getBoundingClientRect();
|
|
4920
|
+
const hostRect = this.getBoundingClientRect();
|
|
4921
|
+
const optionTop = optionRect.top - hostRect.top + this.scrollTop;
|
|
4922
|
+
const maxScroll = this.scrollHeight - this.clientHeight;
|
|
4923
|
+
const top = Math.max(
|
|
4924
|
+
0,
|
|
4925
|
+
Math.min(
|
|
4926
|
+
optionTop + optionRect.height / 2 - this.clientHeight / 2,
|
|
4927
|
+
maxScroll,
|
|
4928
|
+
),
|
|
4929
|
+
);
|
|
4930
|
+
this.scrollTo({ top, behavior });
|
|
4931
|
+
this.syncOverflow();
|
|
4932
|
+
});
|
|
4933
|
+
}
|
|
4934
|
+
|
|
4935
|
+
#unwrapLegacyChooser() {
|
|
4936
|
+
const chooser = this.querySelector(":scope > fig-chooser");
|
|
4937
|
+
if (!chooser) return;
|
|
4938
|
+
while (chooser.firstChild) {
|
|
4939
|
+
this.insertBefore(chooser.firstChild, chooser);
|
|
4940
|
+
}
|
|
4941
|
+
chooser.remove();
|
|
4942
|
+
}
|
|
4943
|
+
|
|
4944
|
+
#ensureNavButtons() {
|
|
4945
|
+
if (
|
|
4946
|
+
this.#navStart &&
|
|
4947
|
+
this.#navEnd &&
|
|
4948
|
+
this.contains(this.#navStart) &&
|
|
4949
|
+
this.contains(this.#navEnd)
|
|
4950
|
+
) {
|
|
4951
|
+
return;
|
|
4952
|
+
}
|
|
4953
|
+
this.#removeNavButtons();
|
|
4954
|
+
const buttons = figLabCreateOverflowButtons({
|
|
4955
|
+
onStart: () => figLabScrollOverflowPage(this, -1),
|
|
4956
|
+
onEnd: () => figLabScrollOverflowPage(this, 1),
|
|
4957
|
+
});
|
|
4958
|
+
this.#navStart = buttons.start;
|
|
4959
|
+
this.#navEnd = buttons.end;
|
|
4960
|
+
this.prepend(this.#navStart);
|
|
4961
|
+
this.append(this.#navEnd);
|
|
4962
|
+
}
|
|
4963
|
+
|
|
4964
|
+
#removeNavButtons() {
|
|
4965
|
+
this.#navStart?.remove();
|
|
4966
|
+
this.#navEnd?.remove();
|
|
4967
|
+
this.#navStart = null;
|
|
4968
|
+
this.#navEnd = null;
|
|
4969
|
+
this.classList.remove("overflow-start", "overflow-end");
|
|
4970
|
+
}
|
|
4971
|
+
}
|
|
4972
|
+
customElements.define("fig-select-options", FigSelectOptions);
|
|
4973
|
+
|
|
4974
|
+
class FigSelect extends HTMLElement {
|
|
4975
|
+
#button = null;
|
|
4976
|
+
#popup = null;
|
|
4977
|
+
#labelEl = null;
|
|
4978
|
+
#panelSlot = null;
|
|
4979
|
+
#observer = null;
|
|
4980
|
+
#initialized = false;
|
|
4981
|
+
#focusedIndex = -1;
|
|
4982
|
+
#syncingValue = false;
|
|
4983
|
+
#popupPositionPatched = false;
|
|
4984
|
+
#originalPositionPopup = null;
|
|
4985
|
+
/** After open align, stop repositioning so overflow scroll isn't yanked back. */
|
|
4986
|
+
#freezeMenuPosition = false;
|
|
4987
|
+
#syncingOptions = false;
|
|
4988
|
+
#boundTriggerClick = this.#handleTriggerClick.bind(this);
|
|
4989
|
+
#boundOptionClick = this.#handleOptionClick.bind(this);
|
|
4990
|
+
#boundKeydown = this.#handleKeydown.bind(this);
|
|
4991
|
+
#boundPopupClose = this.#handlePopupClose.bind(this);
|
|
4992
|
+
#boundSlotChange = this.#handleSlotChange.bind(this);
|
|
4993
|
+
|
|
4994
|
+
static get observedAttributes() {
|
|
4995
|
+
return [
|
|
4996
|
+
"value",
|
|
4997
|
+
"disabled",
|
|
4998
|
+
"label",
|
|
4999
|
+
"options",
|
|
5000
|
+
"position",
|
|
5001
|
+
"offset",
|
|
5002
|
+
"closedby",
|
|
5003
|
+
"open",
|
|
5004
|
+
];
|
|
5005
|
+
}
|
|
5006
|
+
|
|
5007
|
+
get value() {
|
|
5008
|
+
return this.getAttribute("value") ?? "";
|
|
5009
|
+
}
|
|
5010
|
+
|
|
5011
|
+
set value(val) {
|
|
5012
|
+
if (val === null || val === undefined) this.removeAttribute("value");
|
|
5013
|
+
else this.setAttribute("value", String(val));
|
|
5014
|
+
}
|
|
5015
|
+
|
|
5016
|
+
get open() {
|
|
5017
|
+
return figLabBooleanAttribute(this, "open");
|
|
5018
|
+
}
|
|
5019
|
+
|
|
5020
|
+
set open(val) {
|
|
5021
|
+
if (val) this.setAttribute("open", "");
|
|
5022
|
+
else this.removeAttribute("open");
|
|
5023
|
+
}
|
|
5024
|
+
|
|
5025
|
+
connectedCallback() {
|
|
5026
|
+
if (!this.#initialized) this.#initialize();
|
|
5027
|
+
this.#ensurePanelSlotAttrs();
|
|
5028
|
+
this.#syncOptionsFromAttribute();
|
|
5029
|
+
this.#syncDisabled();
|
|
5030
|
+
this.#syncPopupAttrs();
|
|
5031
|
+
this.#syncValue();
|
|
5032
|
+
this.#setupObserver();
|
|
5033
|
+
if (this.open) this.#openList();
|
|
5034
|
+
}
|
|
5035
|
+
|
|
5036
|
+
disconnectedCallback() {
|
|
5037
|
+
this.#teardownListeners();
|
|
5038
|
+
document.removeEventListener("keydown", this.#boundKeydown, true);
|
|
5039
|
+
this.#observer?.disconnect();
|
|
5040
|
+
this.#observer = null;
|
|
5041
|
+
}
|
|
5042
|
+
|
|
5043
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
5044
|
+
if (oldValue === newValue || !this.#initialized) return;
|
|
5045
|
+
if (name === "options") {
|
|
5046
|
+
this.#syncOptionsFromAttribute();
|
|
5047
|
+
this.#syncValue();
|
|
5048
|
+
return;
|
|
5049
|
+
}
|
|
5050
|
+
if (name === "value" || name === "label") {
|
|
5051
|
+
this.#syncValue();
|
|
5052
|
+
return;
|
|
5053
|
+
}
|
|
5054
|
+
if (name === "disabled") {
|
|
5055
|
+
this.#syncDisabled();
|
|
5056
|
+
return;
|
|
5057
|
+
}
|
|
5058
|
+
if (name === "open") {
|
|
5059
|
+
if (newValue === null || newValue === "false") this.#closeList();
|
|
5060
|
+
else this.#openList();
|
|
5061
|
+
return;
|
|
5062
|
+
}
|
|
5063
|
+
if (name === "position" || name === "offset" || name === "closedby") {
|
|
5064
|
+
this.#syncPopupAttrs();
|
|
5065
|
+
}
|
|
5066
|
+
}
|
|
5067
|
+
|
|
5068
|
+
focus(options) {
|
|
5069
|
+
this.#button?.focus(options);
|
|
5070
|
+
}
|
|
5071
|
+
|
|
5072
|
+
blur() {
|
|
5073
|
+
this.#button?.blur();
|
|
5074
|
+
}
|
|
5075
|
+
|
|
5076
|
+
#isMenuChild(node) {
|
|
5077
|
+
return (
|
|
5078
|
+
node?.nodeType === 1 &&
|
|
5079
|
+
(node.tagName === "FIG-SELECT-OPTION" ||
|
|
5080
|
+
node.tagName === "FIG-MENU-SEPARATOR" ||
|
|
5081
|
+
node.tagName === "FIG-SELECT-OPTIONS")
|
|
5082
|
+
);
|
|
5083
|
+
}
|
|
5084
|
+
|
|
5085
|
+
#ensurePanelSlotAttrs() {
|
|
5086
|
+
for (const panel of this.querySelectorAll(":scope > fig-select-options")) {
|
|
5087
|
+
if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
|
|
5091
|
+
#getPanel() {
|
|
5092
|
+
const assigned = this.#panelSlot?.assignedElements({ flatten: true }) ?? [];
|
|
5093
|
+
const fromSlot = assigned.find(
|
|
5094
|
+
(el) => el.tagName === "FIG-SELECT-OPTIONS",
|
|
5095
|
+
);
|
|
5096
|
+
if (fromSlot) return fromSlot;
|
|
5097
|
+
return this.querySelector(":scope > fig-select-options");
|
|
5098
|
+
}
|
|
5099
|
+
|
|
5100
|
+
#hasAuthoredOptions() {
|
|
5101
|
+
return Boolean(
|
|
5102
|
+
this.querySelector(
|
|
5103
|
+
":scope > fig-select-option:not([data-fig-generated]), :scope > fig-select-options > fig-select-option:not([data-fig-generated])",
|
|
5104
|
+
),
|
|
5105
|
+
);
|
|
5106
|
+
}
|
|
5107
|
+
|
|
5108
|
+
#ensureOptionsPanel() {
|
|
5109
|
+
let panel = this.#getPanel();
|
|
5110
|
+
if (panel) {
|
|
5111
|
+
if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
|
|
5112
|
+
return panel;
|
|
5113
|
+
}
|
|
5114
|
+
panel = document.createElement("fig-select-options");
|
|
5115
|
+
panel.setAttribute("slot", "panel");
|
|
5116
|
+
panel.setAttribute("data-fig-generated", "");
|
|
5117
|
+
this.appendChild(panel);
|
|
5118
|
+
return panel;
|
|
5119
|
+
}
|
|
5120
|
+
|
|
5121
|
+
/**
|
|
5122
|
+
* When no authored fig-select-option exists, build panel/options from the
|
|
5123
|
+
* options attribute (comma / newline / JSON — same as fig-options).
|
|
5124
|
+
*/
|
|
5125
|
+
#syncOptionsFromAttribute() {
|
|
5126
|
+
if (this.#hasAuthoredOptions()) return;
|
|
5127
|
+
|
|
5128
|
+
const hasOptionsAttr = this.hasAttribute("options");
|
|
5129
|
+
const panel = hasOptionsAttr
|
|
5130
|
+
? this.#ensureOptionsPanel()
|
|
5131
|
+
: this.#getPanel();
|
|
5132
|
+
if (!panel) return;
|
|
5133
|
+
|
|
5134
|
+
this.#syncingOptions = true;
|
|
5135
|
+
try {
|
|
5136
|
+
for (const opt of panel.querySelectorAll(
|
|
5137
|
+
":scope > fig-select-option[data-fig-generated]",
|
|
5138
|
+
)) {
|
|
5139
|
+
opt.remove();
|
|
5140
|
+
}
|
|
5141
|
+
|
|
5142
|
+
if (!hasOptionsAttr) return;
|
|
5143
|
+
|
|
5144
|
+
const parsed = figLabParseOptionsAttribute(this.getAttribute("options"));
|
|
5145
|
+
const endBtn = panel.querySelector(":scope > .fig-overflow-end");
|
|
5146
|
+
for (const entry of parsed) {
|
|
5147
|
+
const el = document.createElement("fig-select-option");
|
|
5148
|
+
el.setAttribute("data-fig-generated", "");
|
|
5149
|
+
el.setAttribute("value", figLabOptionEntryValue(entry));
|
|
5150
|
+
el.textContent = figLabOptionEntryLabel(entry);
|
|
5151
|
+
if (endBtn) panel.insertBefore(el, endBtn);
|
|
5152
|
+
else panel.appendChild(el);
|
|
5153
|
+
}
|
|
5154
|
+
} finally {
|
|
5155
|
+
this.#syncingOptions = false;
|
|
5156
|
+
}
|
|
5157
|
+
}
|
|
5158
|
+
|
|
5159
|
+
#initialize() {
|
|
5160
|
+
this.#initialized = true;
|
|
5161
|
+
const shadow = this.attachShadow({ mode: "open" });
|
|
5162
|
+
shadow.innerHTML = `
|
|
5163
|
+
<style>
|
|
5164
|
+
:host {
|
|
5165
|
+
display: inline-flex;
|
|
5166
|
+
position: relative;
|
|
5167
|
+
align-items: center;
|
|
5168
|
+
min-width: 0;
|
|
5169
|
+
}
|
|
5170
|
+
:host([full]:not([full="false"])) {
|
|
5171
|
+
display: flex;
|
|
5172
|
+
width: 100%;
|
|
5173
|
+
}
|
|
5174
|
+
.fig-select-trigger {
|
|
5175
|
+
display: flex;
|
|
5176
|
+
align-items: center;
|
|
5177
|
+
justify-content: flex-start;
|
|
5178
|
+
flex: 1;
|
|
5179
|
+
min-width: 0;
|
|
5180
|
+
width: var(--fig-select-trigger-width, 100%);
|
|
5181
|
+
height: 100%;
|
|
5182
|
+
margin: 0;
|
|
5183
|
+
padding: 0 var(--spacer-4, 1rem) 0 var(--spacer-2, 0.5rem);
|
|
5184
|
+
border: 0;
|
|
5185
|
+
border-radius: inherit;
|
|
5186
|
+
background: transparent;
|
|
5187
|
+
box-shadow: none;
|
|
5188
|
+
color: inherit;
|
|
5189
|
+
font: inherit;
|
|
5190
|
+
font-weight: inherit;
|
|
5191
|
+
text-align: left;
|
|
5192
|
+
white-space: nowrap;
|
|
5193
|
+
overflow: hidden;
|
|
5194
|
+
text-overflow: ellipsis;
|
|
5195
|
+
cursor: default;
|
|
5196
|
+
}
|
|
5197
|
+
.fig-select-trigger:hover,
|
|
5198
|
+
.fig-select-trigger:active,
|
|
5199
|
+
.fig-select-trigger:active:hover {
|
|
5200
|
+
background: transparent;
|
|
5201
|
+
box-shadow: none;
|
|
5202
|
+
color: inherit;
|
|
5203
|
+
}
|
|
5204
|
+
.fig-select-trigger:focus-visible,
|
|
5205
|
+
.fig-select-trigger[data-focus-visible] {
|
|
5206
|
+
outline: var(--figma-focus-outline);
|
|
5207
|
+
outline-offset: var(--figma-focus-outline-offset);
|
|
5208
|
+
}
|
|
5209
|
+
:host([disabled]:not([disabled="false"])) .fig-select-trigger,
|
|
5210
|
+
:host([disabled]:not([disabled="false"])) .fig-select-label {
|
|
5211
|
+
color: var(--figma-color-text-tertiary);
|
|
5212
|
+
}
|
|
5213
|
+
.fig-select-label {
|
|
5214
|
+
display: block;
|
|
5215
|
+
width: 100%;
|
|
5216
|
+
min-width: 0;
|
|
5217
|
+
overflow: hidden;
|
|
5218
|
+
text-overflow: ellipsis;
|
|
5219
|
+
white-space: nowrap;
|
|
5220
|
+
text-align: left;
|
|
5221
|
+
}
|
|
5222
|
+
/* Listbox chrome from document fig-select::part(listbox).
|
|
5223
|
+
Overflow UI lives on slotted fig-select-options.
|
|
5224
|
+
Never set display except when open — closed <dialog> must stay display:none. */
|
|
5225
|
+
dialog[is="fig-popup"] {
|
|
5226
|
+
flex-direction: column;
|
|
5227
|
+
overflow: hidden;
|
|
5228
|
+
}
|
|
5229
|
+
dialog[is="fig-popup"][open] {
|
|
5230
|
+
display: flex;
|
|
5231
|
+
}
|
|
5232
|
+
::slotted(fig-select-options) {
|
|
5233
|
+
flex: 1 1 auto;
|
|
5234
|
+
min-height: 0;
|
|
5235
|
+
max-height: inherit;
|
|
5236
|
+
}
|
|
5237
|
+
</style>
|
|
5238
|
+
`;
|
|
5239
|
+
|
|
5240
|
+
const button = document.createElement("fig-button");
|
|
5241
|
+
button.className = "fig-select-trigger";
|
|
5242
|
+
button.setAttribute("part", "trigger");
|
|
5243
|
+
button.setAttribute("variant", "ghost");
|
|
5244
|
+
button.setAttribute("aria-haspopup", "listbox");
|
|
5245
|
+
button.setAttribute("aria-expanded", "false");
|
|
5246
|
+
|
|
5247
|
+
const labelEl = document.createElement("span");
|
|
5248
|
+
labelEl.className = "fig-select-label";
|
|
5249
|
+
labelEl.setAttribute("part", "label");
|
|
5250
|
+
button.appendChild(labelEl);
|
|
5251
|
+
|
|
5252
|
+
const popup = document.createElement("dialog", { is: "fig-popup" });
|
|
5253
|
+
popup.setAttribute("is", "fig-popup");
|
|
5254
|
+
popup.setAttribute("part", "listbox");
|
|
5255
|
+
popup.setAttribute("theme", "menu");
|
|
5256
|
+
popup.setAttribute("role", "listbox");
|
|
5257
|
+
popup.id = figLabUniqueId("fig-select-list");
|
|
5258
|
+
button.setAttribute("aria-controls", popup.id);
|
|
5259
|
+
|
|
5260
|
+
const panelSlot = document.createElement("slot");
|
|
5261
|
+
panelSlot.setAttribute("name", "panel");
|
|
5262
|
+
popup.appendChild(panelSlot);
|
|
5263
|
+
|
|
5264
|
+
shadow.append(button, popup);
|
|
5265
|
+
|
|
5266
|
+
this.#button = button;
|
|
5267
|
+
this.#labelEl = labelEl;
|
|
5268
|
+
this.#popup = popup;
|
|
5269
|
+
this.#panelSlot = panelSlot;
|
|
5270
|
+
popup.anchor = button;
|
|
5271
|
+
|
|
5272
|
+
this.#ensurePanelSlotAttrs();
|
|
5273
|
+
this.#setupListeners();
|
|
5274
|
+
this.#installPopupPositioning();
|
|
5275
|
+
|
|
5276
|
+
if (!this.hasAttribute("value")) {
|
|
5277
|
+
const selected = this.#getOptions().find((opt) =>
|
|
5278
|
+
figLabBooleanAttribute(opt, "selected"),
|
|
5279
|
+
);
|
|
5280
|
+
if (selected) this.setAttribute("value", selected.value);
|
|
5281
|
+
}
|
|
5282
|
+
}
|
|
5283
|
+
|
|
5284
|
+
#installPopupPositioning() {
|
|
5285
|
+
if (!this.#popup || this.#popupPositionPatched) return;
|
|
5286
|
+
if (typeof this.#popup.positionPopup !== "function") return;
|
|
5287
|
+
this.#originalPositionPopup = this.#popup.positionPopup.bind(this.#popup);
|
|
5288
|
+
this.#popup.positionPopup = () => {
|
|
5289
|
+
if (!this.open) {
|
|
5290
|
+
this.#originalPositionPopup?.();
|
|
5291
|
+
return;
|
|
5292
|
+
}
|
|
5293
|
+
this.#positionPopupOverSelected();
|
|
5294
|
+
};
|
|
5295
|
+
this.#popupPositionPatched = true;
|
|
5296
|
+
}
|
|
5297
|
+
|
|
5298
|
+
#getOptionTextRect(option) {
|
|
5299
|
+
if (!option) return null;
|
|
5300
|
+
const range = document.createRange();
|
|
5301
|
+
range.selectNodeContents(option);
|
|
5302
|
+
const rects = [...range.getClientRects()].filter(
|
|
5303
|
+
(rect) => rect.width > 0 && rect.height > 0,
|
|
5304
|
+
);
|
|
5305
|
+
if (rects.length) return rects[0];
|
|
5306
|
+
return option.getBoundingClientRect();
|
|
5307
|
+
}
|
|
5308
|
+
|
|
5309
|
+
#getViewportMargins() {
|
|
5310
|
+
if (typeof this.#popup?.parseViewportMargins === "function") {
|
|
5311
|
+
return this.#popup.parseViewportMargins();
|
|
5312
|
+
}
|
|
5313
|
+
return { top: 8, right: 8, bottom: 8, left: 8 };
|
|
5314
|
+
}
|
|
5315
|
+
|
|
5316
|
+
#positionPopupOverSelected() {
|
|
5317
|
+
// Content ResizeObserver / anchor tracking re-enter here after overflow
|
|
5318
|
+
// scroll; keep the open-time alignment instead of fighting the scroller.
|
|
5319
|
+
if (this.#freezeMenuPosition) return;
|
|
5320
|
+
|
|
5321
|
+
const popup = this.#popup;
|
|
5322
|
+
const label = this.#labelEl;
|
|
5323
|
+
if (!popup || !label) {
|
|
5324
|
+
this.#originalPositionPopup?.();
|
|
5325
|
+
return;
|
|
5326
|
+
}
|
|
5327
|
+
|
|
5328
|
+
const options = this.#getOptions();
|
|
5329
|
+
const selected =
|
|
5330
|
+
options.find((opt) => this.#optionValue(opt) === this.value) ||
|
|
5331
|
+
options[0];
|
|
5332
|
+
if (!selected) {
|
|
5333
|
+
this.#originalPositionPopup?.();
|
|
5334
|
+
return;
|
|
5335
|
+
}
|
|
5336
|
+
|
|
5337
|
+
// Lay out with the default positioning first so option metrics are valid.
|
|
5338
|
+
this.#originalPositionPopup?.();
|
|
5339
|
+
|
|
5340
|
+
const popupRect = popup.getBoundingClientRect();
|
|
5341
|
+
const labelRect = label.getBoundingClientRect();
|
|
5342
|
+
const optionTextRect = this.#getOptionTextRect(selected);
|
|
5343
|
+
if (
|
|
5344
|
+
!popupRect.width ||
|
|
5345
|
+
!popupRect.height ||
|
|
5346
|
+
!labelRect.width ||
|
|
5347
|
+
!optionTextRect
|
|
5348
|
+
) {
|
|
5349
|
+
return;
|
|
5350
|
+
}
|
|
5351
|
+
|
|
5352
|
+
const selectedOffsetX = optionTextRect.left - popupRect.left;
|
|
5353
|
+
const selectedOffsetY = optionTextRect.top - popupRect.top;
|
|
5354
|
+
let left = labelRect.left - selectedOffsetX;
|
|
5355
|
+
let top = labelRect.top - selectedOffsetY;
|
|
5356
|
+
|
|
5357
|
+
const margins = this.#getViewportMargins();
|
|
5358
|
+
const minLeft = margins.left;
|
|
5359
|
+
const minTop = margins.top;
|
|
5360
|
+
const maxLeft = window.innerWidth - popupRect.width - margins.right;
|
|
5361
|
+
const maxTop = window.innerHeight - popupRect.height - margins.bottom;
|
|
5362
|
+
left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft));
|
|
5363
|
+
top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop));
|
|
5364
|
+
|
|
5365
|
+
popup.style.left = `${Math.round(left)}px`;
|
|
5366
|
+
popup.style.top = `${Math.round(top)}px`;
|
|
5367
|
+
|
|
5368
|
+
// Nudge the panel scroller so the selected label stays over the trigger.
|
|
5369
|
+
const panel = this.#getPanel();
|
|
5370
|
+
const alignedTextRect = this.#getOptionTextRect(selected);
|
|
5371
|
+
if (
|
|
5372
|
+
alignedTextRect &&
|
|
5373
|
+
panel &&
|
|
5374
|
+
panel.scrollHeight > panel.clientHeight + 1
|
|
5375
|
+
) {
|
|
5376
|
+
const deltaY = alignedTextRect.top - labelRect.top;
|
|
5377
|
+
if (Math.abs(deltaY) > 0.5) {
|
|
5378
|
+
panel.scrollTop += deltaY;
|
|
5379
|
+
}
|
|
5380
|
+
panel.syncOverflow?.();
|
|
5381
|
+
}
|
|
5382
|
+
}
|
|
5383
|
+
|
|
5384
|
+
#setupListeners() {
|
|
5385
|
+
this.#button?.addEventListener("click", this.#boundTriggerClick);
|
|
5386
|
+
this.#button?.addEventListener("keydown", this.#boundKeydown);
|
|
5387
|
+
// Host click: slotted options stay in light DOM (not dialog.contains).
|
|
5388
|
+
this.addEventListener("click", this.#boundOptionClick);
|
|
5389
|
+
this.#popup?.addEventListener("keydown", this.#boundKeydown);
|
|
5390
|
+
this.#popup?.addEventListener("close", this.#boundPopupClose);
|
|
5391
|
+
this.#panelSlot?.addEventListener("slotchange", this.#boundSlotChange);
|
|
5392
|
+
}
|
|
5393
|
+
|
|
5394
|
+
#teardownListeners() {
|
|
5395
|
+
this.#button?.removeEventListener("click", this.#boundTriggerClick);
|
|
5396
|
+
this.#button?.removeEventListener("keydown", this.#boundKeydown);
|
|
5397
|
+
this.removeEventListener("click", this.#boundOptionClick);
|
|
5398
|
+
this.#popup?.removeEventListener("keydown", this.#boundKeydown);
|
|
5399
|
+
this.#popup?.removeEventListener("close", this.#boundPopupClose);
|
|
5400
|
+
this.#panelSlot?.removeEventListener("slotchange", this.#boundSlotChange);
|
|
5401
|
+
}
|
|
5402
|
+
|
|
5403
|
+
#handleSlotChange() {
|
|
5404
|
+
this.#ensurePanelSlotAttrs();
|
|
5405
|
+
this.#syncValue();
|
|
5406
|
+
}
|
|
5407
|
+
|
|
5408
|
+
#setupObserver() {
|
|
5409
|
+
if (this.#observer) return;
|
|
5410
|
+
this.#observer = new MutationObserver((mutations) => {
|
|
5411
|
+
if (this.#syncingValue || this.#syncingOptions) return;
|
|
5412
|
+
let needsSync = false;
|
|
5413
|
+
for (const mutation of mutations) {
|
|
5414
|
+
if (mutation.type === "childList") {
|
|
5415
|
+
if (
|
|
5416
|
+
[...mutation.addedNodes].some((node) => this.#isMenuChild(node)) ||
|
|
5417
|
+
[...mutation.removedNodes].some((node) => this.#isMenuChild(node))
|
|
5418
|
+
) {
|
|
5419
|
+
needsSync = true;
|
|
5420
|
+
}
|
|
5421
|
+
}
|
|
5422
|
+
if (
|
|
5423
|
+
mutation.type === "attributes" &&
|
|
5424
|
+
mutation.target?.tagName === "FIG-SELECT-OPTION" &&
|
|
5425
|
+
(mutation.attributeName === "value" ||
|
|
5426
|
+
mutation.attributeName === "disabled")
|
|
5427
|
+
) {
|
|
5428
|
+
needsSync = true;
|
|
5429
|
+
}
|
|
5430
|
+
if (
|
|
5431
|
+
mutation.type === "characterData" &&
|
|
5432
|
+
mutation.target?.parentElement?.tagName === "FIG-SELECT-OPTION"
|
|
5433
|
+
) {
|
|
5434
|
+
needsSync = true;
|
|
5435
|
+
}
|
|
5436
|
+
}
|
|
5437
|
+
if (needsSync) this.#syncValue();
|
|
5438
|
+
});
|
|
5439
|
+
this.#observer.observe(this, {
|
|
5440
|
+
childList: true,
|
|
5441
|
+
subtree: true,
|
|
5442
|
+
characterData: true,
|
|
5443
|
+
attributes: true,
|
|
5444
|
+
attributeFilter: ["value", "disabled", "selected"],
|
|
5445
|
+
});
|
|
5446
|
+
}
|
|
5447
|
+
|
|
5448
|
+
#getOptions({ enabledOnly = false } = {}) {
|
|
5449
|
+
const panel = this.#getPanel();
|
|
5450
|
+
const options = panel
|
|
5451
|
+
? Array.from(panel.querySelectorAll(":scope > fig-select-option"))
|
|
5452
|
+
: [];
|
|
5453
|
+
if (!enabledOnly) return options;
|
|
5454
|
+
return options.filter((opt) => !figLabBooleanAttribute(opt, "disabled"));
|
|
5455
|
+
}
|
|
5456
|
+
|
|
5457
|
+
#optionValue(option) {
|
|
5458
|
+
if (!option) return "";
|
|
5459
|
+
if (typeof option.value === "string") return option.value;
|
|
5460
|
+
const attr = option.getAttribute?.("value");
|
|
5461
|
+
if (attr != null) return attr;
|
|
5462
|
+
return (option.textContent || "").trim();
|
|
5463
|
+
}
|
|
5464
|
+
|
|
5465
|
+
#optionLabel(option) {
|
|
5466
|
+
return (option?.textContent || "").trim();
|
|
5467
|
+
}
|
|
5468
|
+
|
|
5469
|
+
#syncPopupAttrs() {
|
|
5470
|
+
if (!this.#popup) return;
|
|
5471
|
+
this.#popup.setAttribute(
|
|
5472
|
+
"position",
|
|
5473
|
+
this.getAttribute("position") || "bottom left",
|
|
5474
|
+
);
|
|
5475
|
+
const offset = this.getAttribute("offset");
|
|
5476
|
+
if (offset) this.#popup.setAttribute("offset", offset);
|
|
5477
|
+
else this.#popup.removeAttribute("offset");
|
|
5478
|
+
const closedby = this.getAttribute("closedby");
|
|
5479
|
+
if (closedby) this.#popup.setAttribute("closedby", closedby);
|
|
5480
|
+
else this.#popup.removeAttribute("closedby");
|
|
5481
|
+
}
|
|
5482
|
+
|
|
5483
|
+
#syncDisabled() {
|
|
5484
|
+
const disabled = figLabBooleanAttribute(this, "disabled");
|
|
5485
|
+
if (this.#button) {
|
|
5486
|
+
if (disabled) this.#button.setAttribute("disabled", "");
|
|
5487
|
+
else this.#button.removeAttribute("disabled");
|
|
5488
|
+
}
|
|
5489
|
+
if (disabled && this.open) this.open = false;
|
|
5490
|
+
}
|
|
5491
|
+
|
|
5492
|
+
#pickFallbackOption(options) {
|
|
5493
|
+
if (!options.length) return null;
|
|
5494
|
+
const selected = options.find((opt) =>
|
|
5495
|
+
figLabBooleanAttribute(opt, "selected"),
|
|
5496
|
+
);
|
|
5497
|
+
if (selected && !figLabBooleanAttribute(selected, "disabled")) {
|
|
5498
|
+
return selected;
|
|
5499
|
+
}
|
|
5500
|
+
return (
|
|
5501
|
+
options.find((opt) => !figLabBooleanAttribute(opt, "disabled")) ||
|
|
5502
|
+
options[0] ||
|
|
5503
|
+
null
|
|
5504
|
+
);
|
|
5505
|
+
}
|
|
5506
|
+
|
|
5507
|
+
#emitValueEvents(value) {
|
|
5508
|
+
this.dispatchEvent(
|
|
5509
|
+
new CustomEvent("input", {
|
|
5510
|
+
detail: value,
|
|
5511
|
+
bubbles: true,
|
|
5512
|
+
composed: true,
|
|
5513
|
+
}),
|
|
5514
|
+
);
|
|
5515
|
+
this.dispatchEvent(
|
|
5516
|
+
new CustomEvent("change", {
|
|
5517
|
+
detail: value,
|
|
5518
|
+
bubbles: true,
|
|
5519
|
+
composed: true,
|
|
5520
|
+
}),
|
|
5521
|
+
);
|
|
5522
|
+
}
|
|
5523
|
+
|
|
5524
|
+
#syncValue() {
|
|
5525
|
+
if (this.#syncingValue) return;
|
|
5526
|
+
this.#syncingValue = true;
|
|
5527
|
+
try {
|
|
5528
|
+
const options = this.#getOptions();
|
|
5529
|
+
const hasValueAttr = this.hasAttribute("value");
|
|
5530
|
+
const previousValue = hasValueAttr ? this.getAttribute("value") : null;
|
|
5531
|
+
let match = hasValueAttr
|
|
5532
|
+
? options.find((opt) => this.#optionValue(opt) === previousValue)
|
|
5533
|
+
: null;
|
|
5534
|
+
let valueCorrected = false;
|
|
5535
|
+
|
|
5536
|
+
if (!match) {
|
|
5537
|
+
if (hasValueAttr) {
|
|
5538
|
+
// Options may not be built yet (options attr sync). Keep value until then.
|
|
5539
|
+
if (!options.length) {
|
|
5540
|
+
if (this.#labelEl) {
|
|
5541
|
+
this.#labelEl.textContent =
|
|
5542
|
+
previousValue || this.getAttribute("label") || "";
|
|
5543
|
+
}
|
|
5544
|
+
return;
|
|
5545
|
+
}
|
|
5546
|
+
// Value orphaned (option removed / value attr changed) — clamp or clear.
|
|
5547
|
+
match = this.#pickFallbackOption(options);
|
|
5548
|
+
if (match) {
|
|
5549
|
+
const nextValue = this.#optionValue(match);
|
|
5550
|
+
if (previousValue !== nextValue) {
|
|
5551
|
+
this.setAttribute("value", nextValue);
|
|
5552
|
+
valueCorrected = true;
|
|
5553
|
+
}
|
|
5554
|
+
} else {
|
|
5555
|
+
this.removeAttribute("value");
|
|
5556
|
+
valueCorrected = true;
|
|
5557
|
+
}
|
|
5558
|
+
} else {
|
|
5559
|
+
// No host value yet — honor a selected option if present.
|
|
5560
|
+
match = options.find((opt) =>
|
|
5561
|
+
figLabBooleanAttribute(opt, "selected"),
|
|
5562
|
+
);
|
|
5563
|
+
if (match) {
|
|
5564
|
+
this.setAttribute("value", this.#optionValue(match));
|
|
5565
|
+
valueCorrected = true;
|
|
5566
|
+
}
|
|
5567
|
+
}
|
|
5568
|
+
}
|
|
5569
|
+
|
|
5570
|
+
for (const opt of options) {
|
|
5571
|
+
const selected = opt === match;
|
|
5572
|
+
opt.setAttribute("aria-selected", selected ? "true" : "false");
|
|
5573
|
+
if (selected) opt.setAttribute("selected", "");
|
|
5574
|
+
else opt.removeAttribute("selected");
|
|
5575
|
+
}
|
|
5576
|
+
|
|
5577
|
+
const label =
|
|
5578
|
+
(match && this.#optionLabel(match)) || this.getAttribute("label") || "";
|
|
5579
|
+
if (this.#labelEl) this.#labelEl.textContent = label;
|
|
5580
|
+
|
|
5581
|
+
const ariaLabel = this.getAttribute("label") || "Select";
|
|
5582
|
+
this.#button?.setAttribute("aria-label", ariaLabel);
|
|
5583
|
+
|
|
5584
|
+
// Don't scrollToOption while open — reposition/sync would fight overflow paging.
|
|
5585
|
+
this.#getPanel()?.syncOverflow?.();
|
|
5586
|
+
|
|
5587
|
+
if (valueCorrected) {
|
|
5588
|
+
this.#emitValueEvents(this.getAttribute("value") ?? "");
|
|
5589
|
+
}
|
|
5590
|
+
} finally {
|
|
5591
|
+
this.#syncingValue = false;
|
|
5592
|
+
}
|
|
5593
|
+
}
|
|
5594
|
+
|
|
5595
|
+
#handleTriggerClick(e) {
|
|
5596
|
+
if (figLabBooleanAttribute(this, "disabled")) return;
|
|
5597
|
+
e.preventDefault();
|
|
5598
|
+
e.stopPropagation();
|
|
5599
|
+
const popupShowing = this.#popup?.matches?.(":open") ?? false;
|
|
5600
|
+
if (this.open && popupShowing) this.open = false;
|
|
5601
|
+
else {
|
|
5602
|
+
if (this.#popup && this.#button) this.#popup.anchor = this.#button;
|
|
5603
|
+
this.open = true;
|
|
5604
|
+
}
|
|
5605
|
+
}
|
|
5606
|
+
|
|
5607
|
+
#handleOptionClick(e) {
|
|
5608
|
+
const path = typeof e.composedPath === "function" ? e.composedPath() : [];
|
|
5609
|
+
const option = path.find(
|
|
5610
|
+
(node) => node?.tagName === "FIG-SELECT-OPTION",
|
|
5611
|
+
);
|
|
5612
|
+
if (!option || !this.contains(option)) return;
|
|
5613
|
+
if (figLabBooleanAttribute(option, "disabled")) return;
|
|
5614
|
+
// Do not stopPropagation — React light-DOM onClick must still fire.
|
|
5615
|
+
this.#selectOption(option);
|
|
5616
|
+
}
|
|
5617
|
+
|
|
5618
|
+
#handleKeydown(e) {
|
|
5619
|
+
if (e.currentTarget === document && e.key !== "Escape") return;
|
|
5620
|
+
|
|
5621
|
+
const listOpen = this.open && (this.#popup?.matches?.(":open") ?? false);
|
|
5622
|
+
if (!listOpen) {
|
|
5623
|
+
if (
|
|
5624
|
+
this.#button?.contains(e.target) &&
|
|
5625
|
+
(e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")
|
|
5626
|
+
) {
|
|
5627
|
+
e.preventDefault();
|
|
5628
|
+
if (this.#popup && this.#button) this.#popup.anchor = this.#button;
|
|
5629
|
+
this.open = true;
|
|
5630
|
+
requestAnimationFrame(() => {
|
|
5631
|
+
const options = this.#getOptions({ enabledOnly: true });
|
|
5632
|
+
const selectedIndex = options.findIndex(
|
|
5633
|
+
(opt) => this.#optionValue(opt) === this.value,
|
|
5634
|
+
);
|
|
5635
|
+
this.#focusOptionAt(selectedIndex >= 0 ? selectedIndex : 0);
|
|
5636
|
+
});
|
|
5637
|
+
}
|
|
5638
|
+
return;
|
|
5639
|
+
}
|
|
5640
|
+
|
|
5641
|
+
const options = this.#getOptions({ enabledOnly: true });
|
|
5642
|
+
if (!options.length) return;
|
|
5643
|
+
|
|
5644
|
+
switch (e.key) {
|
|
5645
|
+
case "ArrowDown":
|
|
5646
|
+
e.preventDefault();
|
|
5647
|
+
this.#syncFocusedIndex();
|
|
5648
|
+
this.#focusOptionAt(this.#focusedIndex + 1);
|
|
5649
|
+
break;
|
|
5650
|
+
case "ArrowUp":
|
|
5651
|
+
e.preventDefault();
|
|
5652
|
+
this.#syncFocusedIndex();
|
|
5653
|
+
this.#focusOptionAt(this.#focusedIndex - 1);
|
|
5654
|
+
break;
|
|
5655
|
+
case "Home":
|
|
5656
|
+
e.preventDefault();
|
|
5657
|
+
this.#focusOptionAt(0);
|
|
5658
|
+
break;
|
|
5659
|
+
case "End":
|
|
5660
|
+
e.preventDefault();
|
|
5661
|
+
this.#focusOptionAt(options.length - 1);
|
|
5662
|
+
break;
|
|
5663
|
+
case "Escape":
|
|
5664
|
+
e.preventDefault();
|
|
5665
|
+
this.open = false;
|
|
5666
|
+
this.#button?.focus();
|
|
5667
|
+
break;
|
|
5668
|
+
case "Enter":
|
|
5669
|
+
case " ": {
|
|
5670
|
+
this.#syncFocusedIndex();
|
|
5671
|
+
const focused = options[this.#focusedIndex];
|
|
5672
|
+
if (!focused) return;
|
|
5673
|
+
e.preventDefault();
|
|
5674
|
+
this.#selectOption(focused);
|
|
5675
|
+
break;
|
|
5676
|
+
}
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5679
|
+
|
|
5680
|
+
#handlePopupClose() {
|
|
5681
|
+
if (this.hasAttribute("open")) this.removeAttribute("open");
|
|
5682
|
+
this.#button?.setAttribute("aria-expanded", "false");
|
|
5683
|
+
this.#button?.focus();
|
|
5684
|
+
this.#focusedIndex = -1;
|
|
5685
|
+
}
|
|
5686
|
+
|
|
5687
|
+
#selectOption(option) {
|
|
5688
|
+
const value = this.#optionValue(option);
|
|
5689
|
+
this.setAttribute("value", value);
|
|
5690
|
+
this.#syncValue();
|
|
5691
|
+
this.#emitValueEvents(value);
|
|
5692
|
+
this.open = false;
|
|
5693
|
+
}
|
|
5694
|
+
|
|
5695
|
+
#getEnabledOptions() {
|
|
5696
|
+
return this.#getOptions({ enabledOnly: true });
|
|
5697
|
+
}
|
|
5698
|
+
|
|
5699
|
+
#syncFocusedIndex() {
|
|
5700
|
+
const options = this.#getEnabledOptions();
|
|
5701
|
+
if (!options.length) {
|
|
5702
|
+
this.#focusedIndex = -1;
|
|
5703
|
+
return;
|
|
5704
|
+
}
|
|
5705
|
+
const active = options.find((opt) => opt === document.activeElement);
|
|
5706
|
+
const index = active ? options.indexOf(active) : -1;
|
|
5707
|
+
this.#focusedIndex = index >= 0 ? index : this.#focusedIndex;
|
|
5708
|
+
}
|
|
5709
|
+
|
|
5710
|
+
#focusOptionAt(index) {
|
|
5711
|
+
const options = this.#getEnabledOptions();
|
|
5712
|
+
if (!options.length) return;
|
|
5713
|
+
const next = ((index % options.length) + options.length) % options.length;
|
|
5714
|
+
this.#focusedIndex = next;
|
|
5715
|
+
options[next]?.focus();
|
|
5716
|
+
}
|
|
5717
|
+
|
|
5718
|
+
#syncPopupWidth() {
|
|
5719
|
+
if (!this.#popup || !this.#button) return;
|
|
5720
|
+
const hostWidth = Math.ceil(this.getBoundingClientRect().width);
|
|
5721
|
+
const triggerWidth = Math.ceil(this.#button.getBoundingClientRect().width);
|
|
5722
|
+
const anchorWidth = Math.max(hostWidth, triggerWidth, 96);
|
|
5723
|
+
const full = figLabBooleanAttribute(this, "full");
|
|
5724
|
+
|
|
5725
|
+
// Use !important — fig-select::part(listbox) width rules beat element.style.
|
|
5726
|
+
if (full) {
|
|
5727
|
+
this.#popup.style.setProperty("width", `${anchorWidth}px`, "important");
|
|
5728
|
+
this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
|
|
5729
|
+
this.#popup.style.setProperty("max-width", `${anchorWidth}px`, "important");
|
|
5730
|
+
} else {
|
|
5731
|
+
this.#popup.style.setProperty("width", "max-content", "important");
|
|
5732
|
+
this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
|
|
5733
|
+
this.#popup.style.setProperty(
|
|
5734
|
+
"max-width",
|
|
5735
|
+
"min(20rem, calc(100vw - 1rem))",
|
|
5736
|
+
"important",
|
|
5737
|
+
);
|
|
5738
|
+
}
|
|
5739
|
+
}
|
|
5740
|
+
|
|
5741
|
+
#openList() {
|
|
5742
|
+
if (!this.#popup || figLabBooleanAttribute(this, "disabled")) return;
|
|
5743
|
+
if (this.#button) this.#popup.anchor = this.#button;
|
|
5744
|
+
this.#installPopupPositioning();
|
|
5745
|
+
this.#freezeMenuPosition = false;
|
|
5746
|
+
this.#syncValue();
|
|
5747
|
+
this.#syncPopupWidth();
|
|
5748
|
+
this.#popup.open = true;
|
|
5749
|
+
document.addEventListener("keydown", this.#boundKeydown, true);
|
|
5750
|
+
this.#button?.setAttribute("aria-expanded", "true");
|
|
5751
|
+
this.#focusedIndex = -1;
|
|
5752
|
+
requestAnimationFrame(() => {
|
|
5753
|
+
this.#syncPopupWidth();
|
|
5754
|
+
this.#positionPopupOverSelected();
|
|
5755
|
+
const panel = this.#getPanel();
|
|
5756
|
+
const options = this.#getEnabledOptions();
|
|
5757
|
+
const selectedIndex = options.findIndex(
|
|
5758
|
+
(opt) => this.#optionValue(opt) === this.value,
|
|
5759
|
+
);
|
|
5760
|
+
if (selectedIndex >= 0) {
|
|
5761
|
+
this.#focusOptionAt(selectedIndex);
|
|
5762
|
+
} else if (
|
|
5763
|
+
this.#button?.hasAttribute("data-focus-visible") ||
|
|
5764
|
+
this.#button?.matches?.(":focus-visible")
|
|
5765
|
+
) {
|
|
5766
|
+
this.#focusOptionAt(0);
|
|
5767
|
+
}
|
|
5768
|
+
panel?.syncOverflow?.();
|
|
5769
|
+
// Freeze after open align so later positionPopup passes don't undo scroll.
|
|
5770
|
+
this.#freezeMenuPosition = true;
|
|
5771
|
+
});
|
|
5772
|
+
}
|
|
5773
|
+
|
|
5774
|
+
#closeList() {
|
|
5775
|
+
if (!this.#popup) return;
|
|
5776
|
+
this.#freezeMenuPosition = false;
|
|
5777
|
+
document.removeEventListener("keydown", this.#boundKeydown, true);
|
|
5778
|
+
this.#popup.open = false;
|
|
5779
|
+
this.#button?.setAttribute("aria-expanded", "false");
|
|
5780
|
+
}
|
|
5781
|
+
}
|
|
5782
|
+
customElements.define("fig-select", FigSelect);
|