@rogieking/figui3 11.5.0 → 11.6.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.js CHANGED
@@ -2907,7 +2907,11 @@ class FigPopup extends HTMLDialogElement {
2907
2907
  _wasDragged = false;
2908
2908
  _previousFocus = null;
2909
2909
  _boundDocumentKeydown;
2910
+ _boundDocumentPointerUp;
2910
2911
  _boundNativeClose;
2912
+ _boundNativeCancel;
2913
+ _suppressNativeCancel = false;
2914
+ _nativeCancelResetTimer = null;
2911
2915
 
2912
2916
  constructor() {
2913
2917
  super();
@@ -2929,7 +2933,10 @@ class FigPopup extends HTMLDialogElement {
2929
2933
  this._boundPointerMove = this.handlePointerMove.bind(this);
2930
2934
  this._boundPointerUp = this.handlePointerUp.bind(this);
2931
2935
  this._boundDocumentKeydown = this.handleDocumentKeydown.bind(this);
2936
+ this._boundDocumentPointerUp =
2937
+ this.handleDocumentPointerUp.bind(this);
2932
2938
  this._boundNativeClose = this.handleNativeClose.bind(this);
2939
+ this._boundNativeCancel = this.handleNativeCancel.bind(this);
2933
2940
  this._boundAutoClose = () => {
2934
2941
  this.open = false;
2935
2942
  };
@@ -2960,6 +2967,10 @@ class FigPopup extends HTMLDialogElement {
2960
2967
  if (typeof this._dragThreshold !== "number") this._dragThreshold = 3;
2961
2968
  if (typeof this._wasDragged === "undefined") this._wasDragged = false;
2962
2969
  if (typeof this._previousFocus === "undefined") this._previousFocus = null;
2970
+ if (typeof this._suppressNativeCancel === "undefined")
2971
+ this._suppressNativeCancel = false;
2972
+ if (typeof this._nativeCancelResetTimer === "undefined")
2973
+ this._nativeCancelResetTimer = null;
2963
2974
 
2964
2975
  if (typeof this._boundReposition !== "function") {
2965
2976
  this._boundReposition = () => this.queueReposition();
@@ -2995,9 +3006,16 @@ class FigPopup extends HTMLDialogElement {
2995
3006
  if (typeof this._boundDocumentKeydown !== "function") {
2996
3007
  this._boundDocumentKeydown = this.handleDocumentKeydown.bind(this);
2997
3008
  }
3009
+ if (typeof this._boundDocumentPointerUp !== "function") {
3010
+ this._boundDocumentPointerUp =
3011
+ this.handleDocumentPointerUp.bind(this);
3012
+ }
2998
3013
  if (typeof this._boundNativeClose !== "function") {
2999
3014
  this._boundNativeClose = this.handleNativeClose.bind(this);
3000
3015
  }
3016
+ if (typeof this._boundNativeCancel !== "function") {
3017
+ this._boundNativeCancel = this.handleNativeCancel.bind(this);
3018
+ }
3001
3019
  if (typeof this._boundAutoClose !== "function") {
3002
3020
  this._boundAutoClose = () => {
3003
3021
  this.open = false;
@@ -3092,6 +3110,8 @@ class FigPopup extends HTMLDialogElement {
3092
3110
 
3093
3111
  this.removeEventListener("close", this._boundNativeClose);
3094
3112
  this.addEventListener("close", this._boundNativeClose);
3113
+ this.removeEventListener("cancel", this._boundNativeCancel);
3114
+ this.addEventListener("cancel", this._boundNativeCancel);
3095
3115
 
3096
3116
  requestAnimationFrame(() => {
3097
3117
  this.setupDragListeners();
@@ -3114,7 +3134,22 @@ class FigPopup extends HTMLDialogElement {
3114
3134
  true,
3115
3135
  );
3116
3136
  document.removeEventListener("keydown", this._boundDocumentKeydown, true);
3137
+ document.removeEventListener(
3138
+ "pointerup",
3139
+ this._boundDocumentPointerUp,
3140
+ true,
3141
+ );
3142
+ document.removeEventListener(
3143
+ "pointercancel",
3144
+ this._boundDocumentPointerUp,
3145
+ true,
3146
+ );
3117
3147
  this.removeEventListener("close", this._boundNativeClose);
3148
+ this.removeEventListener("cancel", this._boundNativeCancel);
3149
+ if (this._nativeCancelResetTimer !== null) {
3150
+ clearTimeout(this._nativeCancelResetTimer);
3151
+ this._nativeCancelResetTimer = null;
3152
+ }
3118
3153
  if (this._rafId !== null) {
3119
3154
  cancelAnimationFrame(this._rafId);
3120
3155
  this._rafId = null;
@@ -3210,6 +3245,16 @@ class FigPopup extends HTMLDialogElement {
3210
3245
  true,
3211
3246
  );
3212
3247
  document.addEventListener("keydown", this._boundDocumentKeydown, true);
3248
+ document.addEventListener(
3249
+ "pointerup",
3250
+ this._boundDocumentPointerUp,
3251
+ true,
3252
+ );
3253
+ document.addEventListener(
3254
+ "pointercancel",
3255
+ this._boundDocumentPointerUp,
3256
+ true,
3257
+ );
3213
3258
  this._wasDragged = false;
3214
3259
  this.queueReposition();
3215
3260
  this._isPopupActive = true;
@@ -3241,6 +3286,21 @@ class FigPopup extends HTMLDialogElement {
3241
3286
  true,
3242
3287
  );
3243
3288
  document.removeEventListener("keydown", this._boundDocumentKeydown, true);
3289
+ document.removeEventListener(
3290
+ "pointerup",
3291
+ this._boundDocumentPointerUp,
3292
+ true,
3293
+ );
3294
+ document.removeEventListener(
3295
+ "pointercancel",
3296
+ this._boundDocumentPointerUp,
3297
+ true,
3298
+ );
3299
+ this._suppressNativeCancel = false;
3300
+ if (this._nativeCancelResetTimer !== null) {
3301
+ clearTimeout(this._nativeCancelResetTimer);
3302
+ this._nativeCancelResetTimer = null;
3303
+ }
3244
3304
 
3245
3305
  if (
3246
3306
  this.hasAttribute("popover") &&
@@ -3317,6 +3377,15 @@ class FigPopup extends HTMLDialogElement {
3317
3377
  if (this.hasAttribute("open")) this.removeAttribute("open");
3318
3378
  }
3319
3379
 
3380
+ handleNativeCancel(event) {
3381
+ if (
3382
+ this._suppressNativeCancel &&
3383
+ this.getAttribute("closedby") === "any"
3384
+ ) {
3385
+ event.preventDefault();
3386
+ }
3387
+ }
3388
+
3320
3389
  setupObservers() {
3321
3390
  this.teardownObservers();
3322
3391
 
@@ -3462,26 +3531,55 @@ class FigPopup extends HTMLDialogElement {
3462
3531
  // counts as inside — Node.contains() is false for slotted nodes.
3463
3532
  const path =
3464
3533
  typeof event.composedPath === "function" ? event.composedPath() : [];
3465
- if (path.includes(this)) return;
3466
- if (this.contains(target)) return;
3534
+ if (path.includes(this) || this.contains(target)) {
3535
+ this.markOwnedPointerInteraction();
3536
+ return;
3537
+ }
3467
3538
 
3468
3539
  const anchor = this.resolveAnchor();
3469
- if (anchor && (path.includes(anchor) || anchor.contains?.(target))) return;
3540
+ if (anchor && (path.includes(anchor) || anchor.contains?.(target))) {
3541
+ this.markOwnedPointerInteraction();
3542
+ return;
3543
+ }
3470
3544
 
3471
- if (this.isInsideDescendantPopup(target)) return;
3545
+ if (this.isInsideDescendantPopup(target, path)) {
3546
+ this.markOwnedPointerInteraction();
3547
+ return;
3548
+ }
3472
3549
 
3550
+ this._suppressNativeCancel = false;
3473
3551
  this.open = false;
3474
3552
  }
3475
3553
 
3554
+ markOwnedPointerInteraction() {
3555
+ this._suppressNativeCancel = true;
3556
+ if (this._nativeCancelResetTimer !== null) {
3557
+ clearTimeout(this._nativeCancelResetTimer);
3558
+ this._nativeCancelResetTimer = null;
3559
+ }
3560
+ }
3561
+
3562
+ handleDocumentPointerUp() {
3563
+ if (!this._suppressNativeCancel) return;
3564
+ if (this._nativeCancelResetTimer !== null) {
3565
+ clearTimeout(this._nativeCancelResetTimer);
3566
+ }
3567
+ // Native dialog light-dismiss runs after pointer dispatch. Keep the guard
3568
+ // through that algorithm, then allow later requestClose() calls normally.
3569
+ this._nativeCancelResetTimer = setTimeout(() => {
3570
+ this._suppressNativeCancel = false;
3571
+ this._nativeCancelResetTimer = null;
3572
+ }, 0);
3573
+ }
3574
+
3476
3575
  handleDocumentKeydown(event) {
3477
3576
  if (event.key !== "Escape" || event.defaultPrevented) return;
3478
3577
  if (!this.open) return;
3479
3578
  if (this.getAttribute("role") === "menu") return;
3480
3579
  const closedby = this.getAttribute("closedby");
3481
3580
  if (closedby === "none") return;
3482
- const openPopups = Array.from(
3483
- document.querySelectorAll('dialog[is="fig-popup"][open]'),
3484
- ).filter((popup) => popup.open);
3581
+ const openPopups = Array.from(document.querySelectorAll("dialog[open]"))
3582
+ .filter((popup) => popup instanceof FigPopup && popup.open);
3485
3583
  const topPopup = openPopups
3486
3584
  .map((popup) => ({
3487
3585
  popup,
@@ -3494,8 +3592,40 @@ class FigPopup extends HTMLDialogElement {
3494
3592
  this.open = false;
3495
3593
  }
3496
3594
 
3497
- isInsideDescendantPopup(target) {
3498
- const targetDialog = target.closest?.('dialog[is="fig-popup"]');
3595
+ getContainingPopup(node) {
3596
+ let current = node;
3597
+ const visited = new Set();
3598
+ while (current && !visited.has(current)) {
3599
+ visited.add(current);
3600
+ if (current instanceof FigPopup) return current;
3601
+ current =
3602
+ current.parentNode ??
3603
+ (current.getRootNode?.() instanceof ShadowRoot
3604
+ ? current.getRootNode().host
3605
+ : null);
3606
+ }
3607
+ return null;
3608
+ }
3609
+
3610
+ composedContains(node) {
3611
+ let current = node;
3612
+ const visited = new Set();
3613
+ while (current && !visited.has(current)) {
3614
+ if (current === this) return true;
3615
+ visited.add(current);
3616
+ current =
3617
+ current.parentNode ??
3618
+ (current.getRootNode?.() instanceof ShadowRoot
3619
+ ? current.getRootNode().host
3620
+ : null);
3621
+ }
3622
+ return false;
3623
+ }
3624
+
3625
+ isInsideDescendantPopup(target, path = []) {
3626
+ const targetDialog =
3627
+ path.find((node) => node instanceof FigPopup) ??
3628
+ this.getContainingPopup(target);
3499
3629
  if (!targetDialog || targetDialog === this) return false;
3500
3630
 
3501
3631
  let current = targetDialog;
@@ -3504,8 +3634,8 @@ class FigPopup extends HTMLDialogElement {
3504
3634
  visited.add(current);
3505
3635
  const popupAnchor = current.anchor;
3506
3636
  if (!(popupAnchor instanceof Element)) break;
3507
- if (this.contains(popupAnchor)) return true;
3508
- current = popupAnchor.closest?.('dialog[is="fig-popup"]');
3637
+ if (this.composedContains(popupAnchor)) return true;
3638
+ current = this.getContainingPopup(popupAnchor);
3509
3639
  }
3510
3640
  return false;
3511
3641
  }
@@ -10889,7 +11019,6 @@ class FigInputPalette extends HTMLElement {
10889
11019
  #renderRAF = null;
10890
11020
  #pickerDefinitionPromise = null;
10891
11021
  #pickerPopup = null;
10892
- #pickerEditor = null;
10893
11022
  #syncingValueAttribute = false;
10894
11023
  #boundHandleKeyDown = this.#handleKeyDown.bind(this);
10895
11024
  #boundHandleHostFocus = () => this.focus();
@@ -11168,13 +11297,13 @@ class FigInputPalette extends HTMLElement {
11168
11297
  #renderPickerWhenAvailable() {
11169
11298
  if (
11170
11299
  this.#editMode !== "picker" ||
11171
- customElements.get("fig-palette-editor")
11300
+ customElements.get("fig-popup-palette")
11172
11301
  ) {
11173
11302
  this.#syncPickerOpenState();
11174
11303
  return;
11175
11304
  }
11176
11305
  this.#pickerDefinitionPromise ??= customElements
11177
- .whenDefined("fig-palette-editor")
11306
+ .whenDefined("fig-popup-palette")
11178
11307
  .then(() => {
11179
11308
  if (!this.isConnected || this.#editMode !== "picker") return;
11180
11309
  this.#syncPickerOpenState();
@@ -11183,28 +11312,16 @@ class FigInputPalette extends HTMLElement {
11183
11312
 
11184
11313
  #ensurePickerPopup() {
11185
11314
  if (this.#pickerPopup?.isConnected) return this.#pickerPopup;
11186
- if (!customElements.get("fig-palette-editor")) return null;
11315
+ if (!customElements.get("fig-popup-palette")) return null;
11187
11316
 
11188
- const popup = document.createElement("dialog", { is: "fig-popup" });
11189
- popup.setAttribute("is", "fig-popup");
11190
- popup.setAttribute("aria-label", "Edit palette");
11191
- popup.setAttribute("position", "bottom left");
11192
- popup.setAttribute("offset", "0 8");
11193
- popup.setAttribute("viewport-margin", "8");
11194
- popup.setAttribute("popover", "manual");
11195
- popup.className = "fig-palette-editor-popup";
11317
+ const popup = document.createElement("dialog", {
11318
+ is: "fig-popup-palette",
11319
+ });
11320
+ popup.setAttribute("is", "fig-popup-palette");
11196
11321
  popup.id = figUniqueId();
11197
11322
  popup.anchor = this;
11198
11323
 
11199
- const editor = document.createElement("fig-palette-editor");
11200
- const content = figCreateElement(
11201
- "fig-content",
11202
- { className: "fig-palette-editor-content" },
11203
- editor,
11204
- );
11205
- popup.append(content);
11206
-
11207
- const handleEditorEvent = (event) => {
11324
+ const handlePopupEvent = (event) => {
11208
11325
  event.stopPropagation();
11209
11326
  if (!Array.isArray(event.detail)) return;
11210
11327
  this.#colors = event.detail.map((entry) => ({
@@ -11224,14 +11341,13 @@ class FigInputPalette extends HTMLElement {
11224
11341
  }),
11225
11342
  );
11226
11343
  };
11227
- editor.addEventListener("input", handleEditorEvent);
11228
- editor.addEventListener("change", handleEditorEvent);
11344
+ popup.addEventListener("input", handlePopupEvent);
11345
+ popup.addEventListener("change", handlePopupEvent);
11229
11346
  popup.addEventListener("close", this.#boundPickerClosed);
11230
11347
  popup.addEventListener("toggle", this.#boundPickerClosed);
11231
11348
 
11232
11349
  (figGetOverlayRoot() ?? document.body).append(popup);
11233
11350
  this.#pickerPopup = popup;
11234
- this.#pickerEditor = editor;
11235
11351
  this.querySelector(".palette-colors-inline")?.setAttribute(
11236
11352
  "aria-controls",
11237
11353
  popup.id,
@@ -11241,21 +11357,21 @@ class FigInputPalette extends HTMLElement {
11241
11357
  }
11242
11358
 
11243
11359
  #syncPickerEditor() {
11244
- const editor = this.#pickerEditor;
11245
- if (!editor) return;
11246
- editor.setAttribute("value", JSON.stringify(this.#colors));
11247
- editor.setAttribute("min", String(this.#min));
11248
- editor.setAttribute("max", String(this.#max));
11249
- editor.toggleAttribute("fixed", this.#isFixed);
11360
+ const popup = this.#pickerPopup;
11361
+ if (!popup) return;
11362
+ popup.setAttribute("value", JSON.stringify(this.#colors));
11363
+ popup.setAttribute("min", String(this.#min));
11364
+ popup.setAttribute("max", String(this.#max));
11365
+ popup.toggleAttribute("fixed", this.#isFixed);
11250
11366
  const disabled =
11251
11367
  this.hasAttribute("disabled") &&
11252
11368
  this.getAttribute("disabled") !== "false";
11253
- editor.toggleAttribute("disabled", disabled);
11369
+ popup.toggleAttribute("disabled", disabled);
11254
11370
  }
11255
11371
 
11256
11372
  #syncPickerOpenState() {
11257
11373
  if (!this.isConnected || this.#editMode !== "picker") return;
11258
- if (!customElements.get("fig-palette-editor")) {
11374
+ if (!customElements.get("fig-popup-palette")) {
11259
11375
  this.#renderPickerWhenAvailable();
11260
11376
  return;
11261
11377
  }
@@ -11286,7 +11402,6 @@ class FigInputPalette extends HTMLElement {
11286
11402
  "aria-controls",
11287
11403
  );
11288
11404
  this.#pickerPopup = null;
11289
- this.#pickerEditor = null;
11290
11405
  }
11291
11406
 
11292
11407
  #createPicker(entry, index, disabled, { inline = false } = {}) {
@@ -11545,10 +11660,11 @@ figDefineElement("fig-input-palette", FigInputPalette);
11545
11660
 
11546
11661
  /* Input Gradient */
11547
11662
  /**
11548
- * A gradient-only fill input built on top of fig-fill-picker.
11663
+ * A gradient-only fill input with inline and reusable popup editing modes.
11549
11664
  * @attr {string} value - JSON string with gradient fill data
11550
11665
  * @attr {string} size - Passed through to the internal fig-swatch (e.g. "large")
11551
11666
  * @attr {boolean} disabled - Whether the input is disabled
11667
+ * @attr {string} edit - `"true"` for inline stops, `"false"` for preview, or `"picker"` for fig-popup-gradient
11552
11668
  * @fires input - When the gradient value changes
11553
11669
  * @fires change - When the gradient value is committed
11554
11670
  */
@@ -11561,7 +11677,20 @@ class FigInputGradient extends HTMLElement {
11561
11677
  #colorObserver = null;
11562
11678
  #repositionRAF = null;
11563
11679
  #pickerDefinitionPromise = null;
11680
+ #pickerPopup = null;
11681
+ #syncingValueAttribute = false;
11564
11682
  anchorElement = null;
11683
+ #boundPickerClosed = () => {
11684
+ if (!this.#pickerPopup) return;
11685
+ if (
11686
+ this.#pickerPopup.hasAttribute("open") ||
11687
+ this.#pickerPopup.matches?.(":open") ||
11688
+ this.#pickerPopup.matches?.(":popover-open")
11689
+ ) {
11690
+ return;
11691
+ }
11692
+ this.classList.remove("has-popup-open");
11693
+ };
11565
11694
  #gradient = {
11566
11695
  type: "linear",
11567
11696
  angle: 90,
@@ -11660,15 +11789,22 @@ class FigInputGradient extends HTMLElement {
11660
11789
  this.#parseValue();
11661
11790
  this.#render();
11662
11791
  this.#renderPickerWhenAvailable();
11792
+ this.removeEventListener("click", this.#onPickerClick);
11793
+ this.addEventListener("click", this.#onPickerClick);
11663
11794
  this.removeEventListener("keydown", this.#onPickerKeyDown);
11664
11795
  this.addEventListener("keydown", this.#onPickerKeyDown);
11665
11796
  if (this.#isEditable) document.addEventListener("keydown", this.#onKeyDown);
11666
11797
  }
11667
11798
 
11668
11799
  #renderPickerWhenAvailable() {
11669
- if (this.#editMode !== "picker" || hasFigFillPicker()) return;
11800
+ if (
11801
+ this.#editMode !== "picker" ||
11802
+ customElements.get("fig-popup-gradient")
11803
+ ) {
11804
+ return;
11805
+ }
11670
11806
  this.#pickerDefinitionPromise ??= customElements
11671
- .whenDefined("fig-fill-picker")
11807
+ .whenDefined("fig-popup-gradient")
11672
11808
  .then(() => {
11673
11809
  if (!this.isConnected || this.#editMode !== "picker") return;
11674
11810
  this.#render();
@@ -11677,7 +11813,9 @@ class FigInputGradient extends HTMLElement {
11677
11813
 
11678
11814
  disconnectedCallback() {
11679
11815
  document.removeEventListener("keydown", this.#onKeyDown);
11816
+ this.removeEventListener("click", this.#onPickerClick);
11680
11817
  this.removeEventListener("keydown", this.#onPickerKeyDown);
11818
+ this.#destroyPickerPopup();
11681
11819
  if (this.#colorObserver) {
11682
11820
  this.#colorObserver.disconnect();
11683
11821
  this.#colorObserver = null;
@@ -11785,10 +11923,18 @@ class FigInputGradient extends HTMLElement {
11785
11923
  if (e.key !== "Enter" && e.key !== " ") return;
11786
11924
  if (e.altKey || e.ctrlKey || e.metaKey) return;
11787
11925
  if (figBooleanAttribute(this, "disabled")) return;
11788
- const picker = this.querySelector("fig-fill-picker");
11789
- if (!picker || typeof picker.open !== "function") return;
11790
11926
  e.preventDefault();
11791
- picker.open();
11927
+ this.#openPickerPopup();
11928
+ };
11929
+
11930
+ #onPickerClick = () => {
11931
+ if (
11932
+ this.#editMode !== "picker" ||
11933
+ figBooleanAttribute(this, "disabled")
11934
+ ) {
11935
+ return;
11936
+ }
11937
+ this.#openPickerPopup();
11792
11938
  };
11793
11939
 
11794
11940
  #parseValue() {
@@ -11900,25 +12046,16 @@ class FigInputGradient extends HTMLElement {
11900
12046
  disabled,
11901
12047
  });
11902
12048
 
11903
- if (mode === "picker" && hasFigFillPicker()) {
11904
- const gradientValue = JSON.stringify(this.value);
11905
- const picker = figCreateElement(
11906
- "fig-fill-picker",
11907
- {
11908
- mode: "gradient",
11909
- value: gradientValue,
11910
- disabled,
11911
- },
11912
- swatch,
11913
- );
11914
- this.replaceChildren(picker);
12049
+ if (mode === "picker") {
12050
+ this.setAttribute("aria-haspopup", "dialog");
12051
+ this.replaceChildren(swatch);
11915
12052
  this.#swatch = swatch;
11916
12053
  this.#track = null;
11917
- this.#setupPickerEvents();
11918
12054
  this.#syncFocusTarget();
11919
12055
  return;
11920
12056
  }
11921
12057
 
12058
+ this.removeAttribute("aria-haspopup");
11922
12059
  const editable = mode === "true" || mode === "picker";
11923
12060
  const track = editable
11924
12061
  ? figCreateElement(
@@ -11938,36 +12075,83 @@ class FigInputGradient extends HTMLElement {
11938
12075
  this.#syncFocusTarget();
11939
12076
  }
11940
12077
 
11941
- #setupPickerEvents() {
11942
- const picker = this.querySelector("fig-fill-picker");
11943
- if (!picker) return;
11944
- picker.anchorElement = this.anchorElement || this;
12078
+ #ensurePickerPopup() {
12079
+ if (this.#pickerPopup?.isConnected) return this.#pickerPopup;
12080
+ if (!customElements.get("fig-popup-gradient")) return null;
12081
+
12082
+ const popup = document.createElement("dialog", {
12083
+ is: "fig-popup-gradient",
12084
+ });
12085
+ popup.setAttribute("is", "fig-popup-gradient");
12086
+ popup.id = figUniqueId();
12087
+ popup.anchor = this.anchorElement || this;
11945
12088
 
11946
- const syncFromPicker = (e) => {
11947
- e.stopPropagation();
11948
- const detail = e.detail;
11949
- if (!detail?.gradient) return;
12089
+ const handlePopupEvent = (event) => {
12090
+ event.stopPropagation();
12091
+ if (!event.detail?.gradient) return;
11950
12092
  this.#gradient = this.#normalizeGradient({
11951
12093
  ...this.#gradient,
11952
- ...detail.gradient,
12094
+ ...event.detail.gradient,
11953
12095
  });
11954
- if (this.#editMode === "picker" && typeof detail.css === "string" && detail.css) {
11955
- this.#swatch?.setAttribute("background", detail.css);
12096
+ this.#syncingValueAttribute = true;
12097
+ this.setAttribute("value", JSON.stringify(this.value));
12098
+ this.#syncingValueAttribute = false;
12099
+ if (typeof event.detail.css === "string" && event.detail.css) {
12100
+ this.#swatch?.setAttribute("background", event.detail.css);
11956
12101
  this.#syncSwatchSize();
11957
- return;
12102
+ } else {
12103
+ this.#syncSwatch();
11958
12104
  }
11959
- this.#syncSwatch();
12105
+ this.dispatchEvent(
12106
+ new CustomEvent(event.type, {
12107
+ bubbles: true,
12108
+ composed: true,
12109
+ detail: this.value,
12110
+ }),
12111
+ );
11960
12112
  };
12113
+ popup.addEventListener("input", handlePopupEvent);
12114
+ popup.addEventListener("change", handlePopupEvent);
12115
+ popup.addEventListener("close", this.#boundPickerClosed);
12116
+ popup.addEventListener("toggle", this.#boundPickerClosed);
11961
12117
 
11962
- picker.addEventListener("input", (e) => {
11963
- syncFromPicker(e);
11964
- this.#emitInput();
11965
- });
12118
+ (figGetOverlayRoot() ?? document.body).append(popup);
12119
+ this.#pickerPopup = popup;
12120
+ this.setAttribute("aria-controls", popup.id);
12121
+ this.#syncPickerPopup();
12122
+ return popup;
12123
+ }
11966
12124
 
11967
- picker.addEventListener("change", (e) => {
11968
- syncFromPicker(e);
11969
- this.#emitChange();
11970
- });
12125
+ #syncPickerPopup() {
12126
+ if (!this.#pickerPopup) return;
12127
+ this.#pickerPopup.setAttribute("value", JSON.stringify(this.#gradient));
12128
+ this.#pickerPopup.toggleAttribute(
12129
+ "disabled",
12130
+ figBooleanAttribute(this, "disabled"),
12131
+ );
12132
+ this.#pickerPopup.anchor = this.anchorElement || this;
12133
+ }
12134
+
12135
+ #openPickerPopup() {
12136
+ const popup = this.#ensurePickerPopup();
12137
+ if (!popup) {
12138
+ this.#renderPickerWhenAvailable();
12139
+ return;
12140
+ }
12141
+ this.#syncPickerPopup();
12142
+ popup.open = true;
12143
+ this.classList.add("has-popup-open");
12144
+ }
12145
+
12146
+ #destroyPickerPopup() {
12147
+ if (!this.#pickerPopup) return;
12148
+ this.#pickerPopup.removeEventListener("close", this.#boundPickerClosed);
12149
+ this.#pickerPopup.removeEventListener("toggle", this.#boundPickerClosed);
12150
+ this.#pickerPopup.open = false;
12151
+ this.#pickerPopup.remove();
12152
+ this.#pickerPopup = null;
12153
+ this.removeAttribute("aria-controls");
12154
+ this.classList.remove("has-popup-open");
11971
12155
  }
11972
12156
 
11973
12157
  #sampleGradientColor(position) {
@@ -12253,7 +12437,13 @@ class FigInputGradient extends HTMLElement {
12253
12437
 
12254
12438
  this.#track.addEventListener("pointerdown", (e) => {
12255
12439
  if (figBooleanAttribute(this, "disabled")) return;
12256
- if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) return;
12440
+ const handle = e.target.closest(
12441
+ "fig-handle:not(.fig-input-gradient-ghost)",
12442
+ );
12443
+ if (handle) {
12444
+ this.#activateStopHandle(handle);
12445
+ return;
12446
+ }
12257
12447
  if (e.button !== 0) return;
12258
12448
  e.preventDefault();
12259
12449
  e.stopPropagation();
@@ -12487,17 +12677,20 @@ class FigInputGradient extends HTMLElement {
12487
12677
  }
12488
12678
 
12489
12679
  attributeChangedCallback(name, oldValue, newValue) {
12490
- if (oldValue === newValue) return;
12680
+ if (oldValue === newValue || this.#syncingValueAttribute) return;
12491
12681
  switch (name) {
12492
12682
  case "value":
12493
12683
  this.#parseValue();
12494
12684
  this.#syncSwatch();
12495
12685
  this.#syncHandles();
12686
+ this.#syncPickerPopup();
12496
12687
  break;
12497
12688
  case "disabled":
12498
12689
  this.#syncDisabled();
12690
+ this.#syncPickerPopup();
12499
12691
  break;
12500
12692
  case "edit":
12693
+ if (this.#editMode !== "picker") this.#destroyPickerPopup();
12501
12694
  this.#render();
12502
12695
  this.#renderPickerWhenAvailable();
12503
12696
  if (this.#isEditable) {
@@ -15955,7 +16148,7 @@ class FigInputFile extends HTMLElement {
15955
16148
  ),
15956
16149
  )
15957
16150
  ) {
15958
- return "sound";
16151
+ return "audio";
15959
16152
  }
15960
16153
  if (
15961
16154
  files.every((file) =>
@@ -15984,7 +16177,7 @@ class FigInputFile extends HTMLElement {
15984
16177
  FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15985
16178
  )
15986
16179
  ) {
15987
- return "sound";
16180
+ return "audio";
15988
16181
  }
15989
16182
  if (
15990
16183
  this.#hasKnownExtension(
@@ -16143,11 +16336,13 @@ class FigInputFile extends HTMLElement {
16143
16336
  this.#audioIcon = null;
16144
16337
  this.#audioWaveform = null;
16145
16338
  this.#audioPlaying = false;
16339
+ this.removeAttribute("data-audio-playing");
16146
16340
  }
16147
16341
 
16148
16342
  #setAudioPlaybackUi(playing) {
16149
16343
  this.#audioPlaying = playing;
16150
16344
  this.#audioIcon?.setAttribute("name", playing ? "pause" : "play");
16345
+ this.toggleAttribute("data-audio-playing", playing);
16151
16346
  this.#uploadBtn?.toggleAttribute("selected", playing);
16152
16347
  this.#uploadBtn?.setAttribute(
16153
16348
  "aria-label",
@@ -16351,26 +16546,27 @@ class FigInputFile extends HTMLElement {
16351
16546
  ? this.#createMultipleFileIcon(this.#files.length)
16352
16547
  : showsImagePreview
16353
16548
  ? this.#createImagePreviewSwatch()
16354
- : createFigIcon(
16355
- showsAudioPreview ? "play" : this.#selectedFileIcon(),
16356
- );
16549
+ : createFigIcon(this.#selectedFileIcon());
16550
+ const truncEl = document.createElement("fig-truncate");
16551
+ truncEl.setAttribute("position", "middle");
16552
+ truncEl.setAttribute("tooltip", "");
16553
+ const buttonLabel = showsMultiplePreview
16554
+ ? "Multiple files"
16555
+ : filename;
16556
+ const dotIdx = buttonLabel.lastIndexOf(".");
16557
+ if (!showsMultiplePreview && dotIdx > 0) {
16558
+ truncEl.setAttribute("tail", buttonLabel.slice(dotIdx));
16559
+ }
16560
+ truncEl.textContent = buttonLabel;
16561
+ fileIcon.setAttribute("slot", "prepend");
16357
16562
  if (showsAudioPreview) {
16358
- this.#audioIcon = fileIcon;
16359
- fileIcon.setAttribute("slot", "prepend");
16360
- this.#uploadBtn.append(fileIcon);
16563
+ fileIcon.classList.add("fig-input-file-audio-icon");
16564
+ truncEl.classList.add("fig-input-file-audio-label");
16565
+ this.#audioIcon = createFigIcon("play");
16566
+ this.#audioIcon.classList.add("fig-input-file-audio-control");
16567
+ this.#audioIcon.setAttribute("slot", "prepend");
16568
+ this.#uploadBtn.append(fileIcon, this.#audioIcon, truncEl);
16361
16569
  } else {
16362
- const truncEl = document.createElement("fig-truncate");
16363
- truncEl.setAttribute("position", "middle");
16364
- truncEl.setAttribute("tooltip", "");
16365
- const buttonLabel = showsMultiplePreview
16366
- ? "Multiple files"
16367
- : filename;
16368
- const dotIdx = buttonLabel.lastIndexOf(".");
16369
- if (!showsMultiplePreview && dotIdx > 0) {
16370
- truncEl.setAttribute("tail", buttonLabel.slice(dotIdx));
16371
- }
16372
- truncEl.textContent = buttonLabel;
16373
- fileIcon.setAttribute("slot", "prepend");
16374
16570
  this.#uploadBtn.append(fileIcon, truncEl);
16375
16571
  }
16376
16572
 
@@ -16489,6 +16685,8 @@ class FigEasingCurve extends HTMLElement {
16489
16685
  #durationBarWidth = 10;
16490
16686
  #durationBarHeight = 10;
16491
16687
  #durationBarRadius = 3;
16688
+ #pickerDefinitionPromise = null;
16689
+ #pickerPopup = null;
16492
16690
 
16493
16691
  static PRESETS = [
16494
16692
  { group: null, name: "Linear", type: "bezier", value: [0, 0, 1, 1] },
@@ -16587,6 +16785,7 @@ class FigEasingCurve extends HTMLElement {
16587
16785
  this.#resizeObserver.disconnect();
16588
16786
  this.#resizeObserver = null;
16589
16787
  }
16788
+ this.#destroyPickerPopup();
16590
16789
  }
16591
16790
 
16592
16791
  attributeChangedCallback(name, oldValue, newValue) {
@@ -16602,6 +16801,9 @@ class FigEasingCurve extends HTMLElement {
16602
16801
  }
16603
16802
 
16604
16803
  if (name === "edit" || name === "presets" || name === "text") {
16804
+ if (name === "edit" && newValue !== "false") {
16805
+ this.#destroyPickerPopup();
16806
+ }
16605
16807
  if (this.isConnected) this.#render();
16606
16808
  return;
16607
16809
  }
@@ -16613,6 +16815,7 @@ class FigEasingCurve extends HTMLElement {
16613
16815
  this.#applyModeConstraint();
16614
16816
  this.#presetName = this.#matchPreset();
16615
16817
  if (this.isConnected) this.#render();
16818
+ this.#syncPickerPopup();
16616
16819
  return;
16617
16820
  }
16618
16821
 
@@ -16631,6 +16834,9 @@ class FigEasingCurve extends HTMLElement {
16631
16834
  if (name === "precision") {
16632
16835
  this.#precision = parseInt(newValue || "2");
16633
16836
  }
16837
+ if (name === "value" || name === "mode" || name === "precision") {
16838
+ this.#syncPickerPopup();
16839
+ }
16634
16840
  }
16635
16841
 
16636
16842
  get value() {
@@ -17092,7 +17298,14 @@ class FigEasingCurve extends HTMLElement {
17092
17298
  let parent = dropdown;
17093
17299
  for (const preset of FigEasingCurve.PRESETS) {
17094
17300
  if (!this.#allowsPreset(preset)) continue;
17095
- if (!this.#isEditEnabled() && !preset.value && !preset.spring) continue;
17301
+ if (
17302
+ !this.#isEditEnabled() &&
17303
+ !customElements.get("fig-popup-easing") &&
17304
+ !preset.value &&
17305
+ !preset.spring
17306
+ ) {
17307
+ continue;
17308
+ }
17096
17309
  if (preset.group !== currentGroup) {
17097
17310
  currentGroup = preset.group;
17098
17311
  parent = currentGroup
@@ -17114,7 +17327,14 @@ class FigEasingCurve extends HTMLElement {
17114
17327
  let currentGroup = undefined;
17115
17328
  for (const p of FigEasingCurve.PRESETS) {
17116
17329
  if (!this.#allowsPreset(p)) continue;
17117
- if (!this.#isEditEnabled() && !p.value && !p.spring) continue;
17330
+ if (
17331
+ !this.#isEditEnabled() &&
17332
+ !customElements.get("fig-popup-easing") &&
17333
+ !p.value &&
17334
+ !p.spring
17335
+ ) {
17336
+ continue;
17337
+ }
17118
17338
  if (p.group !== currentGroup) {
17119
17339
  if (p.group) {
17120
17340
  options.appendChild(
@@ -17733,7 +17953,12 @@ class FigEasingCurve extends HTMLElement {
17733
17953
 
17734
17954
  #refreshCustomPresetIcons() {
17735
17955
  if (!this.#select) return;
17736
- if (!this.#isEditEnabled()) return;
17956
+ if (
17957
+ !this.#isEditEnabled() &&
17958
+ !customElements.get("fig-popup-easing")
17959
+ ) {
17960
+ return;
17961
+ }
17737
17962
  const bezierIcon = FigEasingCurve.#createCurveIcon(
17738
17963
  this.#cp1.x,
17739
17964
  this.#cp1.y,
@@ -17942,6 +18167,22 @@ class FigEasingCurve extends HTMLElement {
17942
18167
  }
17943
18168
 
17944
18169
  if (this.#select) {
18170
+ if (!this.#isEditEnabled() && this.#select.tagName === "FIG-SELECT") {
18171
+ const openPicker = (event) => {
18172
+ if (!customElements.get("fig-popup-easing")) return;
18173
+ event.preventDefault();
18174
+ event.stopImmediatePropagation();
18175
+ this.#openPickerPopup();
18176
+ };
18177
+ this.#select.addEventListener("click", openPicker, true);
18178
+ this.#select.addEventListener(
18179
+ "keydown",
18180
+ (event) => {
18181
+ if (event.key === "Enter" || event.key === " ") openPicker(event);
18182
+ },
18183
+ true,
18184
+ );
18185
+ }
17945
18186
  this.#select.addEventListener("input", (e) => {
17946
18187
  e.stopPropagation();
17947
18188
  });
@@ -18009,6 +18250,59 @@ class FigEasingCurve extends HTMLElement {
18009
18250
  }
18010
18251
  }
18011
18252
 
18253
+ #openPickerPopup() {
18254
+ if (!customElements.get("fig-popup-easing")) {
18255
+ this.#pickerDefinitionPromise ??= customElements
18256
+ .whenDefined("fig-popup-easing")
18257
+ .then(() => {
18258
+ if (!this.isConnected || this.#isEditEnabled()) return;
18259
+ this.#openPickerPopup();
18260
+ });
18261
+ return;
18262
+ }
18263
+
18264
+ let popup = this.#pickerPopup;
18265
+ if (!popup?.isConnected) {
18266
+ popup = document.createElement("dialog", { is: "fig-popup-easing" });
18267
+ popup.setAttribute("is", "fig-popup-easing");
18268
+ popup.id = figUniqueId();
18269
+ popup.anchor = this.#select;
18270
+ const handlePopupEvent = (event) => {
18271
+ event.stopPropagation();
18272
+ this.value = popup.value;
18273
+ this.#emit(event.type);
18274
+ };
18275
+ popup.addEventListener("input", handlePopupEvent);
18276
+ popup.addEventListener("change", handlePopupEvent);
18277
+ (figGetOverlayRoot() ?? document.body).append(popup);
18278
+ this.#pickerPopup = popup;
18279
+ this.#select?.setAttribute("aria-controls", popup.id);
18280
+ }
18281
+
18282
+ this.#syncPickerPopup();
18283
+ popup.open = true;
18284
+ }
18285
+
18286
+ #syncPickerPopup() {
18287
+ const popup = this.#pickerPopup;
18288
+ if (!popup) return;
18289
+ popup.anchor = this.#select;
18290
+ popup.setAttribute("value", this.value);
18291
+ for (const name of ["mode", "precision"]) {
18292
+ const value = this.getAttribute(name);
18293
+ if (value === null) popup.removeAttribute(name);
18294
+ else popup.setAttribute(name, value);
18295
+ }
18296
+ }
18297
+
18298
+ #destroyPickerPopup() {
18299
+ if (!this.#pickerPopup) return;
18300
+ this.#pickerPopup.open = false;
18301
+ this.#pickerPopup.remove();
18302
+ this.#select?.removeAttribute("aria-controls");
18303
+ this.#pickerPopup = null;
18304
+ }
18305
+
18012
18306
  #clientToSVG(e) {
18013
18307
  const ctm = this.#svg.getScreenCTM();
18014
18308
  if (!ctm) return { x: 0, y: 0 };
@@ -20783,6 +21077,7 @@ const FIG_ICON_TOKENS = {
20783
21077
  upload: "--icon-24-upload",
20784
21078
  image: "--icon-24-image",
20785
21079
  video: { medium: "--icon-24-video", small: "--icon-16-video" },
21080
+ audio: "--icon-24-audio",
20786
21081
  sound: { medium: "--icon-24-sound", small: "--icon-16-sound" },
20787
21082
  "sound-off": {
20788
21083
  medium: "--icon-24-sound-off",