@rogieking/figui3 6.17.0 → 6.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig-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
  }
@@ -1293,7 +1299,8 @@ fig-select::part(listbox) {
1293
1299
 
1294
1300
  z-index: var(--z-index);
1295
1301
  position: fixed;
1296
- inset: auto;
1302
+ /* Do not set inset here — ::part inset beats inline left/top and
1303
+ prevents edge clamping from moving the menu back into view. */
1297
1304
  margin: 0;
1298
1305
  border: 0;
1299
1306
  outline: 0;
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%;
@@ -5251,16 +5354,26 @@ class FigSelect extends HTMLElement {
5251
5354
  let left = labelRect.left - selectedOffsetX;
5252
5355
  let top = labelRect.top - selectedOffsetY;
5253
5356
 
5357
+ // Keep the whole menu in-view when aligning over the selected option
5358
+ // would otherwise push it past a viewport edge (corners / far sides).
5254
5359
  const margins = this.#getViewportMargins();
5255
- const minLeft = margins.left;
5256
- const minTop = margins.top;
5257
- const maxLeft = window.innerWidth - popupRect.width - margins.right;
5258
- const maxTop = window.innerHeight - popupRect.height - margins.bottom;
5259
- left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft));
5260
- top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop));
5360
+ if (typeof popup.clampToViewport === "function") {
5361
+ ({ left, top } = popup.clampToViewport({ left, top }, popupRect, margins));
5362
+ } else {
5363
+ const minLeft = margins.left;
5364
+ const minTop = margins.top;
5365
+ const maxLeft = window.innerWidth - popupRect.width - margins.right;
5366
+ const maxTop = window.innerHeight - popupRect.height - margins.bottom;
5367
+ left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft));
5368
+ top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop));
5369
+ }
5261
5370
 
5262
- popup.style.left = `${Math.round(left)}px`;
5263
- popup.style.top = `${Math.round(top)}px`;
5371
+ // !important: fig-select::part(listbox) and dialog UA rules can otherwise
5372
+ // keep the menu at its static/anchor position past the viewport edge.
5373
+ popup.style.setProperty("right", "auto", "important");
5374
+ popup.style.setProperty("bottom", "auto", "important");
5375
+ popup.style.setProperty("left", `${Math.round(left)}px`, "important");
5376
+ popup.style.setProperty("top", `${Math.round(top)}px`, "important");
5264
5377
 
5265
5378
  // Nudge the panel scroller so the selected label stays over the trigger.
5266
5379
  const panel = this.#getPanel();
