@rogieking/figui3 6.17.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/fig-lab.css CHANGED
@@ -276,10 +276,6 @@ propskit-select {
276
276
  background: transparent;
277
277
  box-shadow: none;
278
278
 
279
- &::after {
280
- right: 0;
281
- }
282
-
283
279
  --fig-select-trigger-width: auto;
284
280
  }
285
281
  }
@@ -644,6 +640,14 @@ propskit-slider {
644
640
 
645
641
  &[type="stepper"] {
646
642
  --slider-thumb-travel-offset: 0px;
643
+ datalist{
644
+ opacity: 0;
645
+ }
646
+ &:hover, &:focus-within {
647
+ datalist{
648
+ opacity: 1;
649
+ }
650
+ }
647
651
  }
648
652
 
649
653
  &[type="stepper"] .fig-slider-input-container input[type="range"] {
@@ -1258,6 +1262,8 @@ fig-select {
1258
1262
 
1259
1263
  &[disabled]:not([disabled="false"]) {
1260
1264
  pointer-events: none;
1265
+ color: var(--figma-color-text-tertiary);
1266
+
1261
1267
  &::after {
1262
1268
  background-color: var(--figma-color-icon-disabled);
1263
1269
  }
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
- mutation.attributeName === "label" ||
478
- mutation.attributeName === "direction" ||
479
- mutation.attributeName === "aria-label"
478
+ name === "label" ||
479
+ name === "direction" ||
480
+ name === "aria-label"
480
481
  ) {
481
482
  syncField = true;
482
483
  } else {
@@ -502,34 +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 initialChildren = Array.from(this.childNodes).filter(
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
517
  const select = document.createElement("fig-select");
519
- const existingPanel = initialChildren.find(
520
- (node) =>
521
- node.nodeType === Node.ELEMENT_NODE &&
522
- node.tagName === "FIG-SELECT-OPTIONS",
523
- );
524
- const panel =
525
- existingPanel || document.createElement("fig-select-options");
526
- panel.setAttribute("slot", "panel");
527
-
528
- for (const node of initialChildren) {
529
- if (node === customLabel || node === existingPanel) continue;
530
- panel.appendChild(node);
531
- }
532
- select.append(panel);
533
518
  field.append(label, select);
534
519
  this.#field = field;
535
520
  this.#label = label;
@@ -586,7 +571,14 @@ class PropskitSelect extends HTMLElement {
586
571
 
587
572
  #syncSelectAttributes() {
588
573
  if (!this.#select) return;
589
- 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
+ });
590
582
  const nextManaged = new Set(selectAttrs);
591
583
 
592
584
  for (const attrName of this.#managedSelectAttrs) {
@@ -4743,6 +4735,38 @@ function figLabUniqueId(prefix = "fig-select") {
4743
4735
  return `${prefix}-${figLabSelectId}`;
4744
4736
  }
4745
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
+
4746
4770
  class FigSelectOption extends HTMLElement {
4747
4771
  static get observedAttributes() {
4748
4772
  return ["value", "disabled", "selected"];
@@ -4960,6 +4984,7 @@ class FigSelect extends HTMLElement {
4960
4984
  #originalPositionPopup = null;
4961
4985
  /** After open align, stop repositioning so overflow scroll isn't yanked back. */
4962
4986
  #freezeMenuPosition = false;
4987
+ #syncingOptions = false;
4963
4988
  #boundTriggerClick = this.#handleTriggerClick.bind(this);
4964
4989
  #boundOptionClick = this.#handleOptionClick.bind(this);
4965
4990
  #boundKeydown = this.#handleKeydown.bind(this);
@@ -4967,7 +4992,16 @@ class FigSelect extends HTMLElement {
4967
4992
  #boundSlotChange = this.#handleSlotChange.bind(this);
4968
4993
 
4969
4994
  static get observedAttributes() {
4970
- return ["value", "disabled", "label", "position", "offset", "closedby", "open"];
4995
+ return [
4996
+ "value",
4997
+ "disabled",
4998
+ "label",
4999
+ "options",
5000
+ "position",
5001
+ "offset",
5002
+ "closedby",
5003
+ "open",
5004
+ ];
4971
5005
  }
4972
5006
 
4973
5007
  get value() {
@@ -4991,6 +5025,7 @@ class FigSelect extends HTMLElement {
4991
5025
  connectedCallback() {
4992
5026
  if (!this.#initialized) this.#initialize();
4993
5027
  this.#ensurePanelSlotAttrs();
5028
+ this.#syncOptionsFromAttribute();
4994
5029
  this.#syncDisabled();
4995
5030
  this.#syncPopupAttrs();
4996
5031
  this.#syncValue();
@@ -5007,6 +5042,11 @@ class FigSelect extends HTMLElement {
5007
5042
 
5008
5043
  attributeChangedCallback(name, oldValue, newValue) {
5009
5044
  if (oldValue === newValue || !this.#initialized) return;
5045
+ if (name === "options") {
5046
+ this.#syncOptionsFromAttribute();
5047
+ this.#syncValue();
5048
+ return;
5049
+ }
5010
5050
  if (name === "value" || name === "label") {
5011
5051
  this.#syncValue();
5012
5052
  return;
@@ -5057,6 +5097,65 @@ class FigSelect extends HTMLElement {
5057
5097
  return this.querySelector(":scope > fig-select-options");
5058
5098
  }
5059
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
+
5060
5159
  #initialize() {
5061
5160
  this.#initialized = true;
5062
5161
  const shadow = this.attachShadow({ mode: "open" });
@@ -5107,6 +5206,10 @@ class FigSelect extends HTMLElement {
5107
5206
  outline: var(--figma-focus-outline);
5108
5207
  outline-offset: var(--figma-focus-outline-offset);
5109
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
+ }
5110
5213
  .fig-select-label {
5111
5214
  display: block;
5112
5215
  width: 100%;
@@ -5305,7 +5408,7 @@ class FigSelect extends HTMLElement {
5305
5408
  #setupObserver() {
5306
5409
  if (this.#observer) return;
5307
5410
  this.#observer = new MutationObserver((mutations) => {
5308
- if (this.#syncingValue) return;
5411
+ if (this.#syncingValue || this.#syncingOptions) return;
5309
5412
  let needsSync = false;
5310
5413
  for (const mutation of mutations) {
5311
5414
  if (mutation.type === "childList") {
@@ -5432,6 +5535,14 @@ class FigSelect extends HTMLElement {
5432
5535
 
5433
5536
  if (!match) {
5434
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
+ }
5435
5546
  // Value orphaned (option removed / value attr changed) — clamp or clear.
5436
5547
  match = this.#pickFallbackOption(options);
5437
5548
  if (match) {
package/fig.js CHANGED
@@ -7145,7 +7145,20 @@ class FigField extends HTMLElement {
7145
7145
  !(node instanceof Element && node.classList.contains("fig-field-chevron")),
7146
7146
  );
7147
7147
 
7148
- this.#toggleable = !!(this.input && "open" in this.input);
7148
+ // Popup/menu hosts also expose `open`, but that is overlay state — not a
7149
+ // fig-field disclosure. Only treat true expand/collapse children as toggleable.
7150
+ const inputTag = this.input?.localName ?? "";
7151
+ const popupOpenHost =
7152
+ inputTag === "fig-select" ||
7153
+ inputTag === "fig-menu" ||
7154
+ inputTag === "fig-dropdown" ||
7155
+ inputTag === "fig-tooltip" ||
7156
+ (this.input instanceof HTMLDialogElement);
7157
+ this.#toggleable = !!(
7158
+ this.input &&
7159
+ "open" in this.input &&
7160
+ !popupOpenHost
7161
+ );
7149
7162
 
7150
7163
  if (this.#toggleable && this.label) {
7151
7164
  if (!this.#chevron || !this.#chevron.isConnected) {
@@ -7158,12 +7171,21 @@ class FigField extends HTMLElement {
7158
7171
 
7159
7172
  this.#chevron.addEventListener("click", this.#boundToggle);
7160
7173
  this.label.addEventListener("click", this.#boundToggle);
7161
- } else if (this.input && this.label) {
7162
- const nativeInputs = this.input.querySelectorAll("input, select, textarea");
7163
- // A label with a `for` target already focuses and activates its control.
7164
- // Only use the composite fallback when there is no single native target.
7165
- if (nativeInputs.length !== 1) {
7166
- this.label.addEventListener("click", this.#boundFocus);
7174
+ } else {
7175
+ if (this.#chevron) {
7176
+ this.#chevron.removeEventListener("click", this.#boundToggle);
7177
+ this.#chevron.remove();
7178
+ this.#chevron = null;
7179
+ }
7180
+ if (this.input && this.label) {
7181
+ const nativeInputs = this.input.querySelectorAll(
7182
+ "input, select, textarea",
7183
+ );
7184
+ // A label with a `for` target already focuses and activates its control.
7185
+ // Only use the composite fallback when there is no single native target.
7186
+ if (nativeInputs.length !== 1) {
7187
+ this.label.addEventListener("click", this.#boundFocus);
7188
+ }
7167
7189
  }
7168
7190
  }
7169
7191
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "6.17.0",
3
+ "version": "6.18.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",