@rogieking/figui3 4.2.0 → 4.3.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.
Files changed (3) hide show
  1. package/components.css +27 -2
  2. package/fig.js +328 -2
  3. package/package.json +1 -1
package/components.css CHANGED
@@ -3415,7 +3415,7 @@ fig-input-palette {
3415
3415
  }
3416
3416
  &[open]:not([open="false"]):not([edit="false"]) {
3417
3417
  .palette-colors-expanded {
3418
- display: flex;
3418
+ display: grid;
3419
3419
  }
3420
3420
  }
3421
3421
  &[add="false"] {
@@ -3426,7 +3426,7 @@ fig-input-palette {
3426
3426
  }
3427
3427
  .palette-colors-expanded {
3428
3428
  display: none;
3429
- flex-direction: column;
3429
+ grid-template-columns: [input] 1fr [button] 1.5rem;
3430
3430
  overflow: visible;
3431
3431
  border-radius: 0;
3432
3432
  gap: var(--spacer-2);
@@ -3434,6 +3434,16 @@ fig-input-palette {
3434
3434
 
3435
3435
  > fig-input-color {
3436
3436
  min-width: 0;
3437
+ grid-column: input / -1;
3438
+
3439
+ &:has(+ fig-button) {
3440
+ grid-column: input;
3441
+ }
3442
+ }
3443
+
3444
+ > fig-button,
3445
+ > button {
3446
+ grid-column: button;
3437
3447
  }
3438
3448
 
3439
3449
  fig-chit {
@@ -3678,6 +3688,21 @@ fig-segmented-control {
3678
3688
  }
3679
3689
  }
3680
3690
 
3691
+ /* Options */
3692
+ fig-options {
3693
+ display: flex;
3694
+ width: 100%;
3695
+
3696
+ & > fig-segmented-control {
3697
+ flex: 1;
3698
+ min-width: 0;
3699
+ }
3700
+
3701
+ & > fig-dropdown {
3702
+ flex: 1;
3703
+ }
3704
+ }
3705
+
3681
3706
  fig-joystick {
3682
3707
  --size: 100%;
3683
3708
  --aspect-ratio: 1 / 1;
package/fig.js CHANGED
@@ -517,6 +517,8 @@ class FigTooltip extends HTMLElement {
517
517
  #boundHandleTouchMove;
518
518
  #boundHandleTouchEnd;
519
519
  #boundHandleTouchCancel;
520
+ #boundHandleDialogClose;
521
+ #parentDialog = null;
520
522
  #touchTimeout;
521
523
  #isTouching = false;
522
524
  #observer = null;
@@ -535,10 +537,15 @@ class FigTooltip extends HTMLElement {
535
537
  this.#boundHandleTouchMove = this.#handleTouchMove.bind(this);
536
538
  this.#boundHandleTouchEnd = this.#handleTouchEnd.bind(this);
537
539
  this.#boundHandleTouchCancel = this.#handleTouchCancel.bind(this);
540
+ this.#boundHandleDialogClose = () => this.hidePopup();
538
541
  }
539
542
  connectedCallback() {
540
543
  this.setup();
541
544
  this.setupEventListeners();
545
+ this.#parentDialog = this.closest("dialog");
546
+ if (this.#parentDialog) {
547
+ this.#parentDialog.addEventListener("close", this.#boundHandleDialogClose);
548
+ }
542
549
  }
543
550
 
544
551
  disconnectedCallback() {
@@ -549,6 +556,10 @@ class FigTooltip extends HTMLElement {
549
556
  true,
550
557
  );
551
558
  this.#stopObserving();
559
+ if (this.#parentDialog) {
560
+ this.#parentDialog.removeEventListener("close", this.#boundHandleDialogClose);
561
+ this.#parentDialog = null;
562
+ }
552
563
 
553
564
  if (this.action === "click") {
554
565
  document.body.removeEventListener(
@@ -1112,6 +1123,7 @@ customElements.define("fig-truncate", FigTruncate);
1112
1123
  * @attr {string} position - Position of the dialog (e.g., "bottom right", "top left", "center center")
1113
1124
  * @attr {string} title - Title text for the auto-generated header. If no fig-header[dialog-header] exists, one is prepended with this title and a close button.
1114
1125
  * @attr {boolean} resizable - Whether the dialog can be manually resized by the user (default: false)
1126
+ * @attr {string} closedby - Controls how the dialog can be dismissed: "any" (default, Escape + light dismiss), "closerequest" (Escape only), "none" (programmatic only)
1115
1127
  */
1116
1128
  class FigDialog extends HTMLDialogElement {
1117
1129
  #isDragging = false;
@@ -1397,7 +1409,7 @@ class FigDialog extends HTMLDialogElement {
1397
1409
  }
1398
1410
 
1399
1411
  static get observedAttributes() {
1400
- return ["modal", "drag", "position", "handle", "title", "resizable"];
1412
+ return ["modal", "drag", "position", "handle", "title", "resizable", "closedby"];
1401
1413
  }
1402
1414
 
1403
1415
  attributeChangedCallback(name, oldValue, newValue) {
@@ -1419,6 +1431,20 @@ class FigDialog extends HTMLDialogElement {
1419
1431
  this.#applyPosition();
1420
1432
  }
1421
1433
 
1434
+ if (name === "modal") {
1435
+ const wasModal = this.modal;
1436
+ this.modal = newValue !== null && newValue !== "false";
1437
+ if (this.open && wasModal !== this.modal) {
1438
+ this.close();
1439
+ if (this.modal) this.showModal();
1440
+ else this.show();
1441
+ }
1442
+ }
1443
+
1444
+ if (name === "closedby") {
1445
+ this.closedby = newValue || "any";
1446
+ }
1447
+
1422
1448
  if (name === "title") {
1423
1449
  const autoHeader = this.querySelector("fig-header[data-auto] h3");
1424
1450
  if (autoHeader) {
@@ -3105,6 +3131,277 @@ class FigSegmentedControl extends HTMLElement {
3105
3131
  }
3106
3132
  customElements.define("fig-segmented-control", FigSegmentedControl);
3107
3133
 
3134
+ /* Options */
3135
+ /**
3136
+ * A responsive option picker that renders as a segmented control by default,
3137
+ * automatically swapping to a dropdown when any label overflows.
3138
+ * @attr {string} options - Comma-separated list of option labels
3139
+ * @attr {string} value - Currently selected value
3140
+ * @attr {boolean} disabled - Disables the control
3141
+ * @attr {boolean} full - Full-width segmented control
3142
+ * @attr {string} sizing - Segment sizing mode: "equal" (default) or "auto"
3143
+ */
3144
+ class FigOptions extends HTMLElement {
3145
+ static get observedAttributes() {
3146
+ return ["options", "value", "disabled", "full", "sizing"];
3147
+ }
3148
+
3149
+ #currentMode = "segments"; // "segments" | "dropdown"
3150
+ #naturalWidth = 0;
3151
+ #resizeObserver = null;
3152
+ #parsedOptions = [];
3153
+ #childControl = null;
3154
+ #suppressEvents = false;
3155
+
3156
+ connectedCallback() {
3157
+ this.#parseOptions();
3158
+ this.#renderSegments();
3159
+ this.#startResizeObserver();
3160
+ requestAnimationFrame(() => {
3161
+ requestAnimationFrame(() => this.#checkOverflow());
3162
+ });
3163
+ }
3164
+
3165
+ disconnectedCallback() {
3166
+ this.#resizeObserver?.disconnect();
3167
+ this.#resizeObserver = null;
3168
+ }
3169
+
3170
+ get value() {
3171
+ return this.getAttribute("value") || "";
3172
+ }
3173
+
3174
+ set value(val) {
3175
+ if (val === null || val === undefined) {
3176
+ this.removeAttribute("value");
3177
+ } else {
3178
+ this.setAttribute("value", String(val));
3179
+ }
3180
+ }
3181
+
3182
+ get options() {
3183
+ return this.#parsedOptions.slice();
3184
+ }
3185
+
3186
+ set options(val) {
3187
+ if (Array.isArray(val)) {
3188
+ const hasComma = val.some((v) => String(v).includes(","));
3189
+ const str = hasComma ? JSON.stringify(val) : val.join(",");
3190
+ this.setAttribute("options", str);
3191
+ } else {
3192
+ this.setAttribute("options", String(val || ""));
3193
+ }
3194
+ }
3195
+
3196
+ attributeChangedCallback(name, oldValue, newValue) {
3197
+ if (oldValue === newValue) return;
3198
+
3199
+ if (name === "options") {
3200
+ this.#parseOptions();
3201
+ this.#rebuildCurrentControl();
3202
+ return;
3203
+ }
3204
+
3205
+ if (name === "value") {
3206
+ this.#syncValueToChild();
3207
+ return;
3208
+ }
3209
+
3210
+ if (name === "disabled") {
3211
+ this.#syncAttrToChild("disabled");
3212
+ return;
3213
+ }
3214
+
3215
+ if (name === "full") {
3216
+ this.#syncAttrToChild("full");
3217
+ return;
3218
+ }
3219
+
3220
+ if (name === "sizing") {
3221
+ this.#syncAttrToChild("sizing");
3222
+ this.#rebuildCurrentControl();
3223
+ }
3224
+ }
3225
+
3226
+ #parseOptions() {
3227
+ const raw = this.getAttribute("options") || "";
3228
+ if (raw.startsWith("[")) {
3229
+ try { this.#parsedOptions = JSON.parse(raw); return; } catch {}
3230
+ }
3231
+ const delimiter = raw.includes("\n") ? "\n" : ",";
3232
+ this.#parsedOptions = raw.split(delimiter).map((s) => s.trim()).filter(Boolean);
3233
+ }
3234
+
3235
+ #renderSegments() {
3236
+ this.innerHTML = "";
3237
+ if (this.#parsedOptions.length === 0) return;
3238
+
3239
+ const sc = document.createElement("fig-segmented-control");
3240
+ sc.setAttribute("sizing", this.getAttribute("sizing") || "equal");
3241
+
3242
+ if (this.hasAttribute("disabled")) sc.setAttribute("disabled", "");
3243
+ if (this.hasAttribute("full")) sc.setAttribute("full", "");
3244
+
3245
+ const currentValue = this.getAttribute("value");
3246
+ let hasSelection = false;
3247
+
3248
+ for (const opt of this.#parsedOptions) {
3249
+ const seg = document.createElement("fig-segment");
3250
+ seg.setAttribute("value", opt);
3251
+ seg.textContent = opt;
3252
+ if (currentValue === opt) {
3253
+ seg.setAttribute("selected", "true");
3254
+ hasSelection = true;
3255
+ }
3256
+ sc.appendChild(seg);
3257
+ }
3258
+
3259
+ if (currentValue) sc.setAttribute("value", currentValue);
3260
+
3261
+ sc.addEventListener("input", (e) => {
3262
+ if (this.#suppressEvents) return;
3263
+ this.#suppressEvents = true;
3264
+ this.setAttribute("value", e.detail);
3265
+ this.#suppressEvents = false;
3266
+ this.dispatchEvent(
3267
+ new CustomEvent("input", { detail: e.detail, bubbles: true }),
3268
+ );
3269
+ });
3270
+ sc.addEventListener("change", (e) => {
3271
+ if (this.#suppressEvents) return;
3272
+ this.dispatchEvent(
3273
+ new CustomEvent("change", { detail: e.detail, bubbles: true }),
3274
+ );
3275
+ });
3276
+
3277
+ this.appendChild(sc);
3278
+ this.#childControl = sc;
3279
+ this.#currentMode = "segments";
3280
+ }
3281
+
3282
+ #renderDropdown() {
3283
+ this.innerHTML = "";
3284
+ if (this.#parsedOptions.length === 0) return;
3285
+
3286
+ const dd = document.createElement("fig-dropdown");
3287
+ if (this.hasAttribute("disabled")) dd.setAttribute("disabled", "");
3288
+
3289
+ const currentValue = this.getAttribute("value");
3290
+
3291
+ for (const opt of this.#parsedOptions) {
3292
+ const option = document.createElement("option");
3293
+ option.value = opt;
3294
+ option.textContent = opt;
3295
+ if (currentValue === opt) option.selected = true;
3296
+ dd.appendChild(option);
3297
+ }
3298
+
3299
+ if (currentValue) dd.setAttribute("value", currentValue);
3300
+
3301
+ dd.addEventListener("input", (e) => {
3302
+ if (this.#suppressEvents) return;
3303
+ this.#suppressEvents = true;
3304
+ this.setAttribute("value", e.detail);
3305
+ this.#suppressEvents = false;
3306
+ this.dispatchEvent(
3307
+ new CustomEvent("input", { detail: e.detail, bubbles: true }),
3308
+ );
3309
+ });
3310
+ dd.addEventListener("change", (e) => {
3311
+ if (this.#suppressEvents) return;
3312
+ this.dispatchEvent(
3313
+ new CustomEvent("change", { detail: e.detail, bubbles: true }),
3314
+ );
3315
+ });
3316
+
3317
+ this.appendChild(dd);
3318
+ this.#childControl = dd;
3319
+ this.#currentMode = "dropdown";
3320
+ }
3321
+
3322
+ #rebuildCurrentControl() {
3323
+ if (this.#currentMode === "segments") {
3324
+ this.#renderSegments();
3325
+ requestAnimationFrame(() => {
3326
+ requestAnimationFrame(() => this.#checkOverflow());
3327
+ });
3328
+ } else {
3329
+ this.#renderDropdown();
3330
+ }
3331
+ }
3332
+
3333
+ #syncValueToChild() {
3334
+ if (!this.#childControl || this.#suppressEvents) return;
3335
+ const val = this.getAttribute("value") || "";
3336
+ this.#childControl.value = val;
3337
+ }
3338
+
3339
+ #syncAttrToChild(attr) {
3340
+ if (!this.#childControl) return;
3341
+ if (this.hasAttribute(attr)) {
3342
+ this.#childControl.setAttribute(attr, this.getAttribute(attr) || "");
3343
+ } else {
3344
+ this.#childControl.removeAttribute(attr);
3345
+ }
3346
+ }
3347
+
3348
+ #startResizeObserver() {
3349
+ this.#resizeObserver?.disconnect();
3350
+ this.#resizeObserver = new ResizeObserver(() => {
3351
+ this.#checkOverflow();
3352
+ });
3353
+ this.#resizeObserver.observe(this);
3354
+ }
3355
+
3356
+ #isSegmentTruncated(seg) {
3357
+ const range = document.createRange();
3358
+ range.selectNodeContents(seg);
3359
+ const textWidth = range.getBoundingClientRect().width;
3360
+ const segRect = seg.getBoundingClientRect();
3361
+ const segWidth = segRect.width;
3362
+ const cs = getComputedStyle(seg);
3363
+ const padL = parseFloat(cs.paddingLeft) || 0;
3364
+ const padR = parseFloat(cs.paddingRight) || 0;
3365
+ const contentWidth = segWidth - padL - padR;
3366
+ return textWidth > contentWidth + 0.5;
3367
+ }
3368
+
3369
+ #anySegmentTruncated() {
3370
+ const segments = this.querySelectorAll("fig-segment");
3371
+ for (const seg of segments) {
3372
+ if (this.#isSegmentTruncated(seg)) return true;
3373
+ }
3374
+ return false;
3375
+ }
3376
+
3377
+ #checkOverflow() {
3378
+ if (this.#parsedOptions.length <= 1) return;
3379
+
3380
+ if (this.#currentMode === "segments") {
3381
+ const sc = this.#childControl;
3382
+ const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
3383
+ if (containerOverflow || this.#anySegmentTruncated()) {
3384
+ this.#naturalWidth = this.clientWidth;
3385
+ this.#renderDropdown();
3386
+ }
3387
+ } else {
3388
+ if (this.#naturalWidth > 0 && this.clientWidth >= this.#naturalWidth) {
3389
+ this.#renderSegments();
3390
+ requestAnimationFrame(() => {
3391
+ requestAnimationFrame(() => {
3392
+ const sc = this.#childControl;
3393
+ const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
3394
+ if (containerOverflow || this.#anySegmentTruncated()) {
3395
+ this.#renderDropdown();
3396
+ }
3397
+ });
3398
+ });
3399
+ }
3400
+ }
3401
+ }
3402
+ }
3403
+ customElements.define("fig-options", FigOptions);
3404
+
3108
3405
  /* Slider */
3109
3406
  /**
3110
3407
  * A custom slider input element.
@@ -6139,6 +6436,7 @@ class FigInputPalette extends HTMLElement {
6139
6436
  expandedWrap.className = "palette-colors-expanded";
6140
6437
  this.#colors.forEach((entry, i) => {
6141
6438
  expandedWrap.appendChild(this.#createPicker(entry, i, disabled));
6439
+ expandedWrap.appendChild(this.#createRemoveButton(i, disabled));
6142
6440
  });
6143
6441
  this.appendChild(expandedWrap);
6144
6442
  }
@@ -6203,6 +6501,31 @@ class FigInputPalette extends HTMLElement {
6203
6501
  return ic;
6204
6502
  }
6205
6503
 
6504
+ #createRemoveButton(index, disabled) {
6505
+ const btn = document.createElement("fig-button");
6506
+ btn.setAttribute("variant", "ghost");
6507
+ btn.setAttribute("icon", "true");
6508
+ btn.setAttribute("aria-label", "Remove color");
6509
+ btn.className = "palette-remove-btn";
6510
+ if (disabled || this.#colors.length <= this.#min) btn.setAttribute("disabled", "");
6511
+ btn.innerHTML = `<span class="fig-mask-icon" style="--icon: var(--icon-minus)"></span>`;
6512
+ btn.addEventListener("click", () => {
6513
+ if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
6514
+ this.#removeColor(index);
6515
+ });
6516
+ return btn;
6517
+ }
6518
+
6519
+ #removeColor(index) {
6520
+ if (index < 0 || index >= this.#colors.length) return;
6521
+ if (this.#colors.length <= this.#min) return;
6522
+ this.#colors.splice(index, 1);
6523
+ this.#inlinePickers = [];
6524
+ this.#expandedPickers = [];
6525
+ this.#render();
6526
+ this.#emitChange();
6527
+ }
6528
+
6206
6529
  #createAddButton(disabled, parent = this) {
6207
6530
  const atMax = this.#colors.length >= this.#max;
6208
6531
  const addBtn = document.createElement("fig-button");
@@ -6242,7 +6565,10 @@ class FigInputPalette extends HTMLElement {
6242
6565
 
6243
6566
  const expandedIc = this.#createPicker(entry, index, disabled);
6244
6567
  const expandedWrap = this.querySelector(".palette-colors-expanded");
6245
- if (expandedWrap) expandedWrap.appendChild(expandedIc);
6568
+ if (expandedWrap) {
6569
+ expandedWrap.appendChild(expandedIc);
6570
+ expandedWrap.appendChild(this.#createRemoveButton(index, disabled));
6571
+ }
6246
6572
 
6247
6573
  if (this.#colors.length >= this.#max) {
6248
6574
  const addBtn = this.querySelector(".palette-add-btn");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "4.2.0",
3
+ "version": "4.3.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",