@@ -5305,7 +5418,7 @@ class FigSelect extends HTMLElement {
5305
5418
  #setupObserver() {
5306
5419
  if (this.#observer) return;
5307
5420
  this.#observer = new MutationObserver((mutations) => {
5308
- if (this.#syncingValue) return;
5421
+ if (this.#syncingValue || this.#syncingOptions) return;
5309
5422
  let needsSync = false;
5310
5423
  for (const mutation of mutations) {
5311
5424
  if (mutation.type === "childList") {
@@ -5432,6 +5545,14 @@ class FigSelect extends HTMLElement {
5432
5545
 
5433
5546
  if (!match) {
5434
5547
  if (hasValueAttr) {
5548
+ // Options may not be built yet (options attr sync). Keep value until then.
5549
+ if (!options.length) {
5550
+ if (this.#labelEl) {
5551
+ this.#labelEl.textContent =
5552
+ previousValue || this.getAttribute("label") || "";
5553
+ }
5554
+ return;
5555
+ }
5435
5556
  // Value orphaned (option removed / value attr changed) — clamp or clear.
5436
5557
  match = this.#pickFallbackOption(options);
5437
5558
  if (match) {
package/fig.js CHANGED
@@ -1600,6 +1600,11 @@ class FigDialog extends HTMLDialogElement {
1600
1600
 
1601
1601
  this._ensureHeader();
1602
1602
 
1603
+ // Markup `<dialog open>` (non-modal) never calls show() — still need a stack bump.
1604
+ if (this.open && !this.matches?.(":modal")) {
1605
+ this._assignStackingOrder();
1606
+ }
1607
+
1603
1608
  figNextFrame(this, () => {
1604
1609
  this._addCloseListeners();
1605
1610
  this._setupDragListeners();
@@ -1641,18 +1646,37 @@ class FigDialog extends HTMLDialogElement {
1641
1646
  requestAnimationFrame(() => target.focus?.());
1642
1647
  }
1643
1648
 
1649
+ _assignStackingOrder() {
1650
+ this.style.zIndex = String(figGetHighestZIndex() + 1);
1651
+ }
1652
+
1653
+ _clearStackingOrder() {
1654
+ this.style.zIndex = "";
1655
+ }
1656
+
1644
1657
  show() {
1645
1658
  this._captureFocusBeforeOpen();
1659
+ // Non-modal dialogs are position:fixed in normal paint order — bump above
1660
+ // other FigUI overlays (same helper as fig-popup).
1661
+ this._assignStackingOrder();
1646
1662
  this.addEventListener("close", this._boundRestoreFocus, { once: true });
1647
1663
  return super.show();
1648
1664
  }
1649
1665
 
1650
1666
  showModal() {
1651
1667
  this._captureFocusBeforeOpen();
1668
+ // Top layer owns stacking; clear any prior non-modal inline z-index.
1669
+ this._clearStackingOrder();
1652
1670
  this.addEventListener("close", this._boundRestoreFocus, { once: true });
1653
1671
  return super.showModal();
1654
1672
  }
1655
1673
 
1674
+ close(...args) {
1675
+ const result = super.close(...args);
1676
+ this._clearStackingOrder();
1677
+ return result;
1678
+ }
1679
+
1656
1680
  _handleIframeMouseLeave(event) {
1657
1681
  const iframe = event?.currentTarget;
1658
1682
  if (!(iframe instanceof HTMLIFrameElement)) return;
@@ -7145,7 +7169,20 @@ class FigField extends HTMLElement {
7145
7169
  !(node instanceof Element && node.classList.contains("fig-field-chevron")),
7146
7170
  );
7147
7171
 
7148
- this.#toggleable = !!(this.input && "open" in this.input);
7172
+ // Popup/menu hosts also expose `open`, but that is overlay state — not a
7173
+ // fig-field disclosure. Only treat true expand/collapse children as toggleable.
7174
+ const inputTag = this.input?.localName ?? "";
7175
+ const popupOpenHost =
7176
+ inputTag === "fig-select" ||
7177
+ inputTag === "fig-menu" ||
7178
+ inputTag === "fig-dropdown" ||
7179
+ inputTag === "fig-tooltip" ||
7180
+ (this.input instanceof HTMLDialogElement);
7181
+ this.#toggleable = !!(
7182
+ this.input &&
7183
+ "open" in this.input &&
7184
+ !popupOpenHost
7185
+ );
7149
7186
 
7150
7187
  if (this.#toggleable && this.label) {
7151
7188
  if (!this.#chevron || !this.#chevron.isConnected) {
@@ -7158,12 +7195,21 @@ class FigField extends HTMLElement {
7158
7195
 
7159
7196
  this.#chevron.addEventListener("click", this.#boundToggle);
7160
7197
  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);
7198
+ } else {
7199
+ if (this.#chevron) {
7200
+ this.#chevron.removeEventListener("click", this.#boundToggle);
7201
+ this.#chevron.remove();
7202
+ this.#chevron = null;
7203
+ }
7204
+ if (this.input && this.label) {
7205
+ const nativeInputs = this.input.querySelectorAll(
7206
+ "input, select, textarea",
7207
+ );
7208
+ // A label with a `for` target already focuses and activates its control.
7209
+ // Only use the composite fallback when there is no single native target.
7210
+ if (nativeInputs.length !== 1) {
7211
+ this.label.addEventListener("click", this.#boundFocus);
7212
+ }
7167
7213
  }
7168
7214
  }
7169
7215
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "6.17.0",
3
+ "version": "6.18.1",
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",