@rogieking/figui3 4.15.9 → 5.1.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.js CHANGED
@@ -25,6 +25,10 @@ function createFigIcon(name, options = {}) {
25
25
  return icon;
26
26
  }
27
27
 
28
+ function hasFigFillPicker() {
29
+ return typeof customElements !== "undefined" && !!customElements.get("fig-fill-picker");
30
+ }
31
+
28
32
  function figSupportsCustomizedBuiltIns() {
29
33
  if (
30
34
  typeof window === "undefined" ||
@@ -2441,7 +2445,51 @@ class FigPopup extends HTMLDialogElement {
2441
2445
  // Always use the rendered popup rect so beak alignment matches real final placement.
2442
2446
  const resolvedLeft = rect.left;
2443
2447
  const resolvedTop = rect.top;
2444
- const edgeInset = 10;
2448
+ const styles = getComputedStyle(this);
2449
+ const toPx = (value, fallback = 0) => {
2450
+ const raw = String(value || "").trim();
2451
+ const n = parseFloat(raw);
2452
+ if (!Number.isFinite(n)) return fallback;
2453
+ if (raw.endsWith("rem")) {
2454
+ return n * parseFloat(getComputedStyle(document.documentElement).fontSize);
2455
+ }
2456
+ if (raw.endsWith("em")) {
2457
+ return n * parseFloat(styles.fontSize);
2458
+ }
2459
+ return n;
2460
+ };
2461
+ const radiusForSide = (side) => {
2462
+ if (side === "top") {
2463
+ return Math.max(
2464
+ toPx(styles.borderTopLeftRadius),
2465
+ toPx(styles.borderTopRightRadius),
2466
+ );
2467
+ }
2468
+ if (side === "bottom") {
2469
+ return Math.max(
2470
+ toPx(styles.borderBottomLeftRadius),
2471
+ toPx(styles.borderBottomRightRadius),
2472
+ );
2473
+ }
2474
+ if (side === "left") {
2475
+ return Math.max(
2476
+ toPx(styles.borderTopLeftRadius),
2477
+ toPx(styles.borderBottomLeftRadius),
2478
+ );
2479
+ }
2480
+ if (side === "right") {
2481
+ return Math.max(
2482
+ toPx(styles.borderTopRightRadius),
2483
+ toPx(styles.borderBottomRightRadius),
2484
+ );
2485
+ }
2486
+ return 0;
2487
+ };
2488
+ const beakWidth = toPx(
2489
+ styles.getPropertyValue("--fig-popup-beak-width"),
2490
+ 16,
2491
+ );
2492
+ const edgeInset = Math.max(10, radiusForSide(beakSide) + beakWidth / 2);
2445
2493
 
2446
2494
  let beakOffset;
2447
2495
  if (beakSide === "top" || beakSide === "bottom") {
@@ -3574,7 +3622,7 @@ customElements.define("fig-options", FigOptions);
3574
3622
  * @attr {number} min - The minimum value
3575
3623
  * @attr {number} max - The maximum value
3576
3624
  * @attr {number} step - The step increment
3577
- * @attr {boolean} text - Whether to show a text input alongside the slider
3625
+ * @attr {boolean} text - Whether to show a text input alongside the slider (default true)
3578
3626
  * @attr {string} placeholder - Placeholder for the number input when text is enabled
3579
3627
  * @attr {string} units - The units to display after the value
3580
3628
  * @attr {number} transform - A multiplier for the displayed value
@@ -3628,8 +3676,7 @@ class FigSlider extends HTMLElement {
3628
3676
  const rawValue = this.getAttribute("value");
3629
3677
  this.type = this.getAttribute("type") || "range";
3630
3678
  this.variant = this.getAttribute("variant") || "default";
3631
- this.text =
3632
- this.hasAttribute("text") && this.getAttribute("text") !== "false";
3679
+ this.text = this.getAttribute("text") !== "false";
3633
3680
  this.units = this.getAttribute("units") || "";
3634
3681
  this.transform = Number(this.getAttribute("transform") || 1);
3635
3682
  this.disabled = this.getAttribute("disabled") ? true : false;
@@ -3997,7 +4044,7 @@ class FigSlider extends HTMLElement {
3997
4044
  this.#regenerateInnerHTML();
3998
4045
  break;
3999
4046
  case "text":
4000
- this.text = newValue !== null && newValue !== "false";
4047
+ this.text = newValue !== "false";
4001
4048
  this.#regenerateInnerHTML();
4002
4049
  break;
4003
4050
  default:
@@ -4024,11 +4071,13 @@ customElements.define("fig-slider", FigSlider);
4024
4071
  */
4025
4072
  class FigInputText extends HTMLElement {
4026
4073
  #isInteracting = false;
4074
+ #passwordVisible = false;
4027
4075
  #boundMouseMove;
4028
4076
  #boundMouseUp;
4029
4077
  #boundWindowBlur;
4030
4078
  #boundMouseDown;
4031
4079
  #boundInputChange;
4080
+ #boundNativeInput;
4032
4081
 
4033
4082
  constructor() {
4034
4083
  super();
@@ -4041,6 +4090,9 @@ class FigInputText extends HTMLElement {
4041
4090
  e.stopPropagation();
4042
4091
  this.#handleInputChange(e);
4043
4092
  };
4093
+ this.#boundNativeInput = () => {
4094
+ this.#syncSearchClearVisibility();
4095
+ };
4044
4096
  }
4045
4097
 
4046
4098
  connectedCallback() {
@@ -4109,6 +4161,10 @@ class FigInputText extends HTMLElement {
4109
4161
 
4110
4162
  this.input = this.querySelector("input,textarea");
4111
4163
  this.input.readOnly = this.readonly;
4164
+ this.#syncSearchPrefix();
4165
+ this.#syncSearchClear();
4166
+ this.#syncSearchClearVisibility();
4167
+ this.#syncPasswordToggle();
4112
4168
 
4113
4169
  if (this.type === "number") {
4114
4170
  if (this.getAttribute("min")) {
@@ -4124,12 +4180,15 @@ class FigInputText extends HTMLElement {
4124
4180
  }
4125
4181
  this.input.removeEventListener("change", this.#boundInputChange);
4126
4182
  this.input.addEventListener("change", this.#boundInputChange);
4183
+ this.input.removeEventListener("input", this.#boundNativeInput);
4184
+ this.input.addEventListener("input", this.#boundNativeInput);
4127
4185
  });
4128
4186
  }
4129
4187
 
4130
4188
  disconnectedCallback() {
4131
4189
  if (this.input) {
4132
4190
  this.input.removeEventListener("change", this.#boundInputChange);
4191
+ this.input.removeEventListener("input", this.#boundNativeInput);
4133
4192
  }
4134
4193
  this.removeEventListener("pointerdown", this.#boundMouseDown);
4135
4194
  window.removeEventListener("pointermove", this.#boundMouseMove);
@@ -4140,6 +4199,132 @@ class FigInputText extends HTMLElement {
4140
4199
  focus() {
4141
4200
  this.input.focus();
4142
4201
  }
4202
+ #syncSearchPrefix() {
4203
+ const generated = this.querySelector(
4204
+ '[slot="prepend"][data-generated="search-prefix"]',
4205
+ );
4206
+ if (this.type !== "search") {
4207
+ generated?.remove();
4208
+ return;
4209
+ }
4210
+ const prepend = this.querySelector('[slot="prepend"]');
4211
+ if (prepend && prepend !== generated) return;
4212
+ if (generated) return;
4213
+
4214
+ const icon = createFigIcon("search");
4215
+ icon.setAttribute("slot", "prepend");
4216
+ icon.setAttribute("data-generated", "search-prefix");
4217
+ icon.setAttribute("color", "var(--figma-color-icon)");
4218
+ icon.addEventListener("click", this.focus.bind(this));
4219
+ this.prepend(icon);
4220
+ }
4221
+ #syncSearchClear() {
4222
+ const generated = this.querySelector(
4223
+ '[slot="append"][data-generated="search-clear"]',
4224
+ );
4225
+ if (this.type !== "search") {
4226
+ generated?.remove();
4227
+ return;
4228
+ }
4229
+ const append = this.querySelector('[slot="append"]');
4230
+ if (append && append !== generated) return;
4231
+ if (generated) return;
4232
+
4233
+ const wrapper = document.createElement("span");
4234
+ wrapper.setAttribute("slot", "append");
4235
+ wrapper.setAttribute("data-generated", "search-clear");
4236
+
4237
+ const tooltip = document.createElement("fig-tooltip");
4238
+ tooltip.setAttribute("text", "Clear search");
4239
+
4240
+ const button = document.createElement("fig-button");
4241
+ button.setAttribute("variant", "ghost");
4242
+ button.setAttribute("icon", "");
4243
+ button.setAttribute("aria-label", "Clear search");
4244
+
4245
+ const icon = createFigIcon("", { size: "small" });
4246
+ icon.setAttribute("color", "var(--figma-color-icon-secondary)");
4247
+ button.append(icon);
4248
+ tooltip.append(button);
4249
+ wrapper.append(tooltip);
4250
+ this.append(wrapper);
4251
+ icon.style.setProperty("--icon", "var(--icon-16-close)");
4252
+
4253
+ button.addEventListener("click", (e) => {
4254
+ e.preventDefault();
4255
+ e.stopPropagation();
4256
+ if (!this.input || this.input.value === "") {
4257
+ this.focus();
4258
+ return;
4259
+ }
4260
+ this.value = "";
4261
+ this.input.value = "";
4262
+ this.dispatchEvent(new CustomEvent("input", { detail: "", bubbles: true }));
4263
+ this.dispatchEvent(new CustomEvent("change", { detail: "", bubbles: true }));
4264
+ this.#syncSearchClearVisibility();
4265
+ this.focus();
4266
+ });
4267
+ }
4268
+ #syncSearchClearVisibility() {
4269
+ if (this.type !== "search") {
4270
+ this.removeAttribute("data-search-has-value");
4271
+ return;
4272
+ }
4273
+ this.toggleAttribute("data-search-has-value", !!this.input?.value);
4274
+ }
4275
+ #syncPasswordToggle() {
4276
+ const generated = this.querySelector(
4277
+ '[slot="append"][data-generated="password-toggle"]',
4278
+ );
4279
+ if (this.type !== "password") {
4280
+ generated?.remove();
4281
+ this.#passwordVisible = false;
4282
+ return;
4283
+ }
4284
+ const append = this.querySelector('[slot="append"]');
4285
+ if (append && append !== generated) return;
4286
+ if (generated) {
4287
+ this.#updatePasswordToggle(generated);
4288
+ return;
4289
+ }
4290
+
4291
+ const wrapper = document.createElement("span");
4292
+ wrapper.setAttribute("slot", "append");
4293
+ wrapper.setAttribute("data-generated", "password-toggle");
4294
+
4295
+ const tooltip = document.createElement("fig-tooltip");
4296
+ const button = document.createElement("fig-button");
4297
+ button.setAttribute("variant", "ghost");
4298
+ button.setAttribute("icon", "");
4299
+
4300
+ const icon = createFigIcon("visible", { size: "small" });
4301
+ icon.setAttribute("color", "var(--figma-color-icon-secondary)");
4302
+ button.append(icon);
4303
+ tooltip.append(button);
4304
+ wrapper.append(tooltip);
4305
+ this.append(wrapper);
4306
+ this.#updatePasswordToggle(wrapper);
4307
+
4308
+ button.addEventListener("click", (e) => {
4309
+ e.preventDefault();
4310
+ e.stopPropagation();
4311
+ this.#passwordVisible = !this.#passwordVisible;
4312
+ if (this.input) {
4313
+ this.input.type = this.#passwordVisible ? "text" : "password";
4314
+ }
4315
+ this.#updatePasswordToggle(wrapper);
4316
+ this.focus();
4317
+ });
4318
+ }
4319
+ #updatePasswordToggle(wrapper) {
4320
+ const tooltip = wrapper.querySelector("fig-tooltip");
4321
+ const button = wrapper.querySelector("fig-button");
4322
+ const icon = wrapper.querySelector("fig-icon");
4323
+ const label = this.#passwordVisible ? "Hide password" : "Show password";
4324
+ tooltip?.setAttribute("text", label);
4325
+ button?.setAttribute("aria-label", label);
4326
+ icon?.setAttribute("name", this.#passwordVisible ? "visible" : "hidden");
4327
+ }
4143
4328
  #transformNumber(value) {
4144
4329
  if (value === "") return "";
4145
4330
  let transformed = Number(value) * (this.transform || 1);
@@ -4157,6 +4342,7 @@ class FigInputText extends HTMLElement {
4157
4342
  }
4158
4343
  this.value = value;
4159
4344
  this.input.value = valueTransformed;
4345
+ this.#syncSearchClearVisibility();
4160
4346
  this.dispatchEvent(
4161
4347
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4162
4348
  );
@@ -4293,6 +4479,7 @@ class FigInputText extends HTMLElement {
4293
4479
  this.value = value;
4294
4480
  this.input.value = value;
4295
4481
  }
4482
+ this.#syncSearchClearVisibility();
4296
4483
  break;
4297
4484
  case "min":
4298
4485
  case "max":
@@ -4310,6 +4497,14 @@ class FigInputText extends HTMLElement {
4310
4497
  this.placeholder = newValue ?? "";
4311
4498
  this.input.placeholder = this.placeholder;
4312
4499
  break;
4500
+ case "type":
4501
+ this.type = newValue || "text";
4502
+ this.input.type = this.type;
4503
+ this.#syncSearchPrefix();
4504
+ this.#syncSearchClear();
4505
+ this.#syncSearchClearVisibility();
4506
+ this.#syncPasswordToggle();
4507
+ break;
4313
4508
  default:
4314
4509
  this[name] = this.input[name] = newValue;
4315
4510
  break;
@@ -4382,7 +4577,7 @@ class FigInputNumber extends HTMLElement {
4382
4577
  e.preventDefault();
4383
4578
  e.stopPropagation();
4384
4579
  const btn = e.target.closest("button");
4385
- if (!btn || this.disabled) return;
4580
+ if (!btn || this.disabled || btn.disabled) return;
4386
4581
  const dir = btn.classList.contains("fig-stepper-up") ? 1 : -1;
4387
4582
  this.#stepValue(dir);
4388
4583
  this.input.focus();
@@ -4392,6 +4587,31 @@ class FigInputNumber extends HTMLElement {
4392
4587
  this.#stepperEl.remove();
4393
4588
  this.#stepperEl = null;
4394
4589
  }
4590
+ this.#syncStepperState();
4591
+ }
4592
+
4593
+ #syncStepperState() {
4594
+ if (!this.#stepperEl) return;
4595
+ const up = this.#stepperEl.querySelector(".fig-stepper-up");
4596
+ const down = this.#stepperEl.querySelector(".fig-stepper-down");
4597
+ if (!up || !down) return;
4598
+
4599
+ const numericValue = this.input
4600
+ ? this.#getNumericValue(this.input.value)
4601
+ : this.value;
4602
+ const current =
4603
+ numericValue !== "" && numericValue !== null && numericValue !== undefined
4604
+ ? Number(numericValue) / (this.transform || 1)
4605
+ : Number(this.value);
4606
+ const hasCurrent = Number.isFinite(current);
4607
+ const disabled = Boolean(this.disabled);
4608
+ const atMin =
4609
+ hasCurrent && typeof this.min === "number" && current <= this.min;
4610
+ const atMax =
4611
+ hasCurrent && typeof this.max === "number" && current >= this.max;
4612
+
4613
+ up.disabled = disabled || atMax;
4614
+ down.disabled = disabled || atMin;
4395
4615
  }
4396
4616
 
4397
4617
  #stepValue(direction) {
@@ -4403,6 +4623,7 @@ class FigInputNumber extends HTMLElement {
4403
4623
  value = this.#sanitizeInput(value, false);
4404
4624
  this.value = value;
4405
4625
  this.input.value = this.#formatWithUnit(this.value);
4626
+ this.#syncStepperState();
4406
4627
  this.dispatchEvent(
4407
4628
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4408
4629
  );
@@ -4513,6 +4734,7 @@ class FigInputNumber extends HTMLElement {
4513
4734
  const disabledAttr = this.getAttribute("disabled");
4514
4735
  this.disabled = this.input.disabled = disabledAttr !== "false";
4515
4736
  }
4737
+ this.#syncStepperState();
4516
4738
 
4517
4739
  this.addEventListener("pointerdown", this.#boundMouseDown);
4518
4740
  this.input.removeEventListener("change", this.#boundInputChange);
@@ -4624,6 +4846,7 @@ class FigInputNumber extends HTMLElement {
4624
4846
  this.value = "";
4625
4847
  e.target.value = "";
4626
4848
  }
4849
+ this.#syncStepperState();
4627
4850
  this.dispatchEvent(
4628
4851
  new CustomEvent("change", { detail: this.value, bubbles: true }),
4629
4852
  );
@@ -4649,6 +4872,7 @@ class FigInputNumber extends HTMLElement {
4649
4872
  value = this.#sanitizeInput(value, false);
4650
4873
  this.value = value;
4651
4874
  this.input.value = this.#formatWithUnit(this.value);
4875
+ this.#syncStepperState();
4652
4876
 
4653
4877
  this.dispatchEvent(
4654
4878
  new CustomEvent("input", { detail: this.value, bubbles: true }),
@@ -4665,6 +4889,7 @@ class FigInputNumber extends HTMLElement {
4665
4889
  } else {
4666
4890
  this.value = "";
4667
4891
  }
4892
+ this.#syncStepperState();
4668
4893
  this.dispatchEvent(
4669
4894
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4670
4895
  );
@@ -4682,6 +4907,7 @@ class FigInputNumber extends HTMLElement {
4682
4907
  this.value = "";
4683
4908
  e.target.value = "";
4684
4909
  }
4910
+ this.#syncStepperState();
4685
4911
  this.dispatchEvent(
4686
4912
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4687
4913
  );
@@ -4702,6 +4928,7 @@ class FigInputNumber extends HTMLElement {
4702
4928
  value = this.#sanitizeInput(value, false);
4703
4929
  this.value = value;
4704
4930
  this.input.value = this.#formatWithUnit(this.value);
4931
+ this.#syncStepperState();
4705
4932
  this.dispatchEvent(
4706
4933
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4707
4934
  );
@@ -4784,6 +5011,7 @@ class FigInputNumber extends HTMLElement {
4784
5011
  case "disabled":
4785
5012
  this.disabled = this.input.disabled =
4786
5013
  newValue !== null && newValue !== "false";
5014
+ this.#syncStepperState();
4787
5015
  break;
4788
5016
  case "units":
4789
5017
  this.#rawUnits = newValue || "";
@@ -4816,15 +5044,18 @@ class FigInputNumber extends HTMLElement {
4816
5044
  }
4817
5045
  this.value = value;
4818
5046
  this.input.value = this.#formatWithUnit(this.value);
5047
+ this.#syncStepperState();
4819
5048
  break;
4820
5049
  case "min":
4821
5050
  case "max":
4822
5051
  case "step":
4823
5052
  if (newValue === null || newValue === "") {
4824
5053
  this[name] = undefined;
5054
+ this.#syncStepperState();
4825
5055
  break;
4826
5056
  }
4827
5057
  this[name] = Number(newValue);
5058
+ this.#syncStepperState();
4828
5059
  break;
4829
5060
  case "steppers": {
4830
5061
  const hasSteppers = newValue !== null && newValue !== "false";
@@ -5018,17 +5249,13 @@ class FigInputColor extends HTMLElement {
5018
5249
  #fillPicker;
5019
5250
  #textInput;
5020
5251
  #alphaInput;
5252
+ #suppressNativeColorClick = false;
5253
+ #pendingFillPickerPointerOpen = false;
5254
+ #nativeColorClickTimer = null;
5021
5255
  constructor() {
5022
5256
  super();
5023
5257
  }
5024
5258
 
5025
- get picker() {
5026
- return this.getAttribute("picker") || "native";
5027
- }
5028
- set picker(value) {
5029
- this.setAttribute("picker", value);
5030
- }
5031
-
5032
5259
  get alpha() {
5033
5260
  return this.getAttribute("alpha");
5034
5261
  }
@@ -5040,17 +5267,21 @@ class FigInputColor extends HTMLElement {
5040
5267
  }
5041
5268
  }
5042
5269
 
5043
- #buildFillPickerAttrs() {
5270
+ #fillPickerAttrs() {
5044
5271
  const attrs = {};
5045
5272
  const experimental = this.getAttribute("experimental");
5046
5273
  if (experimental) attrs["experimental"] = experimental;
5047
- // picker-* attributes forwarded to fill picker (except anchor, handled programmatically)
5048
5274
  for (const { name, value } of this.attributes) {
5049
5275
  if (name.startsWith("picker-") && name !== "picker-anchor") {
5050
5276
  attrs[name.slice(7)] = value;
5051
5277
  }
5052
5278
  }
5053
5279
  if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
5280
+ return attrs;
5281
+ }
5282
+
5283
+ #buildFillPickerAttrs() {
5284
+ const attrs = this.#fillPickerAttrs();
5054
5285
  return Object.entries(attrs)
5055
5286
  .map(([k, v]) => `${k}="${v}"`)
5056
5287
  .join(" ");
@@ -5069,10 +5300,7 @@ class FigInputColor extends HTMLElement {
5069
5300
  #buildUI() {
5070
5301
  this.#setValues(this.getAttribute("value"));
5071
5302
 
5072
- const useFigmaPicker = this.picker === "figma";
5073
- const hidePicker = this.picker === "false";
5074
- const showAlpha = this.getAttribute("alpha") === "true";
5075
- const fpAttrs = this.#buildFillPickerAttrs();
5303
+ const showAlpha = this.getAttribute("alpha") !== "false";
5076
5304
  const disabled = this.#disabled;
5077
5305
  const disabledAttr = disabled ? " disabled" : "";
5078
5306
 
@@ -5097,32 +5325,14 @@ class FigInputColor extends HTMLElement {
5097
5325
  }
5098
5326
 
5099
5327
  let swatchElement = "";
5100
- if (!hidePicker) {
5101
- swatchElement = useFigmaPicker
5102
- ? `<fig-fill-picker mode="solid" ${fpAttrs} ${
5103
- showAlpha ? "" : 'alpha="false"'
5104
- } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
5105
- this.#alphaPercent
5106
- }}'${disabledAttr}></fig-fill-picker>`
5107
- : `<fig-chit background="${this.hexOpaque}" alpha="${this.rgba.a}"${disabledAttr}></fig-chit>`;
5108
- }
5328
+ swatchElement = `<fig-chit background="${this.hexOpaque}" alpha="${this.rgba.a}"${disabledAttr}></fig-chit>`;
5109
5329
 
5110
5330
  html = `<div class="input-combo">
5111
5331
  ${swatchElement}
5112
5332
  ${label}
5113
5333
  </div>`;
5114
5334
  } else {
5115
- if (hidePicker) {
5116
- html = ``;
5117
- } else {
5118
- html = useFigmaPicker
5119
- ? `<fig-fill-picker mode="solid" ${fpAttrs} ${
5120
- showAlpha ? "" : 'alpha="false"'
5121
- } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
5122
- this.#alphaPercent
5123
- }}'${disabledAttr}></fig-fill-picker>`
5124
- : `<fig-chit background="${this.hexOpaque}" alpha="${this.rgba.a}"${disabledAttr}></fig-chit>`;
5125
- }
5335
+ html = `<fig-chit background="${this.hexOpaque}" alpha="${this.rgba.a}"${disabledAttr}></fig-chit>`;
5126
5336
  }
5127
5337
  this.innerHTML = html;
5128
5338
 
@@ -5143,31 +5353,16 @@ class FigInputColor extends HTMLElement {
5143
5353
  swatchInput?.setAttribute("disabled", "");
5144
5354
  if (swatchInput) swatchInput.style.pointerEvents = "none";
5145
5355
  }
5356
+ this.#swatch.addEventListener("pointerdown", this.#handleSwatchPointerDown.bind(this), {
5357
+ capture: true,
5358
+ });
5359
+ this.#swatch.addEventListener("click", this.#handleSwatchClick.bind(this), {
5360
+ capture: true,
5361
+ });
5362
+ swatchInput?.addEventListener("keydown", this.#handleSwatchKeyDown.bind(this));
5146
5363
  this.#swatch.addEventListener("input", this.#handleInput.bind(this));
5147
5364
  }
5148
5365
 
5149
- // Setup fill picker (figma picker)
5150
- if (this.#fillPicker) {
5151
- const anchor = this.getAttribute("picker-anchor");
5152
- if (anchor === "self") {
5153
- this.#fillPicker.anchorElement = this;
5154
- } else if (anchor) {
5155
- const el = document.querySelector(anchor);
5156
- if (el) this.#fillPicker.anchorElement = el;
5157
- }
5158
- if (this.hasAttribute("disabled")) {
5159
- this.#fillPicker.setAttribute("disabled", "");
5160
- }
5161
- this.#fillPicker.addEventListener(
5162
- "input",
5163
- this.#handleFillPickerInput.bind(this),
5164
- );
5165
- this.#fillPicker.addEventListener(
5166
- "change",
5167
- this.#handleChange.bind(this),
5168
- );
5169
- }
5170
-
5171
5366
  if (this.#textInput) {
5172
5367
  const hex = this.rgbAlphaToHex(this.rgba, 1);
5173
5368
  // Display without # prefix
@@ -5197,8 +5392,103 @@ class FigInputColor extends HTMLElement {
5197
5392
  }
5198
5393
  });
5199
5394
  }
5395
+
5396
+ #syncFillPicker() {
5397
+ if (!this.#fillPicker) return;
5398
+ for (const [name, value] of Object.entries(this.#fillPickerAttrs())) {
5399
+ this.#fillPicker.setAttribute(name, value);
5400
+ }
5401
+ this.#fillPicker.setAttribute("mode", "solid");
5402
+ if (this.getAttribute("alpha") !== "false") {
5403
+ this.#fillPicker.removeAttribute("alpha");
5404
+ } else {
5405
+ this.#fillPicker.setAttribute("alpha", "false");
5406
+ }
5407
+ if (this.hasAttribute("disabled")) {
5408
+ this.#fillPicker.setAttribute("disabled", "");
5409
+ } else {
5410
+ this.#fillPicker.removeAttribute("disabled");
5411
+ }
5412
+ this.#fillPicker.anchorElement = this;
5413
+ this.#fillPicker.setAttribute(
5414
+ "value",
5415
+ JSON.stringify({
5416
+ type: "solid",
5417
+ color: this.hexOpaque,
5418
+ opacity: this.#alphaPercent,
5419
+ }),
5420
+ );
5421
+ }
5422
+
5423
+ #ensureFillPicker() {
5424
+ if (!hasFigFillPicker()) return null;
5425
+ if (this.#fillPicker?.isConnected) {
5426
+ this.#syncFillPicker();
5427
+ return this.#fillPicker;
5428
+ }
5429
+
5430
+ const picker = document.createElement("fig-fill-picker");
5431
+ picker.innerHTML = "<span hidden></span>";
5432
+ picker.addEventListener("input", this.#handleFillPickerInput.bind(this));
5433
+ picker.addEventListener("change", this.#handleChange.bind(this));
5434
+ this.appendChild(picker);
5435
+ this.#fillPicker = picker;
5436
+ this.#syncFillPicker();
5437
+ return picker;
5438
+ }
5439
+
5440
+ #openFillPicker() {
5441
+ if (this.hasAttribute("disabled") || this.hasAttribute("swatch-disabled")) return false;
5442
+ const picker = this.#ensureFillPicker();
5443
+ if (!picker) return false;
5444
+ requestAnimationFrame(() => picker.open?.());
5445
+ return true;
5446
+ }
5447
+
5448
+ #cancelNativeColorEvent(event) {
5449
+ event.preventDefault();
5450
+ event.stopPropagation();
5451
+ event.stopImmediatePropagation?.();
5452
+ }
5453
+
5454
+ #handleSwatchPointerDown(event) {
5455
+ if (!hasFigFillPicker()) return;
5456
+ if (this.hasAttribute("disabled") || this.hasAttribute("swatch-disabled")) return;
5457
+ this.#pendingFillPickerPointerOpen = true;
5458
+ this.#suppressNativeColorClick = true;
5459
+ if (this.#nativeColorClickTimer) clearTimeout(this.#nativeColorClickTimer);
5460
+ this.#nativeColorClickTimer = setTimeout(() => {
5461
+ this.#suppressNativeColorClick = false;
5462
+ this.#pendingFillPickerPointerOpen = false;
5463
+ this.#nativeColorClickTimer = null;
5464
+ }, 500);
5465
+ this.#cancelNativeColorEvent(event);
5466
+ }
5467
+
5468
+ #handleSwatchClick(event) {
5469
+ if (!this.#suppressNativeColorClick) return;
5470
+ this.#suppressNativeColorClick = false;
5471
+ if (this.#nativeColorClickTimer) {
5472
+ clearTimeout(this.#nativeColorClickTimer);
5473
+ this.#nativeColorClickTimer = null;
5474
+ }
5475
+ this.#cancelNativeColorEvent(event);
5476
+ if (this.#pendingFillPickerPointerOpen) {
5477
+ this.#pendingFillPickerPointerOpen = false;
5478
+ this.#openFillPicker();
5479
+ }
5480
+ }
5481
+
5482
+ #handleSwatchKeyDown(event) {
5483
+ if (event.key !== "Enter" && event.key !== " ") return;
5484
+ if (!hasFigFillPicker()) return;
5485
+ if (!this.#openFillPicker()) return;
5486
+ this.#cancelNativeColorEvent(event);
5487
+ }
5488
+
5200
5489
  #setValues(hexValue) {
5201
- this.rgba = this.convertToRGBA(hexValue);
5490
+ const colorValue = hexValue || "#D9D9D9";
5491
+ this.rgba = this.convertToRGBA(colorValue);
5202
5492
  this.value = this.rgbAlphaToHex(
5203
5493
  {
5204
5494
  r: isNaN(this.rgba.r) ? 0 : this.rgba.r,
@@ -5209,9 +5499,7 @@ class FigInputColor extends HTMLElement {
5209
5499
  );
5210
5500
  this.hexWithAlpha = this.value.toUpperCase();
5211
5501
  this.hexOpaque = this.hexWithAlpha.slice(0, 7);
5212
- if (hexValue.length > 7) {
5213
- this.#alphaPercent = (this.rgba.a * 100).toFixed(0);
5214
- }
5502
+ this.#alphaPercent = colorValue.length > 7 ? (this.rgba.a * 100).toFixed(0) : 100;
5215
5503
  this.style.setProperty("--alpha", this.rgba.a);
5216
5504
  }
5217
5505
 
@@ -5335,7 +5623,6 @@ class FigInputColor extends HTMLElement {
5335
5623
  "value",
5336
5624
  "style",
5337
5625
  "mode",
5338
- "picker",
5339
5626
  "experimental",
5340
5627
  "alpha",
5341
5628
  "text",
@@ -5382,13 +5669,7 @@ class FigInputColor extends HTMLElement {
5382
5669
  // Emitting here causes infinite loops with React and other frameworks.
5383
5670
  break;
5384
5671
  case "mode":
5385
- // Mode attribute is passed through to fig-fill-picker when used
5386
- if (this.#fillPicker && newValue) {
5387
- this.#fillPicker.setAttribute("mode", newValue);
5388
- }
5389
- break;
5390
- case "picker":
5391
- // Picker type change requires re-render
5672
+ this.#syncFillPicker();
5392
5673
  break;
5393
5674
  case "alpha":
5394
5675
  case "text":
@@ -5414,8 +5695,7 @@ class FigInputColor extends HTMLElement {
5414
5695
  else child.removeAttribute("disabled");
5415
5696
  }
5416
5697
  if (this.#fillPicker) {
5417
- if (disabled) this.#fillPicker.setAttribute("disabled", "");
5418
- else this.#fillPicker.removeAttribute("disabled");
5698
+ this.#syncFillPicker();
5419
5699
  }
5420
5700
  }
5421
5701
 
@@ -5535,7 +5815,7 @@ const GRADIENT_HUE_INTERPOLATIONS = [
5535
5815
 
5536
5816
  const GRADIENT_PICKER_SPACES = ["srgb-linear", "oklab", "oklch"];
5537
5817
 
5538
- function normalizeGradientConfig(gradient) {
5818
+ export function normalizeGradientConfig(gradient) {
5539
5819
  const next = { ...(gradient ?? {}) };
5540
5820
  let interpolationSpace = String(
5541
5821
  next.interpolationSpace ?? "oklab",
@@ -5557,7 +5837,7 @@ function normalizeGradientConfig(gradient) {
5557
5837
  return next;
5558
5838
  }
5559
5839
 
5560
- function gradientToValueShape(gradient) {
5840
+ export function gradientToValueShape(gradient) {
5561
5841
  const normalized = normalizeGradientConfig(gradient);
5562
5842
  const output = {
5563
5843
  ...normalized,
@@ -5571,7 +5851,7 @@ function gradientToValueShape(gradient) {
5571
5851
  return output;
5572
5852
  }
5573
5853
 
5574
- function gradientInterpolationClause(gradient) {
5854
+ export function gradientInterpolationClause(gradient) {
5575
5855
  const normalized = normalizeGradientConfig(gradient);
5576
5856
  if (normalized.interpolationSpace === "oklch") {
5577
5857
  return `in oklch ${normalized.hueInterpolation} hue`;
@@ -5900,6 +6180,46 @@ class FigInputFill extends HTMLElement {
5900
6180
  .join(" ");
5901
6181
  }
5902
6182
 
6183
+ #fillPickerChitBackground() {
6184
+ switch (this.#fillType) {
6185
+ case "solid":
6186
+ return this.#solid.color;
6187
+ case "gradient": {
6188
+ const sorted = [...this.#gradient.stops].sort(
6189
+ (a, b) => a.position - b.position,
6190
+ );
6191
+ const stops = sorted
6192
+ .map((stop) => {
6193
+ const alpha = (stop.opacity ?? 100) / 100;
6194
+ if (alpha >= 1) return `${stop.color} ${stop.position}%`;
6195
+ const { r, g, b } = figHexToRGB(stop.color);
6196
+ return `rgba(${r}, ${g}, ${b}, ${alpha}) ${stop.position}%`;
6197
+ })
6198
+ .join(", ");
6199
+ return `linear-gradient(${this.#gradient.angle}deg ${gradientInterpolationClause(this.#gradient)}, ${stops})`;
6200
+ }
6201
+ case "image":
6202
+ return this.#image.url ? `url(${this.#image.url})` : "#D9D9D9";
6203
+ default:
6204
+ return "#D9D9D9";
6205
+ }
6206
+ }
6207
+
6208
+ #fillPickerChitAlpha() {
6209
+ switch (this.#fillType) {
6210
+ case "solid":
6211
+ return this.#solid.alpha;
6212
+ case "image":
6213
+ return this.#image.opacity ?? 1;
6214
+ case "video":
6215
+ return this.#video.opacity ?? 1;
6216
+ case "webcam":
6217
+ return this.#webcam.opacity ?? 1;
6218
+ default:
6219
+ return 1;
6220
+ }
6221
+ }
6222
+
5903
6223
  #syncDisabled() {
5904
6224
  const disabled = this.hasAttribute("disabled");
5905
6225
  for (const child of [
@@ -5982,7 +6302,9 @@ class FigInputFill extends HTMLElement {
5982
6302
  <div class="input-combo">
5983
6303
  <fig-fill-picker ${fpAttrs} value='${fillPickerValue}' ${
5984
6304
  disabled ? "disabled" : ""
5985
- }></fig-fill-picker>
6305
+ }>
6306
+ <fig-chit background="${this.#fillPickerChitBackground()}" alpha="${this.#fillPickerChitAlpha()}"${disabled ? " disabled" : ""}></fig-chit>
6307
+ </fig-fill-picker>
5986
6308
  ${controlsHtml}
5987
6309
  </div>`;
5988
6310
 
@@ -7097,7 +7419,7 @@ class FigInputGradient extends HTMLElement {
7097
7419
  const disabled = this.hasAttribute("disabled");
7098
7420
  const mode = this.#editMode;
7099
7421
 
7100
- if (mode === "picker") {
7422
+ if (mode === "picker" && hasFigFillPicker()) {
7101
7423
  const experimental = this.getAttribute("experimental");
7102
7424
  const expAttr = experimental ? ` experimental="${experimental}"` : "";
7103
7425
  const gradientValue = JSON.stringify(this.value);
@@ -7113,11 +7435,11 @@ class FigInputGradient extends HTMLElement {
7113
7435
 
7114
7436
  this.innerHTML = `
7115
7437
  <fig-chit background="${this.#buildGradientCSS()}"${disabled ? " disabled" : ""}></fig-chit>
7116
- ${mode === "true" ? `<div class="fig-input-gradient-track">${this.#buildStopHandles()}</div>` : ""}`;
7438
+ ${mode === "true" || mode === "picker" ? `<div class="fig-input-gradient-track">${this.#buildStopHandles()}</div>` : ""}`;
7117
7439
  this.#chit = this.querySelector("fig-chit");
7118
7440
  this.#track = this.querySelector(".fig-input-gradient-track");
7119
7441
 
7120
- if (mode === "true") {
7442
+ if (mode === "true" || mode === "picker") {
7121
7443
  this.#setupGhostHandle();
7122
7444
  this.#setupEventListeners();
7123
7445
  requestAnimationFrame(() => this.#repositionHandles());
@@ -8450,6 +8772,7 @@ class FigMedia extends HTMLElement {
8450
8772
  #mediaEl = null;
8451
8773
  #fileInput = null;
8452
8774
  #blobUrl = null;
8775
+ #previewSrc = null;
8453
8776
  #file = null;
8454
8777
  #boundHandleFileInput = this.#handleFileInput.bind(this);
8455
8778
  #boundHandleMediaPlay = this.#handleMediaPlay.bind(this);
@@ -8502,6 +8825,10 @@ class FigMedia extends HTMLElement {
8502
8825
  return this.#file;
8503
8826
  }
8504
8827
 
8828
+ #currentMediaSrc() {
8829
+ return this.#previewSrc || this.#src || "";
8830
+ }
8831
+
8505
8832
  /**
8506
8833
  * Returns a base64 data URL for the loaded image.
8507
8834
  * Requires a CORS-clean image (same-origin or with appropriate Access-Control headers);
@@ -8509,7 +8836,7 @@ class FigMedia extends HTMLElement {
8509
8836
  */
8510
8837
  async getBase64() {
8511
8838
  if (this.mediaKind !== "image") return null;
8512
- if (!this.#src) return null;
8839
+ if (!this.#currentMediaSrc()) return null;
8513
8840
  if (!this.#mediaEl) return null;
8514
8841
  try {
8515
8842
  if (typeof this.#mediaEl.decode === "function") {
@@ -8565,6 +8892,7 @@ class FigMedia extends HTMLElement {
8565
8892
  URL.revokeObjectURL(this.#blobUrl);
8566
8893
  this.#blobUrl = null;
8567
8894
  }
8895
+ this.#previewSrc = null;
8568
8896
  }
8569
8897
 
8570
8898
  #removeMediaElementListeners() {
@@ -8642,7 +8970,7 @@ class FigMedia extends HTMLElement {
8642
8970
  #syncGeneratedMediaElement() {
8643
8971
  if (!this.#mediaEl) return;
8644
8972
  if (!this.#mediaEl.hasAttribute("data-generated")) return;
8645
- const src = this.#src || "";
8973
+ const src = this.#currentMediaSrc();
8646
8974
  if (this.#mediaEl.getAttribute("src") !== src) {
8647
8975
  if (src) {
8648
8976
  this.#mediaEl.setAttribute("src", src);
@@ -8827,7 +9155,11 @@ class FigMedia extends HTMLElement {
8827
9155
  fi.setAttribute("variant", "overlay");
8828
9156
  const defaultLabel = this.getAttribute("label") || "Upload";
8829
9157
  fi.setAttribute("label", this.#src ? "Replace" : defaultLabel);
8830
- if (this.#src) fi.setAttribute("url", this.#src);
9158
+ if (this.#file?.name) {
9159
+ fi.setAttribute("filename", this.#file.name);
9160
+ } else if (this.#src) {
9161
+ fi.setAttribute("url", this.#src);
9162
+ }
8831
9163
  fi.addEventListener("change", this.#boundHandleFileInput);
8832
9164
  this.append(fi);
8833
9165
  this.#fileInput = fi;
@@ -8844,6 +9176,7 @@ class FigMedia extends HTMLElement {
8844
9176
  #handleFileInput(e) {
8845
9177
  if (e.target !== this.#fileInput) return;
8846
9178
  const file = e.detail?.files?.[0];
9179
+ const cleared = e.detail?.cleared === true;
8847
9180
 
8848
9181
  if (!file) {
8849
9182
  if (this.#blobUrl) {
@@ -8851,7 +9184,19 @@ class FigMedia extends HTMLElement {
8851
9184
  this.#blobUrl = null;
8852
9185
  }
8853
9186
  this.#file = null;
8854
- this.removeAttribute("src");
9187
+ this.#previewSrc = null;
9188
+ if (cleared) this.src = "";
9189
+ this.#syncGeneratedMediaElement();
9190
+ if (this.#fileInput) {
9191
+ const defaultLabel = this.getAttribute("label") || "Upload";
9192
+ this.#fileInput.setAttribute("label", this.#src ? "Replace" : defaultLabel);
9193
+ this.#fileInput.removeAttribute("filename");
9194
+ if (this.#src) {
9195
+ this.#fileInput.setAttribute("url", this.#src);
9196
+ } else {
9197
+ this.#fileInput.removeAttribute("url");
9198
+ }
9199
+ }
8855
9200
  this.dispatchEvent(
8856
9201
  new CustomEvent("change", { bubbles: true, cancelable: true }),
8857
9202
  );
@@ -8863,8 +9208,9 @@ class FigMedia extends HTMLElement {
8863
9208
  }
8864
9209
  this.#file = file;
8865
9210
  this.#blobUrl = URL.createObjectURL(file);
9211
+ this.#previewSrc = this.#blobUrl;
8866
9212
 
8867
- this.setAttribute("src", this.#blobUrl);
9213
+ this.#syncGeneratedMediaElement();
8868
9214
 
8869
9215
  this.dispatchEvent(
8870
9216
  new CustomEvent("loaded", {
@@ -8878,9 +9224,8 @@ class FigMedia extends HTMLElement {
8878
9224
  );
8879
9225
 
8880
9226
  if (this.#fileInput) {
8881
- this.#fileInput.removeEventListener("change", this.#boundHandleFileInput);
8882
- this.#fileInput.clear();
8883
- this.#fileInput.addEventListener("change", this.#boundHandleFileInput);
9227
+ this.#fileInput.removeAttribute("url");
9228
+ this.#fileInput.setAttribute("filename", file.name);
8884
9229
  this.#fileInput.setAttribute("label", "Replace");
8885
9230
  }
8886
9231
  }
@@ -8917,10 +9262,14 @@ class FigMedia extends HTMLElement {
8917
9262
  if (oldValue === newValue) return;
8918
9263
 
8919
9264
  if (name === "src") {
8920
- this.#src = newValue || "";
8921
- if (this.#blobUrl && this.#src !== this.#blobUrl) {
9265
+ const nextSrc = newValue || "";
9266
+ const isCurrentPreviewBlob =
9267
+ nextSrc && (nextSrc === this.#previewSrc || nextSrc === this.#blobUrl);
9268
+ this.#src = nextSrc;
9269
+ if (this.#blobUrl && !isCurrentPreviewBlob) {
8922
9270
  URL.revokeObjectURL(this.#blobUrl);
8923
9271
  this.#blobUrl = null;
9272
+ this.#previewSrc = null;
8924
9273
  this.#file = null;
8925
9274
  }
8926
9275
  this.#syncGeneratedMediaElement();
@@ -8928,9 +9277,16 @@ class FigMedia extends HTMLElement {
8928
9277
  const defaultLabel = this.getAttribute("label") || "Upload";
8929
9278
  this.#fileInput.setAttribute("label", this.#src ? "Replace" : defaultLabel);
8930
9279
  if (this.#src) {
8931
- this.#fileInput.setAttribute("url", this.#src);
9280
+ if (this.#file?.name) {
9281
+ this.#fileInput.removeAttribute("url");
9282
+ this.#fileInput.setAttribute("filename", this.#file.name);
9283
+ } else {
9284
+ this.#fileInput.setAttribute("url", this.#src);
9285
+ this.#fileInput.removeAttribute("filename");
9286
+ }
8932
9287
  } else {
8933
9288
  this.#fileInput.removeAttribute("url");
9289
+ this.#fileInput.removeAttribute("filename");
8934
9290
  }
8935
9291
  }
8936
9292
  }
@@ -9101,7 +9457,7 @@ class FigMediaControls extends HTMLElement {
9101
9457
  });
9102
9458
 
9103
9459
  const slider = document.createElement("fig-slider");
9104
- slider.setAttribute("variant", "neue");
9460
+ slider.setAttribute("text", "false");
9105
9461
  slider.setAttribute("min", "0");
9106
9462
  slider.setAttribute("max", String(this.duration));
9107
9463
  slider.setAttribute("step", "0.1");
@@ -9197,7 +9553,7 @@ customElements.define("fig-media-controls", FigMediaControls);
9197
9553
 
9198
9554
  /* File Upload Input */
9199
9555
  class FigInputFile extends HTMLElement {
9200
- static observedAttributes = ["accepts", "label", "disabled", "multiple", "variant", "url"];
9556
+ static observedAttributes = ["accepts", "label", "disabled", "multiple", "variant", "url", "filename"];
9201
9557
 
9202
9558
  #fileInput = null;
9203
9559
  #filenameEl = null;
@@ -9211,6 +9567,8 @@ class FigInputFile extends HTMLElement {
9211
9567
  }
9212
9568
 
9213
9569
  get #urlFilename() {
9570
+ const filename = this.getAttribute("filename");
9571
+ if (filename) return filename;
9214
9572
  const url = this.getAttribute("url");
9215
9573
  if (!url) return "";
9216
9574
  try {
@@ -9254,12 +9612,13 @@ class FigInputFile extends HTMLElement {
9254
9612
  this.#files = null;
9255
9613
  if (this.#fileInput) this.#fileInput.value = "";
9256
9614
  this.removeAttribute("url");
9615
+ this.removeAttribute("filename");
9257
9616
  this.#render();
9258
- this.#emitEvents();
9617
+ this.#emitEvents({ cleared: true });
9259
9618
  }
9260
9619
 
9261
- #emitEvents() {
9262
- const detail = { files: this.#files };
9620
+ #emitEvents(extraDetail = {}) {
9621
+ const detail = { files: this.#files, ...extraDetail };
9263
9622
  this.dispatchEvent(new CustomEvent("input", { detail, bubbles: true }));
9264
9623
  this.dispatchEvent(new CustomEvent("change", { detail, bubbles: true }));
9265
9624
  }
@@ -9349,7 +9708,10 @@ class FigInputFile extends HTMLElement {
9349
9708
  this.getAttribute("disabled") !== "false";
9350
9709
  const multiple = this.hasAttribute("multiple");
9351
9710
  const variant = this.getAttribute("variant") || "input";
9352
- const hasFile = (this.#files && this.#files.length > 0) || !!this.getAttribute("url");
9711
+ const hasFile =
9712
+ (this.#files && this.#files.length > 0) ||
9713
+ !!this.getAttribute("url") ||
9714
+ !!this.getAttribute("filename");
9353
9715
 
9354
9716
  this.innerHTML = "";
9355
9717
 
@@ -9395,7 +9757,7 @@ class FigInputFile extends HTMLElement {
9395
9757
  const clearTooltip = document.createElement("fig-tooltip");
9396
9758
  clearTooltip.setAttribute("text", "Remove");
9397
9759
  this.#clearBtn = document.createElement("fig-button");
9398
- this.#clearBtn.setAttribute("variant", "ghost");
9760
+ this.#clearBtn.setAttribute("variant", variant === "overlay" ? "overlay" : "ghost");
9399
9761
  this.#clearBtn.setAttribute("icon", "true");
9400
9762
  this.#clearBtn.className = "fig-input-file-clear";
9401
9763
  if (disabled) this.#clearBtn.setAttribute("disabled", "");
@@ -9445,7 +9807,7 @@ customElements.define("fig-input-file", FigInputFile);
9445
9807
  * A bezier / spring easing curve editor with draggable control points.
9446
9808
  * @attr {string} value - Bezier: "0.42, 0, 0.58, 1" or Spring: "spring(200, 15, 1)"
9447
9809
  * @attr {number} precision - Decimal places for output values (default 2)
9448
- * @attr {boolean} dropdown - Show a preset dropdown selector
9810
+ * @attr {boolean} edit - Show the editor and custom preset options (default true; set "false" for presets only)
9449
9811
  */
9450
9812
  class FigEasingCurve extends HTMLElement {
9451
9813
  #cp1 = { x: 0.42, y: 0 };
@@ -9463,6 +9825,7 @@ class FigEasingCurve extends HTMLElement {
9463
9825
  #bezierEndpointStart = null;
9464
9826
  #bezierEndpointEnd = null;
9465
9827
  #dropdown = null;
9828
+ #valueInput = null;
9466
9829
  #presetName = null;
9467
9830
  #targetLine = null;
9468
9831
  #springDuration = 0.8;
@@ -9544,7 +9907,7 @@ class FigEasingCurve extends HTMLElement {
9544
9907
  ];
9545
9908
 
9546
9909
  static get observedAttributes() {
9547
- return ["value", "precision", "aspect-ratio"];
9910
+ return ["value", "precision", "aspect-ratio", "edit"];
9548
9911
  }
9549
9912
 
9550
9913
  connectedCallback() {
@@ -9566,6 +9929,8 @@ class FigEasingCurve extends HTMLElement {
9566
9929
  }
9567
9930
 
9568
9931
  attributeChangedCallback(name, oldValue, newValue) {
9932
+ if (oldValue === newValue) return;
9933
+
9569
9934
  if (name === "aspect-ratio") {
9570
9935
  figSyncCssVar(this, "--aspect-ratio", newValue);
9571
9936
  if (this.#svg) {
@@ -9575,16 +9940,21 @@ class FigEasingCurve extends HTMLElement {
9575
9940
  return;
9576
9941
  }
9577
9942
 
9578
- if (!this.#svg) return;
9943
+ if (name === "edit") {
9944
+ if (this.isConnected) this.#render();
9945
+ return;
9946
+ }
9947
+
9579
9948
  if (name === "value" && newValue) {
9580
9949
  const prevMode = this.#mode;
9581
9950
  this.#parseValue(newValue);
9582
9951
  this.#presetName = this.#matchPreset();
9583
- if (prevMode !== this.#mode) {
9952
+ if (prevMode !== this.#mode && this.#isEditEnabled()) {
9584
9953
  this.#render();
9585
9954
  } else {
9586
- this.#updatePaths();
9955
+ if (this.#svg) this.#updatePaths();
9587
9956
  this.#syncDropdown();
9957
+ this.#syncValueInput();
9588
9958
  }
9589
9959
  }
9590
9960
  if (name === "precision") {
@@ -9635,7 +10005,7 @@ class FigEasingCurve extends HTMLElement {
9635
10005
  this.#spring.stiffness = parseFloat(springMatch[1]);
9636
10006
  this.#spring.damping = parseFloat(springMatch[2]);
9637
10007
  this.#spring.mass = parseFloat(springMatch[3]);
9638
- return;
10008
+ return true;
9639
10009
  }
9640
10010
  const parts = str.split(",").map((s) => parseFloat(s.trim()));
9641
10011
  if (parts.length >= 4 && parts.every((n) => !isNaN(n))) {
@@ -9644,7 +10014,9 @@ class FigEasingCurve extends HTMLElement {
9644
10014
  this.#cp1.y = parts[1];
9645
10015
  this.#cp2.x = parts[2];
9646
10016
  this.#cp2.y = parts[3];
10017
+ return true;
9647
10018
  }
10019
+ return false;
9648
10020
  }
9649
10021
 
9650
10022
  #matchPreset() {
@@ -9777,23 +10149,38 @@ class FigEasingCurve extends HTMLElement {
9777
10149
 
9778
10150
  // --- Rendering ---
9779
10151
 
10152
+ #isEditEnabled() {
10153
+ return this.getAttribute("edit") !== "false";
10154
+ }
10155
+
9780
10156
  #render() {
9781
10157
  this.classList.toggle("spring-mode", this.#mode === "spring");
9782
10158
  this.classList.toggle("bezier-mode", this.#mode !== "spring");
9783
10159
  this.#syncMetricsFromCSS();
9784
10160
  this.innerHTML = this.#getInnerHTML();
9785
10161
  this.#cacheRefs();
9786
- this.#syncHandleSizes();
9787
- this.#syncViewportSize();
9788
- this.#updatePaths();
10162
+ if (this.#svg) {
10163
+ this.#syncHandleSizes();
10164
+ this.#syncViewportSize();
10165
+ this.#updatePaths();
10166
+ }
10167
+ this.#syncValueInput();
9789
10168
  this.#setupEvents();
9790
10169
  }
9791
10170
 
10171
+ static #escapeAttribute(value) {
10172
+ return String(value)
10173
+ .replace(/&/g, "&amp;")
10174
+ .replace(/"/g, "&quot;")
10175
+ .replace(/</g, "&lt;")
10176
+ .replace(/>/g, "&gt;");
10177
+ }
10178
+
9792
10179
  #getDropdownHTML() {
9793
- if (this.getAttribute("dropdown") !== "true") return "";
9794
10180
  let optionsHTML = "";
9795
10181
  let currentGroup = undefined;
9796
10182
  for (const p of FigEasingCurve.PRESETS) {
10183
+ if (!this.#isEditEnabled() && !p.value && !p.spring) continue;
9797
10184
  if (p.group !== currentGroup) {
9798
10185
  if (currentGroup !== undefined) optionsHTML += `</optgroup>`;
9799
10186
  if (p.group) optionsHTML += `<optgroup label="${p.group}">`;
@@ -9822,6 +10209,8 @@ class FigEasingCurve extends HTMLElement {
9822
10209
  #getInnerHTML() {
9823
10210
  const size = 200;
9824
10211
  const dropdown = this.#getDropdownHTML();
10212
+ if (!this.#isEditEnabled()) return dropdown;
10213
+ const valueInput = `<fig-input-text class="fig-easing-curve-value-input" value="${FigEasingCurve.#escapeAttribute(this.value)}" full></fig-input-text>`;
9825
10214
 
9826
10215
  if (this.#mode === "spring") {
9827
10216
  const targetY = 40;
@@ -9833,7 +10222,7 @@ class FigEasingCurve extends HTMLElement {
9833
10222
  <path class="fig-easing-curve-path"/>
9834
10223
  <foreignObject class="fig-easing-curve-handle" data-handle="bounce" width="20" height="20"><fig-handle size="small"></fig-handle></foreignObject>
9835
10224
  <foreignObject class="fig-easing-curve-handle fig-easing-curve-duration-bar" data-handle="duration" width="20" height="20"><fig-handle size="small"></fig-handle></foreignObject>
9836
- </svg></div>`;
10225
+ </svg></div>${valueInput}`;
9837
10226
  }
9838
10227
 
9839
10228
  return `${dropdown}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
@@ -9846,7 +10235,7 @@ class FigEasingCurve extends HTMLElement {
9846
10235
  <circle class="fig-easing-curve-endpoint" data-endpoint="end" r="${this.#bezierEndpointRadius}"/>
9847
10236
  <foreignObject class="fig-easing-curve-handle" data-handle="1" width="20" height="20"><fig-handle size="small"></fig-handle></foreignObject>
9848
10237
  <foreignObject class="fig-easing-curve-handle" data-handle="2" width="20" height="20"><fig-handle size="small"></fig-handle></foreignObject>
9849
- </svg></div>`;
10238
+ </svg></div>${valueInput}`;
9850
10239
  }
9851
10240
 
9852
10241
  #readCssNumber(name, fallback) {
@@ -9881,6 +10270,7 @@ class FigEasingCurve extends HTMLElement {
9881
10270
  this.#bezierEndpointStart = this.querySelector('[data-endpoint="start"]');
9882
10271
  this.#bezierEndpointEnd = this.querySelector('[data-endpoint="end"]');
9883
10272
  this.#dropdown = this.querySelector(".fig-easing-curve-dropdown");
10273
+ this.#valueInput = this.querySelector(".fig-easing-curve-value-input");
9884
10274
  this.#targetLine = this.querySelector(".fig-easing-curve-target");
9885
10275
  this.#bounds = this.querySelector(".fig-easing-curve-bounds");
9886
10276
  this.#diagonal = this.querySelector(".fig-easing-curve-diagonal");
@@ -9964,6 +10354,17 @@ class FigEasingCurve extends HTMLElement {
9964
10354
  }
9965
10355
  }
9966
10356
 
10357
+ #syncActiveBezierArm() {
10358
+ this.#line1?.classList.toggle(
10359
+ "is-active",
10360
+ this.#mode === "bezier" && this.#isDragging === 1,
10361
+ );
10362
+ this.#line2?.classList.toggle(
10363
+ "is-active",
10364
+ this.#mode === "bezier" && this.#isDragging === 2,
10365
+ );
10366
+ }
10367
+
9967
10368
  #updateBezierPaths() {
9968
10369
  if (this.#bounds) {
9969
10370
  this.#bounds.setAttribute("x", "0");
@@ -10096,6 +10497,47 @@ class FigEasingCurve extends HTMLElement {
10096
10497
  this.#refreshCustomPresetIcons();
10097
10498
  }
10098
10499
 
10500
+ #syncValueInput() {
10501
+ if (!this.#valueInput) return;
10502
+ this.#valueInput.setAttribute("value", this.value);
10503
+ }
10504
+
10505
+ #parseManualBezierValue(value) {
10506
+ const parts = value.split(",").map((part) => Number.parseFloat(part.trim()));
10507
+ if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
10508
+ return null;
10509
+ }
10510
+ if (parts[0] < 0 || parts[0] > 1 || parts[2] < 0 || parts[2] > 1) {
10511
+ return null;
10512
+ }
10513
+ return parts;
10514
+ }
10515
+
10516
+ #applyManualValue(value, eventType) {
10517
+ const parts = this.#parseManualBezierValue(value);
10518
+ if (!parts) {
10519
+ if (eventType === "change") this.#syncValueInput();
10520
+ return;
10521
+ }
10522
+
10523
+ const prevMode = this.#mode;
10524
+ this.#mode = "bezier";
10525
+ this.#cp1.x = parts[0];
10526
+ this.#cp1.y = parts[1];
10527
+ this.#cp2.x = parts[2];
10528
+ this.#cp2.y = parts[3];
10529
+
10530
+ this.#presetName = this.#matchPreset();
10531
+ if (prevMode !== this.#mode) {
10532
+ this.#render();
10533
+ } else {
10534
+ this.#updatePaths();
10535
+ this.#syncDropdown();
10536
+ if (eventType === "change") this.#syncValueInput();
10537
+ }
10538
+ this.#emit(eventType);
10539
+ }
10540
+
10099
10541
  #setOptionIconByValue(root, optionValue, icon) {
10100
10542
  if (!root) return;
10101
10543
  for (const option of root.querySelectorAll("option")) {
@@ -10107,6 +10549,7 @@ class FigEasingCurve extends HTMLElement {
10107
10549
 
10108
10550
  #refreshCustomPresetIcons() {
10109
10551
  if (!this.#dropdown) return;
10552
+ if (!this.#isEditEnabled()) return;
10110
10553
  const bezierIcon = FigEasingCurve.curveIcon(
10111
10554
  this.#cp1.x,
10112
10555
  this.#cp1.y,
@@ -10147,7 +10590,7 @@ class FigEasingCurve extends HTMLElement {
10147
10590
  }
10148
10591
 
10149
10592
  #setupEvents() {
10150
- if (this.#mode === "bezier") {
10593
+ if (this.#svg && this.#mode === "bezier") {
10151
10594
  this.#handle1.addEventListener("pointerdown", (e) =>
10152
10595
  this.#startBezierDrag(e, 1),
10153
10596
  );
@@ -10165,7 +10608,7 @@ class FigEasingCurve extends HTMLElement {
10165
10608
  this.#startBezierDrag(e, this.#bezierHandleForClientHalf(e));
10166
10609
  });
10167
10610
  }
10168
- } else {
10611
+ } else if (this.#svg) {
10169
10612
  this.#handle1.addEventListener("pointerdown", (e) => {
10170
10613
  e.stopPropagation();
10171
10614
  this.#startSpringDrag(e, "bounce");
@@ -10204,8 +10647,9 @@ class FigEasingCurve extends HTMLElement {
10204
10647
  if (this.#mode !== "bezier") {
10205
10648
  this.#mode = "bezier";
10206
10649
  this.#render();
10207
- } else {
10650
+ } else if (this.#svg) {
10208
10651
  this.#updatePaths();
10652
+ this.#syncValueInput();
10209
10653
  }
10210
10654
  } else if (preset.type === "spring") {
10211
10655
  if (preset.spring) {
@@ -10215,14 +10659,30 @@ class FigEasingCurve extends HTMLElement {
10215
10659
  if (this.#mode !== "spring") {
10216
10660
  this.#mode = "spring";
10217
10661
  this.#render();
10218
- } else {
10662
+ } else if (this.#svg) {
10219
10663
  this.#updatePaths();
10664
+ this.#syncValueInput();
10220
10665
  }
10221
10666
  }
10222
10667
  this.#emit("input");
10223
10668
  this.#emit("change");
10224
10669
  });
10225
10670
  }
10671
+
10672
+ if (this.#valueInput) {
10673
+ this.#valueInput.addEventListener("input", (e) => {
10674
+ e.stopPropagation();
10675
+ const value = e.detail ?? e.target?.value;
10676
+ if (typeof value !== "string") return;
10677
+ this.#applyManualValue(value, "input");
10678
+ });
10679
+ this.#valueInput.addEventListener("change", (e) => {
10680
+ e.stopPropagation();
10681
+ const value = e.detail ?? e.target?.value;
10682
+ if (typeof value !== "string") return;
10683
+ this.#applyManualValue(value, "change");
10684
+ });
10685
+ }
10226
10686
  }
10227
10687
 
10228
10688
  #clientToSVG(e) {
@@ -10243,6 +10703,7 @@ class FigEasingCurve extends HTMLElement {
10243
10703
  #startBezierDrag(e, handle) {
10244
10704
  e.preventDefault();
10245
10705
  this.#isDragging = handle;
10706
+ this.#syncActiveBezierArm();
10246
10707
 
10247
10708
  const onMove = (e) => {
10248
10709
  if (!this.#isDragging) return;
@@ -10263,11 +10724,13 @@ class FigEasingCurve extends HTMLElement {
10263
10724
  this.#updatePaths();
10264
10725
  this.#presetName = this.#matchPreset();
10265
10726
  this.#syncDropdown();
10727
+ this.#syncValueInput();
10266
10728
  this.#emit("input");
10267
10729
  };
10268
10730
 
10269
10731
  const onUp = () => {
10270
10732
  this.#isDragging = null;
10733
+ this.#syncActiveBezierArm();
10271
10734
  document.removeEventListener("pointermove", onMove);
10272
10735
  document.removeEventListener("pointerup", onUp);
10273
10736
  this.#emit("change");
@@ -10311,6 +10774,7 @@ class FigEasingCurve extends HTMLElement {
10311
10774
  this.#updatePaths();
10312
10775
  this.#presetName = this.#matchPreset();
10313
10776
  this.#syncDropdown();
10777
+ this.#syncValueInput();
10314
10778
  this.#emit("input");
10315
10779
  };
10316
10780
 
@@ -11818,11 +12282,34 @@ customElements.define("fig-spinner", FigSpinner);
11818
12282
  /**
11819
12283
  * A styled visual preview layer for arbitrary content such as images, canvas,
11820
12284
  * video, SVG, or custom rendered surfaces.
12285
+ * @attr {string} fit - CSS object-fit value for direct media children
11821
12286
  */
11822
- class FigPreview extends HTMLElement {}
12287
+ class FigPreview extends HTMLElement {
12288
+ static get observedAttributes() {
12289
+ return ["fit"];
12290
+ }
12291
+
12292
+ connectedCallback() {
12293
+ this.#syncFit();
12294
+ }
12295
+
12296
+ attributeChangedCallback(name, oldValue, newValue) {
12297
+ if (oldValue === newValue) return;
12298
+ if (name === "fit") this.#syncFit();
12299
+ }
12300
+
12301
+ #syncFit() {
12302
+ const fit = this.getAttribute("fit");
12303
+ if (fit) {
12304
+ this.style.setProperty("--fig-preview-fit", fit);
12305
+ } else {
12306
+ this.style.removeProperty("--fig-preview-fit");
12307
+ }
12308
+ }
12309
+ }
11823
12310
  customElements.define("fig-preview", FigPreview);
11824
12311
 
11825
- /** @type {Record<string, string>} */
12312
+ /** @type {Record<string, string | { medium: string, small: string }>} */
11826
12313
  const FIG_ICON_TOKENS = {
11827
12314
  chevron: "--icon-16-chevron",
11828
12315
  checkmark: "--icon-16-checkmark",
@@ -11834,16 +12321,25 @@ const FIG_ICON_TOKENS = {
11834
12321
  minus: "--icon-24-minus",
11835
12322
  back: "--icon-24-back",
11836
12323
  forward: "--icon-24-forward",
11837
- close: "--icon-24-close",
12324
+ close: { medium: "--icon-24-close", small: "--icon-16-close" },
11838
12325
  rotate: "--icon-24-rotate",
11839
12326
  swap: "--icon-24-swap",
11840
12327
  play: "--icon-24-play",
11841
12328
  pause: "--icon-24-pause",
12329
+ search: "--icon-24-search",
12330
+ visible: { medium: "--icon-24-visible", small: "--icon-16-visible" },
12331
+ hidden: { medium: "--icon-24-hidden", small: "--icon-16-hidden" },
11842
12332
  };
11843
12333
 
11844
- function figIconCssVar(name) {
12334
+ function figIconCssVar(name, size = "medium") {
11845
12335
  const token = name && FIG_ICON_TOKENS[name];
11846
- return token ? `var(${token})` : "";
12336
+ if (!token) return "";
12337
+
12338
+ const tokenName =
12339
+ typeof token === "string"
12340
+ ? token
12341
+ : token[size === "small" ? "small" : "medium"];
12342
+ return tokenName ? `var(${tokenName})` : "";
11847
12343
  }
11848
12344
 
11849
12345
  /**
@@ -11867,11 +12363,11 @@ class FigIcon extends HTMLElement {
11867
12363
 
11868
12364
  #sync() {
11869
12365
  const iconName = this.getAttribute("name");
11870
- const cssVar = figIconCssVar(iconName);
12366
+ const size = this.getAttribute("size") || "medium";
12367
+ const cssVar = figIconCssVar(iconName, size);
11871
12368
  if (cssVar) this.style.setProperty("--icon", cssVar);
11872
12369
  else this.style.removeProperty("--icon");
11873
12370
 
11874
- const size = this.getAttribute("size") || "medium";
11875
12371
  if (size === "small") {
11876
12372
  this.style.setProperty("--size", "var(--spacer-3)");
11877
12373
  } else {
@@ -11901,2175 +12397,23 @@ customElements.define("fig-button-combo", FigButtonCombo);
11901
12397
  class FigInputCombo extends HTMLElement {}
11902
12398
  customElements.define("fig-input-combo", FigInputCombo);
11903
12399
 
11904
- // FigFillPicker
12400
+
12401
+
12402
+ /* Color Tip */
11905
12403
  /**
11906
- * A comprehensive fill picker component supporting solid colors, gradients, images, video, and webcam.
11907
- * Uses display: contents and wraps a trigger element that opens a dialog picker.
11908
- *
11909
- * @attr {string} value - JSON-encoded fill value
11910
- * @attr {boolean} disabled - Whether the picker is disabled
11911
- * @attr {boolean} alpha - Whether to show alpha/opacity controls (default: true)
11912
- * @attr {string} dialog-position - Position of the popup (default: "left")
12404
+ * A compact solid-color tip that wraps fig-fill-picker.
12405
+ * @attr {string} value - Solid color string (hex/rgb/hsl/named)
12406
+ * @attr {boolean} selected - Whether the tip is selected
12407
+ * @attr {boolean} disabled - Whether the tip is disabled
12408
+ * @fires input - While color changes
12409
+ * @fires change - When color is committed
11913
12410
  */
11914
- class FigFillPicker extends HTMLElement {
11915
- #trigger = null;
12411
+ class FigColorTip extends HTMLElement {
12412
+ #fillPicker = null;
11916
12413
  #chit = null;
11917
- #dialog = null;
11918
- #activeTab = "solid";
11919
- anchorElement = null;
11920
-
11921
- // Fill state
11922
- #fillType = "solid";
11923
- #gamut = "srgb"; // "srgb" or "display-p3"
11924
- #color = { h: 0, s: 0, v: 85, a: 1 }; // Default gray #D9D9D9
11925
- #colorInputMode = "hex";
11926
- #gradient = {
11927
- type: "linear",
11928
- angle: 0,
11929
- centerX: 50,
11930
- centerY: 50,
11931
- interpolationSpace: "oklab",
11932
- hueInterpolation: "shorter",
11933
- stops: [
11934
- { position: 0, color: "#D9D9D9", opacity: 100 },
11935
- { position: 100, color: "#737373", opacity: 100 },
11936
- ],
11937
- };
11938
- #image = { url: null, scaleMode: "fill", scale: 50 };
11939
- #video = { url: null, scaleMode: "fill", scale: 50 };
11940
- #webcam = { stream: null, snapshot: null };
11941
-
11942
- // Custom mode slots and data
11943
- #customSlots = {};
11944
- #customData = {};
11945
-
11946
- // DOM references for solid tab
11947
- #colorArea = null;
11948
- #colorAreaHandle = null;
11949
- #hueSlider = null;
11950
- #opacitySlider = null;
11951
- #isDraggingColor = false;
11952
- #teardownColorAreaEvents = null;
11953
- #dialogOpenObserver = null;
11954
- #webcamTabObserver = null;
11955
-
11956
- constructor() {
11957
- super();
11958
- }
11959
-
11960
- static get observedAttributes() {
11961
- return ["value", "disabled", "alpha", "mode", "experimental"];
11962
- }
11963
-
11964
- connectedCallback() {
11965
- // Use display: contents
11966
- this.style.display = "contents";
11967
-
11968
- requestAnimationFrame(() => {
11969
- this.#setupTrigger();
11970
- this.#parseValue();
11971
- this.#updateChit();
11972
- });
11973
- }
11974
-
11975
- disconnectedCallback() {
11976
- if (this.#teardownColorAreaEvents) {
11977
- this.#teardownColorAreaEvents();
11978
- this.#teardownColorAreaEvents = null;
11979
- }
11980
- if (this.#dialogOpenObserver) {
11981
- this.#dialogOpenObserver.disconnect();
11982
- this.#dialogOpenObserver = null;
11983
- }
11984
- if (this.#webcamTabObserver) {
11985
- this.#webcamTabObserver.disconnect();
11986
- this.#webcamTabObserver = null;
11987
- }
11988
- if (this.#webcam.stream) {
11989
- this.#webcam.stream.getTracks().forEach((track) => track.stop());
11990
- this.#webcam.stream = null;
11991
- }
11992
- if (this.#video.url && this.#video.url.startsWith("blob:")) {
11993
- URL.revokeObjectURL(this.#video.url);
11994
- }
11995
- if (this.#chit) this.#chit.removeAttribute("selected");
11996
- if (this.#dialog) {
11997
- this.#dialog.close();
11998
- this.#dialog.remove();
11999
- this.#dialog = null;
12000
- }
12001
- }
12002
-
12003
- #setupTrigger() {
12004
- const child = Array.from(this.children).find(
12005
- (el) => !el.getAttribute("slot")?.startsWith("mode-"),
12006
- );
12007
-
12008
- if (!child) {
12009
- // Scenario 1: Empty - create fig-chit
12010
- this.#chit = document.createElement("fig-chit");
12011
- this.#chit.setAttribute("background", "#D9D9D9");
12012
- this.appendChild(this.#chit);
12013
- this.#trigger = this.#chit;
12014
- } else if (child.tagName === "FIG-CHIT") {
12015
- // Scenario 2: Has fig-chit - use and populate it
12016
- this.#chit = child;
12017
- this.#trigger = child;
12018
- } else {
12019
- // Scenario 3: Other element - trigger only, no populate
12020
- this.#trigger = child;
12021
- this.#chit = null;
12022
- }
12023
-
12024
- this.#trigger.addEventListener("click", (e) => {
12025
- if (this.hasAttribute("disabled")) return;
12026
- e.stopPropagation();
12027
- e.preventDefault();
12028
- this.#openDialog();
12029
- });
12030
-
12031
- // Prevent fig-chit's internal color input from opening system picker
12032
- if (this.#chit) {
12033
- requestAnimationFrame(() => {
12034
- const input = this.#chit.querySelector('input[type="color"]');
12035
- if (input) {
12036
- input.style.pointerEvents = "none";
12037
- }
12038
- });
12039
- }
12040
- }
12041
-
12042
- #parseValue() {
12043
- const valueAttr = this.getAttribute("value");
12044
- if (!valueAttr) return;
12045
-
12046
- const builtinTypes = ["solid", "gradient", "image", "video", "webcam"];
12047
-
12048
- try {
12049
- const parsed = JSON.parse(valueAttr);
12050
- if (parsed.type) this.#fillType = parsed.type;
12051
- if (parsed.color) {
12052
- // Handle both hex string and HSV object
12053
- if (typeof parsed.color === "string") {
12054
- this.#color = this.#hexToHSV(parsed.color);
12055
- } else if (
12056
- typeof parsed.color === "object" &&
12057
- parsed.color.h !== undefined
12058
- ) {
12059
- this.#color = parsed.color;
12060
- }
12061
- }
12062
- // Parse opacity (0-100) and convert to alpha (0-1)
12063
- if (parsed.opacity !== undefined) {
12064
- this.#color.a = parsed.opacity / 100;
12065
- }
12066
- if (parsed.colorSpace === "display-p3" || parsed.colorSpace === "srgb") {
12067
- this.#gamut = parsed.colorSpace;
12068
- }
12069
- if (parsed.gradient) {
12070
- this.#gradient = normalizeGradientConfig({
12071
- ...this.#gradient,
12072
- ...parsed.gradient,
12073
- });
12074
- }
12075
- if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
12076
- if (parsed.video) this.#video = { ...this.#video, ...parsed.video };
12077
-
12078
- // Store full parsed data for custom (non-built-in) types
12079
- if (parsed.type && !builtinTypes.includes(parsed.type)) {
12080
- const { type, ...rest } = parsed;
12081
- this.#customData[parsed.type] = rest;
12082
- }
12083
- } catch (e) {
12084
- // If not JSON, treat as hex color
12085
- if (valueAttr.startsWith("#")) {
12086
- this.#fillType = "solid";
12087
- this.#color = this.#hexToHSV(valueAttr);
12088
- }
12089
- }
12090
- }
12091
-
12092
- #updateChit() {
12093
- if (!this.#chit) return;
12094
-
12095
- let bg;
12096
- let bgSize = "cover";
12097
- let bgPosition = "center";
12098
-
12099
- switch (this.#fillType) {
12100
- case "solid":
12101
- bg = this.#hsvToHex(this.#color);
12102
- break;
12103
- case "gradient":
12104
- bg = this.#getGradientCSS();
12105
- break;
12106
- case "image":
12107
- if (this.#image.url) {
12108
- bg = `url(${this.#image.url})`;
12109
- const sizing = this.#getBackgroundSizing(
12110
- this.#image.scaleMode,
12111
- this.#image.scale,
12112
- );
12113
- bgSize = sizing.size;
12114
- bgPosition = sizing.position;
12115
- } else {
12116
- bg = "";
12117
- }
12118
- break;
12119
- case "video":
12120
- if (this.#video.url) {
12121
- bg = `url(${this.#video.url})`;
12122
- const sizing = this.#getBackgroundSizing(
12123
- this.#video.scaleMode,
12124
- this.#video.scale,
12125
- );
12126
- bgSize = sizing.size;
12127
- bgPosition = sizing.position;
12128
- } else {
12129
- bg = "";
12130
- }
12131
- break;
12132
- default:
12133
- const slot = this.#customSlots[this.#fillType];
12134
- bg = slot?.element?.getAttribute("chit-background") || "#D9D9D9";
12135
- }
12136
-
12137
- this.#chit.setAttribute("background", bg);
12138
- this.#chit.style.setProperty("--chit-bg-size", bgSize);
12139
- this.#chit.style.setProperty("--chit-bg-position", bgPosition);
12140
-
12141
- if (this.#fillType === "solid") {
12142
- this.#chit.setAttribute("alpha", this.#color.a);
12143
- } else {
12144
- this.#chit.removeAttribute("alpha");
12145
- }
12146
- }
12147
-
12148
- #getBackgroundSizing(scaleMode, scale) {
12149
- switch (scaleMode) {
12150
- case "fill":
12151
- return { size: "cover", position: "center" };
12152
- case "fit":
12153
- return { size: "contain", position: "center" };
12154
- case "crop":
12155
- return { size: "cover", position: "center" };
12156
- case "tile":
12157
- return { size: `${scale}%`, position: "top left" };
12158
- default:
12159
- return { size: "cover", position: "center" };
12160
- }
12161
- }
12162
-
12163
- #openDialog() {
12164
- if (!this.#dialog) {
12165
- this.#createDialog();
12166
- }
12167
-
12168
- this.#switchTab(this.#fillType);
12169
-
12170
- const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
12171
- if (gamutEl) gamutEl.value = this.#gamut;
12172
-
12173
- if (this.#chit) this.#chit.setAttribute("selected", "true");
12174
-
12175
- this.#dialog.open = true;
12176
-
12177
- requestAnimationFrame(() => {
12178
- requestAnimationFrame(() => {
12179
- this.#drawColorArea();
12180
- this.#updateHandlePosition();
12181
- });
12182
- });
12183
- }
12184
-
12185
- open() {
12186
- this.#openDialog();
12187
- }
12188
-
12189
- close() {
12190
- if (this.#dialog) this.#dialog.open = false;
12191
- }
12192
-
12193
- #createDialog() {
12194
- // Collect slotted custom mode content before any DOM changes
12195
- this.#customSlots = {};
12196
- this.querySelectorAll('[slot^="mode-"]').forEach((el) => {
12197
- const modeName = el.getAttribute("slot").slice(5);
12198
- this.#customSlots[modeName] = {
12199
- element: el,
12200
- label:
12201
- el.getAttribute("label") ||
12202
- modeName.charAt(0).toUpperCase() + modeName.slice(1),
12203
- };
12204
- });
12205
-
12206
- this.#dialog = document.createElement("dialog", { is: "fig-popup" });
12207
- this.#dialog.setAttribute("is", "fig-popup");
12208
- this.#dialog.setAttribute("drag", "true");
12209
- this.#dialog.setAttribute("handle", "fig-header");
12210
- this.#dialog.setAttribute("autoresize", "false");
12211
- this.#dialog.classList.add("fig-fill-picker-dialog");
12212
-
12213
- this.#dialog.anchor = this.anchorElement || this.#trigger;
12214
- const dialogPosition = this.getAttribute("dialog-position") || "left";
12215
- this.#dialog.setAttribute("position", dialogPosition);
12216
- this.#dialog.setAttribute("offset", this.getAttribute("dialog-offset") || "8 8");
12217
-
12218
- const builtinModes = ["solid", "gradient", "image", "video", "webcam"];
12219
- const builtinLabels = {
12220
- solid: "Solid",
12221
- gradient: "Gradient",
12222
- image: "Image",
12223
- video: "Video",
12224
- webcam: "Webcam",
12225
- };
12226
-
12227
- // Build allowed modes: built-ins filtered normally, custom names accepted if slot exists
12228
- const mode = this.getAttribute("mode");
12229
- let allowedModes;
12230
- if (mode) {
12231
- const requested = mode.split(",").map((m) => m.trim().toLowerCase());
12232
- allowedModes = requested.filter(
12233
- (m) => builtinModes.includes(m) || this.#customSlots[m],
12234
- );
12235
- if (allowedModes.length === 0) allowedModes = [...builtinModes];
12236
- } else {
12237
- allowedModes = [...builtinModes];
12238
- }
12239
-
12240
- // Build labels map: built-in labels + custom slot labels
12241
- const modeLabels = { ...builtinLabels };
12242
- for (const [name, { label }] of Object.entries(this.#customSlots)) {
12243
- modeLabels[name] = label;
12244
- }
12245
-
12246
- if (!allowedModes.includes(this.#fillType)) {
12247
- this.#fillType = allowedModes[0];
12248
- this.#activeTab = allowedModes[0];
12249
- }
12250
-
12251
- const experimental = this.getAttribute("experimental");
12252
- const expAttr = experimental ? `experimental="${experimental}"` : "";
12253
-
12254
- let headerContent;
12255
- if (allowedModes.length === 1) {
12256
- headerContent = `<h3 class="fig-fill-picker-type-label">${modeLabels[allowedModes[0]]}</h3>`;
12257
- } else {
12258
- const options = allowedModes
12259
- .map((m) => `<option value="${m}">${modeLabels[m]}</option>`)
12260
- .join("\n ");
12261
- headerContent = `<fig-dropdown class="fig-fill-picker-type" ${expAttr} value="${this.#fillType}">
12262
- ${options}
12263
- </fig-dropdown>`;
12264
- }
12265
-
12266
- // Generate tab containers for all allowed modes
12267
- const tabDivs = allowedModes
12268
- .map((m) => `<div class="fig-fill-picker-tab" data-tab="${m}"></div>`)
12269
- .join("\n ");
12270
-
12271
- const gamutDropdown = `<fig-dropdown class="fig-fill-picker-gamut" ${expAttr} value="${this.#gamut}">
12272
- <option value="srgb">sRGB</option>
12273
- <option value="display-p3">Display P3</option>
12274
- </fig-dropdown>`;
12275
-
12276
- this.#dialog.innerHTML = `
12277
- <fig-header>
12278
- ${headerContent}
12279
- ${gamutDropdown}
12280
- <fig-button icon variant="ghost" class="fig-fill-picker-close">
12281
- <fig-icon name="close"></fig-icon>
12282
- </fig-button>
12283
- </fig-header>
12284
- <fig-content>
12285
- ${tabDivs}
12286
- </fig-content>
12287
- `;
12288
-
12289
- document.body.appendChild(this.#dialog);
12290
-
12291
- // Populate custom tab containers and emit modeready
12292
- for (const [modeName, { element }] of Object.entries(this.#customSlots)) {
12293
- const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`);
12294
- if (!container) continue;
12295
-
12296
- // Move children (not the element itself) for vanilla HTML usage
12297
- while (element.firstChild) {
12298
- container.appendChild(element.firstChild);
12299
- }
12300
-
12301
- // Emit modeready so frameworks can render into the container
12302
- this.dispatchEvent(
12303
- new CustomEvent("modeready", {
12304
- bubbles: true,
12305
- detail: { mode: modeName, container },
12306
- }),
12307
- );
12308
- }
12309
-
12310
- // Setup type dropdown switching (only if not locked)
12311
- const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
12312
- if (typeDropdown) {
12313
- typeDropdown.addEventListener("change", (e) => {
12314
- this.#switchTab(e.target.value);
12315
- });
12316
- }
12317
-
12318
- // Setup gamut dropdown
12319
- const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
12320
- if (gamutEl) {
12321
- const handleGamutChange = (e) => {
12322
- const val = e.currentTarget?.value ?? e.target?.value ?? e.detail;
12323
- if (val && val !== this.#gamut) {
12324
- this.#gamut = val;
12325
- this.#onGamutChange();
12326
- }
12327
- };
12328
- gamutEl.addEventListener("input", handleGamutChange);
12329
- gamutEl.addEventListener("change", handleGamutChange);
12330
- }
12331
-
12332
- this.#dialog
12333
- .querySelector(".fig-fill-picker-close")
12334
- .addEventListener("click", () => {
12335
- this.#dialog.open = false;
12336
- });
12337
-
12338
- const onDialogClose = () => {
12339
- if (this.#chit) this.#chit.removeAttribute("selected");
12340
- this.#emitChange();
12341
- this.dispatchEvent(new CustomEvent("close"));
12342
- };
12343
- this.#dialog.addEventListener("close", onDialogClose);
12344
-
12345
- this.#dialogOpenObserver = new MutationObserver(() => {
12346
- const isOpen =
12347
- this.#dialog.hasAttribute("open") &&
12348
- this.#dialog.getAttribute("open") !== "false";
12349
- if (!isOpen) onDialogClose();
12350
- });
12351
- this.#dialogOpenObserver.observe(this.#dialog, {
12352
- attributes: true,
12353
- attributeFilter: ["open"],
12354
- });
12355
-
12356
- // Initialize built-in tabs (skip any overridden by custom slots)
12357
- const builtinInits = {
12358
- solid: () => this.#initSolidTab(),
12359
- gradient: () => this.#initGradientTab(),
12360
- image: () => this.#initImageTab(),
12361
- video: () => this.#initVideoTab(),
12362
- webcam: () => this.#initWebcamTab(),
12363
- };
12364
- for (const [name, init] of Object.entries(builtinInits)) {
12365
- if (!this.#customSlots[name] && allowedModes.includes(name)) init();
12366
- }
12367
-
12368
- // Listen for input/change from custom tab content
12369
- for (const modeName of Object.keys(this.#customSlots)) {
12370
- if (builtinModes.includes(modeName)) continue;
12371
- const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`);
12372
- if (!container) continue;
12373
- container.addEventListener("input", (e) => {
12374
- if (e.target === this) return;
12375
- e.stopPropagation();
12376
- if (e.detail) this.#customData[modeName] = e.detail;
12377
- this.#emitInput();
12378
- });
12379
- container.addEventListener("change", (e) => {
12380
- if (e.target === this) return;
12381
- e.stopPropagation();
12382
- if (e.detail) this.#customData[modeName] = e.detail;
12383
- this.#emitChange();
12384
- });
12385
- }
12386
- }
12387
-
12388
- #switchTab(tabName) {
12389
- // Only allow switching to modes that have a tab container in the dialog
12390
- const tab = this.#dialog?.querySelector(
12391
- `.fig-fill-picker-tab[data-tab="${tabName}"]`,
12392
- );
12393
- if (!tab) return;
12394
-
12395
- this.#activeTab = tabName;
12396
- this.#fillType = tabName;
12397
-
12398
- // Update dropdown selection (only exists if not locked)
12399
- const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
12400
- if (typeDropdown && typeDropdown.value !== tabName) {
12401
- typeDropdown.value = tabName;
12402
- }
12403
-
12404
- // Show/hide tab content
12405
- const tabContents = this.#dialog.querySelectorAll(".fig-fill-picker-tab");
12406
- tabContents.forEach((content) => {
12407
- if (content.dataset.tab === tabName) {
12408
- content.style.display = "block";
12409
- } else {
12410
- content.style.display = "none";
12411
- }
12412
- });
12413
-
12414
- // Zero out content padding for custom mode tabs
12415
- const contentEl = this.#dialog.querySelector("fig-content");
12416
- if (contentEl) {
12417
- contentEl.style.padding = this.#customSlots[tabName] ? "0" : "";
12418
- }
12419
-
12420
- // Update tab-specific UI after visibility change
12421
- if (tabName === "gradient") {
12422
- // Use RAF to ensure layout is complete before updating angle input
12423
- requestAnimationFrame(() => {
12424
- this.#updateGradientUI();
12425
- const barInput = tab.querySelector(".fig-fill-picker-gradient-bar-input");
12426
- barInput?.refreshLayout?.();
12427
- requestAnimationFrame(() => {
12428
- barInput?.refreshLayout?.();
12429
- });
12430
- });
12431
- }
12432
-
12433
- this.#updateChit();
12434
- this.#emitInput();
12435
- }
12436
-
12437
- // ============ SOLID TAB ============
12438
- #initSolidTab() {
12439
- const container = this.#dialog.querySelector('[data-tab="solid"]');
12440
- const showAlpha = this.getAttribute("alpha") !== "false";
12441
- const experimental = this.getAttribute("experimental");
12442
- const expAttr = experimental ? `experimental="${experimental}"` : "";
12443
-
12444
- container.innerHTML = `
12445
- <fig-preview class="fig-fill-picker-color-area">
12446
- <canvas width="200" height="200"></canvas>
12447
- <fig-handle
12448
- type="color"
12449
- color="${this.#hsvToHex({ ...this.#color, a: 1 })}"
12450
- data-no-color-picker
12451
- drag
12452
- drag-surface=".fig-fill-picker-color-area"
12453
- drag-axes="x,y"
12454
- drag-snapping="modifier"
12455
- ></fig-handle>
12456
- </fig-preview>
12457
- <div class="fig-fill-picker-sliders">
12458
- <fig-tooltip text="Sample color"><fig-button icon variant="ghost" class="fig-fill-picker-eyedropper"><fig-icon name="eyedropper"></fig-icon></fig-button></fig-tooltip>
12459
- <fig-slider type="hue" variant="neue" min="0" max="360" value="${
12460
- this.#color.h
12461
- }"></fig-slider>
12462
- ${
12463
- showAlpha
12464
- ? `<fig-slider type="opacity" variant="neue" text="true" units="%" min="0" max="100" value="${
12465
- this.#color.a * 100
12466
- }" color="${this.#hsvToHex(this.#color)}"></fig-slider>`
12467
- : ""
12468
- }
12469
- </div>
12470
- <fig-field class="fig-fill-picker-inputs" direction="horizontal">
12471
- <fig-dropdown class="fig-fill-picker-input-mode" ${expAttr} value="${this.#colorInputMode}">
12472
- <option value="hex">Hex</option>
12473
- <option value="rgb">RGB</option>
12474
- <option value="hsl">HSL</option>
12475
- <option value="hsb">HSB</option>
12476
- <option value="lab">LAB</option>
12477
- <option value="lch">LCH</option>
12478
- </fig-dropdown>
12479
- <span class="fig-fill-picker-input-fields"></span>
12480
- </fig-field>
12481
- `;
12482
-
12483
- // Setup color area
12484
- this.#colorArea = container.querySelector("canvas");
12485
- this.#colorAreaHandle = container.querySelector("fig-handle");
12486
- this.#drawColorArea();
12487
- this.#updateHandlePosition();
12488
- this.#setupColorAreaEvents();
12489
-
12490
- // Setup hue slider
12491
- this.#hueSlider = container.querySelector('fig-slider[type="hue"]');
12492
- this.#hueSlider.addEventListener("input", (e) => {
12493
- this.#color.h = parseFloat(e.target.value);
12494
- this.#drawColorArea();
12495
- this.#updateHandlePosition();
12496
- this.#updateColorInputs();
12497
- this.#emitInput();
12498
- });
12499
- this.#hueSlider.addEventListener("change", () => {
12500
- this.#emitChange();
12501
- });
12502
-
12503
- // Setup opacity slider
12504
- if (showAlpha) {
12505
- this.#opacitySlider = container.querySelector(
12506
- 'fig-slider[type="opacity"]',
12507
- );
12508
- this.#opacitySlider.addEventListener("input", (e) => {
12509
- this.#color.a = parseFloat(e.target.value) / 100;
12510
- this.#updateColorInputs();
12511
- this.#emitInput();
12512
- });
12513
- this.#opacitySlider.addEventListener("change", () => {
12514
- this.#emitChange();
12515
- });
12516
- }
12517
-
12518
- // Setup color input mode dropdown
12519
- const modeDropdown = container.querySelector(".fig-fill-picker-input-mode");
12520
- modeDropdown.addEventListener("input", (e) => {
12521
- this.#colorInputMode = e.target.value;
12522
- this.#rebuildColorInputFields();
12523
- });
12524
-
12525
- // Build initial color input fields
12526
- this.#rebuildColorInputFields();
12527
-
12528
- // Setup eyedropper
12529
- const eyedropper = container.querySelector(".fig-fill-picker-eyedropper");
12530
- if ("EyeDropper" in window) {
12531
- eyedropper.addEventListener("click", async () => {
12532
- try {
12533
- const dropper = new EyeDropper();
12534
- const result = await dropper.open();
12535
- this.#color = { ...this.#hexToHSV(result.sRGBHex), a: this.#color.a };
12536
- this.#drawColorArea();
12537
- this.#updateHandlePosition();
12538
- this.#updateColorInputs();
12539
- this.#emitInput();
12540
- } catch (e) {
12541
- // User cancelled or error
12542
- }
12543
- });
12544
- } else {
12545
- eyedropper.setAttribute("disabled", "");
12546
- eyedropper.title = "EyeDropper not supported in this browser";
12547
- }
12548
- }
12549
-
12550
- #onGamutChange() {
12551
- // Recreate the solid canvas with the new color space
12552
- const solidContainer = this.#dialog?.querySelector('[data-tab="solid"]');
12553
- if (solidContainer) {
12554
- const oldCanvas = solidContainer.querySelector("canvas");
12555
- if (oldCanvas) {
12556
- const newCanvas = document.createElement("canvas");
12557
- newCanvas.width = oldCanvas.width;
12558
- newCanvas.height = oldCanvas.height;
12559
- oldCanvas.replaceWith(newCanvas);
12560
- this.#colorArea = newCanvas;
12561
- this.#setupColorAreaEvents();
12562
- }
12563
- this.#drawColorArea();
12564
- this.#updateHandlePosition();
12565
- }
12566
- // Refresh gradient preview if gradient tab exists
12567
- this.#updateGradientPreview();
12568
- this.#emitInput();
12569
- }
12570
-
12571
- #drawColorArea() {
12572
- // Refresh canvas reference in case DOM changed
12573
- if (!this.#colorArea && this.#dialog) {
12574
- this.#colorArea = this.#dialog.querySelector('[data-tab="solid"] canvas');
12575
- }
12576
- if (!this.#colorArea) return;
12577
-
12578
- const colorSpace = this.#gamut === "display-p3" ? "display-p3" : "srgb";
12579
- const ctx = this.#colorArea.getContext("2d", { colorSpace });
12580
- if (!ctx) return;
12581
-
12582
- const width = this.#colorArea.width;
12583
- const height = this.#colorArea.height;
12584
-
12585
- ctx.clearRect(0, 0, width, height);
12586
-
12587
- const hue = this.#color.h;
12588
- const isP3 = this.#gamut === "display-p3";
12589
-
12590
- const gradH = ctx.createLinearGradient(0, 0, width, 0);
12591
- if (isP3) {
12592
- gradH.addColorStop(0, "color(display-p3 1 1 1)");
12593
- const [r, g, b] = hslToP3(hue, 100, 50);
12594
- gradH.addColorStop(1, `color(display-p3 ${r} ${g} ${b})`);
12595
- } else {
12596
- gradH.addColorStop(0, "#FFFFFF");
12597
- gradH.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
12598
- }
12599
-
12600
- ctx.fillStyle = gradH;
12601
- ctx.fillRect(0, 0, width, height);
12602
-
12603
- const gradV = ctx.createLinearGradient(0, 0, 0, height);
12604
- gradV.addColorStop(0, "rgba(0,0,0,0)");
12605
- gradV.addColorStop(1, "rgba(0,0,0,1)");
12606
-
12607
- ctx.fillStyle = gradV;
12608
- ctx.fillRect(0, 0, width, height);
12609
- }
12610
-
12611
- #updateHandlePosition(retryCount = 0) {
12612
- if (!this.#colorAreaHandle || !this.#colorArea) return;
12613
-
12614
- const rect = this.#colorArea.getBoundingClientRect();
12615
-
12616
- // If the canvas isn't visible yet (0 dimensions), schedule a retry (max 5 attempts)
12617
- if ((rect.width === 0 || rect.height === 0) && retryCount < 5) {
12618
- requestAnimationFrame(() => this.#updateHandlePosition(retryCount + 1));
12619
- return;
12620
- }
12621
-
12622
- const xPct = Math.max(0, Math.min(100, this.#color.s));
12623
- const yPct = Math.max(0, Math.min(100, 100 - this.#color.v));
12624
-
12625
- this.#colorAreaHandle.setAttribute("value", `${xPct}% ${yPct}%`);
12626
- this.#colorAreaHandle.setAttribute(
12627
- "color",
12628
- this.#hsvToHex({ ...this.#color, a: 1 }),
12629
- );
12630
- }
12631
-
12632
- #updateColorFromAreaPosition(x, y, opts = {}) {
12633
- const { updateHandle = true, emitInput = true, emitChange = false } = opts;
12634
- this.#color.s = Math.max(0, Math.min(100, x * 100));
12635
- this.#color.v = Math.max(0, Math.min(100, (1 - y) * 100));
12636
- if (this.#colorAreaHandle) {
12637
- this.#colorAreaHandle.setAttribute(
12638
- "color",
12639
- this.#hsvToHex({ ...this.#color, a: 1 }),
12640
- );
12641
- }
12642
- if (updateHandle) this.#updateHandlePosition();
12643
- this.#updateColorInputs();
12644
- if (emitInput) this.#emitInput();
12645
- if (emitChange) this.#emitChange();
12646
- }
12647
-
12648
- #setupColorAreaEvents() {
12649
- if (this.#teardownColorAreaEvents) {
12650
- this.#teardownColorAreaEvents();
12651
- this.#teardownColorAreaEvents = null;
12652
- }
12653
- if (!this.#colorArea || !this.#colorAreaHandle) return;
12654
-
12655
- const colorAreaEl = this.#colorArea.parentElement || this.#colorArea;
12656
- const colorAreaHandleEl = this.#colorAreaHandle;
12657
-
12658
- let isPlaneDragging = false;
12659
-
12660
- const updatePlaneFromEvent = (e, opts = {}) => {
12661
- const rect = colorAreaEl.getBoundingClientRect();
12662
- if (rect.width === 0 || rect.height === 0) return;
12663
- const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
12664
- const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
12665
- this.#updateColorFromAreaPosition(x / rect.width, y / rect.height, opts);
12666
- };
12667
-
12668
- const onPlanePointerDown = (e) => {
12669
- if (e.button !== 0) return;
12670
- if (
12671
- e.target === colorAreaHandleEl ||
12672
- colorAreaHandleEl.contains(e.target)
12673
- )
12674
- return;
12675
- isPlaneDragging = true;
12676
- this.#isDraggingColor = true;
12677
- colorAreaEl.setPointerCapture(e.pointerId);
12678
- updatePlaneFromEvent(e, { updateHandle: true, emitInput: true });
12679
- };
12680
-
12681
- const onPlanePointerMove = (e) => {
12682
- if (!isPlaneDragging) return;
12683
- if (e.buttons === 0) {
12684
- onPlaneDragEnd();
12685
- return;
12686
- }
12687
- updatePlaneFromEvent(e, { updateHandle: true, emitInput: true });
12688
- };
12689
-
12690
- const onPlaneDragEnd = () => {
12691
- if (!isPlaneDragging) return;
12692
- isPlaneDragging = false;
12693
- this.#isDraggingColor = false;
12694
- this.#emitChange();
12695
- };
12696
-
12697
- const onHandleInput = (e) => {
12698
- this.#isDraggingColor = true;
12699
- const px = e.detail?.px;
12700
- const py = e.detail?.py;
12701
- if (!Number.isFinite(px) || !Number.isFinite(py)) return;
12702
- colorAreaHandleEl.setAttribute("value", `${px * 100}% ${py * 100}%`);
12703
- this.#updateColorFromAreaPosition(px, py, {
12704
- updateHandle: false,
12705
- emitInput: true,
12706
- });
12707
- };
12708
-
12709
- const onHandleChange = (e) => {
12710
- const px = e.detail?.px;
12711
- const py = e.detail?.py;
12712
- if (Number.isFinite(px) && Number.isFinite(py)) {
12713
- colorAreaHandleEl.setAttribute("value", `${px * 100}% ${py * 100}%`);
12714
- this.#updateColorFromAreaPosition(px, py, {
12715
- updateHandle: false,
12716
- emitInput: false,
12717
- });
12718
- }
12719
- this.#isDraggingColor = false;
12720
- this.#emitChange();
12721
- };
12722
-
12723
- colorAreaEl.addEventListener("pointerdown", onPlanePointerDown);
12724
- colorAreaEl.addEventListener("pointermove", onPlanePointerMove);
12725
- colorAreaEl.addEventListener("pointerup", onPlaneDragEnd);
12726
- colorAreaEl.addEventListener("pointercancel", onPlaneDragEnd);
12727
- colorAreaEl.addEventListener("lostpointercapture", onPlaneDragEnd);
12728
-
12729
- colorAreaHandleEl.addEventListener("input", onHandleInput);
12730
- colorAreaHandleEl.addEventListener("change", onHandleChange);
12731
-
12732
- this.#teardownColorAreaEvents = () => {
12733
- colorAreaEl.removeEventListener("pointerdown", onPlanePointerDown);
12734
- colorAreaEl.removeEventListener("pointermove", onPlanePointerMove);
12735
- colorAreaEl.removeEventListener("pointerup", onPlaneDragEnd);
12736
- colorAreaEl.removeEventListener("pointercancel", onPlaneDragEnd);
12737
- colorAreaEl.removeEventListener("lostpointercapture", onPlaneDragEnd);
12738
-
12739
- colorAreaHandleEl.removeEventListener("input", onHandleInput);
12740
- colorAreaHandleEl.removeEventListener("change", onHandleChange);
12741
- this.#isDraggingColor = false;
12742
- };
12743
- }
12744
-
12745
- #rebuildColorInputFields() {
12746
- const container = this.#dialog?.querySelector(
12747
- ".fig-fill-picker-input-fields",
12748
- );
12749
- if (!container) return;
12750
-
12751
- const wrap = (tooltip, html) =>
12752
- `<fig-tooltip text="${tooltip}">${html}</fig-tooltip>`;
12753
-
12754
- const num = (cls, min, max, step) =>
12755
- `<fig-input-number class="${cls}" min="${min}" max="${max}"${step != null ? ` step="${step}"` : ""}></fig-input-number>`;
12756
-
12757
- let html;
12758
- switch (this.#colorInputMode) {
12759
- case "rgb":
12760
- html = `<div class="input-combo">
12761
- ${wrap("Red", num("fig-fill-picker-ci-r", 0, 255))}
12762
- ${wrap("Green", num("fig-fill-picker-ci-g", 0, 255))}
12763
- ${wrap("Blue", num("fig-fill-picker-ci-b", 0, 255))}
12764
- </div>`;
12765
- break;
12766
- case "hsl":
12767
- html = `<div class="input-combo">
12768
- ${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))}
12769
- ${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))}
12770
- ${wrap("Lightness", num("fig-fill-picker-ci-l", 0, 100))}
12771
- </div>`;
12772
- break;
12773
- case "hsb":
12774
- html = `<div class="input-combo">
12775
- ${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))}
12776
- ${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))}
12777
- ${wrap("Brightness", num("fig-fill-picker-ci-v", 0, 100))}
12778
- </div>`;
12779
- break;
12780
- case "lab":
12781
- html = `<div class="input-combo">
12782
- ${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))}
12783
- ${wrap("Green-Red axis", num("fig-fill-picker-ci-oka", -0.4, 0.4, 0.001))}
12784
- ${wrap("Blue-Yellow axis", num("fig-fill-picker-ci-okb", -0.4, 0.4, 0.001))}
12785
- </div>`;
12786
- break;
12787
- case "lch":
12788
- html = `<div class="input-combo">
12789
- ${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))}
12790
- ${wrap("Chroma", num("fig-fill-picker-ci-okc", 0, 0.4, 0.001))}
12791
- ${wrap("Hue", num("fig-fill-picker-ci-okh", 0, 360))}
12792
- </div>`;
12793
- break;
12794
- default: // hex
12795
- html = `<fig-input-text class="fig-fill-picker-ci-hex" placeholder="FFFFFF"></fig-input-text>`;
12796
- break;
12797
- }
12798
-
12799
- container.innerHTML = html;
12800
- this.#wireColorInputEvents();
12801
- requestAnimationFrame(() => this.#updateColorInputs());
12802
- }
12803
-
12804
- #wireColorInputEvents() {
12805
- const container = this.#dialog?.querySelector(
12806
- ".fig-fill-picker-input-fields",
12807
- );
12808
- if (!container) return;
12809
-
12810
- const onInput = () => {
12811
- if (this.#isDraggingColor) return;
12812
- const color = this.#readColorFromInputs();
12813
- if (!color) return;
12814
- this.#color = { ...color, a: this.#color.a };
12815
- this.#drawColorArea();
12816
- this.#updateHandlePosition();
12817
- if (this.#hueSlider) {
12818
- this.#hueSlider.setAttribute("value", this.#color.h);
12819
- }
12820
- this.#emitInput();
12821
- };
12822
-
12823
- const onChange = () => this.#emitChange();
12824
-
12825
- const inputs = container.querySelectorAll(
12826
- "fig-input-number, fig-input-text",
12827
- );
12828
- inputs.forEach((el) => {
12829
- el.addEventListener("input", onInput);
12830
- el.addEventListener("change", onChange);
12831
- });
12832
- }
12833
-
12834
- #readColorFromInputs() {
12835
- const q = (cls) => this.#dialog?.querySelector(`.${cls}`);
12836
- const val = (cls) => parseFloat(q(cls)?.value ?? 0);
12837
-
12838
- switch (this.#colorInputMode) {
12839
- case "rgb":
12840
- return this.#rgbToHSV({
12841
- r: val("fig-fill-picker-ci-r"),
12842
- g: val("fig-fill-picker-ci-g"),
12843
- b: val("fig-fill-picker-ci-b"),
12844
- });
12845
- case "hsl": {
12846
- const rgb = this.#hslToRGB({
12847
- h: val("fig-fill-picker-ci-h"),
12848
- s: val("fig-fill-picker-ci-s"),
12849
- l: val("fig-fill-picker-ci-l"),
12850
- });
12851
- return this.#rgbToHSV(rgb);
12852
- }
12853
- case "hsb":
12854
- return {
12855
- h: val("fig-fill-picker-ci-h"),
12856
- s: val("fig-fill-picker-ci-s"),
12857
- v: val("fig-fill-picker-ci-v"),
12858
- a: 1,
12859
- };
12860
- case "lab": {
12861
- const rgb = this.#oklabToRGB({
12862
- l: val("fig-fill-picker-ci-okl") / 100,
12863
- a: val("fig-fill-picker-ci-oka"),
12864
- b: val("fig-fill-picker-ci-okb"),
12865
- });
12866
- return this.#rgbToHSV(rgb);
12867
- }
12868
- case "lch": {
12869
- const rgb = this.#oklchToRGB({
12870
- l: val("fig-fill-picker-ci-okl") / 100,
12871
- c: val("fig-fill-picker-ci-okc"),
12872
- h: val("fig-fill-picker-ci-okh"),
12873
- });
12874
- return this.#rgbToHSV(rgb);
12875
- }
12876
- default: {
12877
- // hex
12878
- const hexEl = q("fig-fill-picker-ci-hex");
12879
- if (!hexEl) return null;
12880
- let hex = hexEl.value.replace(/^#/, "");
12881
- if (hex.length === 3)
12882
- hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
12883
- if (hex.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(hex)) return null;
12884
- return this.#hexToHSV(`#${hex}`);
12885
- }
12886
- }
12887
- }
12888
-
12889
- #updateColorInputs() {
12890
- if (!this.#dialog) return;
12891
-
12892
- const hex = this.#hsvToHex(this.#color);
12893
- const rgb = this.#hsvToRGB(this.#color);
12894
- const q = (cls) => this.#dialog.querySelector(`.${cls}`);
12895
- const set = (cls, v) => {
12896
- const el = q(cls);
12897
- if (el) el.setAttribute("value", v);
12898
- };
12899
-
12900
- switch (this.#colorInputMode) {
12901
- case "rgb":
12902
- set("fig-fill-picker-ci-r", rgb.r);
12903
- set("fig-fill-picker-ci-g", rgb.g);
12904
- set("fig-fill-picker-ci-b", rgb.b);
12905
- break;
12906
- case "hsl": {
12907
- const hsl = this.#rgbToHSL(rgb);
12908
- set("fig-fill-picker-ci-h", Math.round(hsl.h));
12909
- set("fig-fill-picker-ci-s", Math.round(hsl.s));
12910
- set("fig-fill-picker-ci-l", Math.round(hsl.l));
12911
- break;
12912
- }
12913
- case "hsb":
12914
- set("fig-fill-picker-ci-h", Math.round(this.#color.h));
12915
- set("fig-fill-picker-ci-s", Math.round(this.#color.s));
12916
- set("fig-fill-picker-ci-v", Math.round(this.#color.v));
12917
- break;
12918
- case "lab": {
12919
- const lab = this.#rgbToOKLAB(rgb);
12920
- set("fig-fill-picker-ci-okl", Math.round(lab.l * 100));
12921
- set("fig-fill-picker-ci-oka", +lab.a.toFixed(3));
12922
- set("fig-fill-picker-ci-okb", +lab.b.toFixed(3));
12923
- break;
12924
- }
12925
- case "lch": {
12926
- const lch = this.#rgbToOKLCH(rgb);
12927
- set("fig-fill-picker-ci-okl", Math.round(lch.l * 100));
12928
- set("fig-fill-picker-ci-okc", +lch.c.toFixed(3));
12929
- set("fig-fill-picker-ci-okh", Math.round(lch.h));
12930
- break;
12931
- }
12932
- default: // hex
12933
- set("fig-fill-picker-ci-hex", hex.replace(/^#/, "").toUpperCase());
12934
- break;
12935
- }
12936
-
12937
- if (this.#opacitySlider) {
12938
- this.#opacitySlider.setAttribute("color", hex);
12939
- }
12940
-
12941
- this.#updateChit();
12942
- }
12943
-
12944
- // ============ GRADIENT TAB ============
12945
- #initGradientTab() {
12946
- const container = this.#dialog.querySelector('[data-tab="gradient"]');
12947
- const experimental = this.getAttribute("experimental");
12948
- const expAttr = experimental ? `experimental="${experimental}"` : "";
12949
-
12950
- container.innerHTML = `
12951
- <fig-field class="fig-fill-picker-gradient-header" direction="horizontal">
12952
- <fig-dropdown class="fig-fill-picker-gradient-type" ${expAttr} value="${
12953
- this.#gradient.type
12954
- }">
12955
- <option value="linear" selected>Linear</option>
12956
- <option value="radial">Radial</option>
12957
- <option value="angular">Angular</option>
12958
- </fig-dropdown>
12959
- <fig-tooltip text="Rotate gradient">
12960
- <fig-input-number class="fig-fill-picker-gradient-angle" value="${
12961
- (this.#gradient.angle - 90 + 360) % 360
12962
- }" min="0" max="360" units="°" wrap></fig-input-number>
12963
- </fig-tooltip>
12964
- <div class="fig-fill-picker-gradient-center input-combo" style="display: none;">
12965
- <fig-input-number min="0" max="100" value="${
12966
- this.#gradient.centerX
12967
- }" units="%" class="fig-fill-picker-gradient-cx"></fig-input-number>
12968
- <fig-input-number min="0" max="100" value="${
12969
- this.#gradient.centerY
12970
- }" units="%" class="fig-fill-picker-gradient-cy"></fig-input-number>
12971
- </div>
12972
- <fig-tooltip text="Flip gradient">
12973
- <fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip">
12974
- <fig-icon name="swap"></fig-icon>
12975
- </fig-button>
12976
- </fig-tooltip>
12977
- </fig-field>
12978
- <fig-preview class="fig-fill-picker-gradient-preview">
12979
- <fig-input-gradient class="fig-fill-picker-gradient-bar-input" edit="true" size="large" value='${JSON.stringify({ type: "gradient", gradient: gradientToValueShape(this.#gradient) })}'></fig-input-gradient>
12980
- </fig-preview>
12981
- <fig-field class="fig-fill-picker-gradient-interpolation" direction="horizontal">
12982
- <label>Mixing</label>
12983
- <fig-dropdown class="fig-fill-picker-gradient-space" full ${expAttr} value="${
12984
- this.#gradient.interpolationSpace === "oklch"
12985
- ? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
12986
- : this.#gradient.interpolationSpace
12987
- }">
12988
- <optgroup label="sRGB">
12989
- <option value="srgb-linear">Linear</option>
12990
- </optgroup>
12991
- <optgroup label="OKLab">
12992
- <option value="oklab">Perceptual</option>
12993
- </optgroup>
12994
- <optgroup label="OKLCH">
12995
- <option value="oklch-shorter">Shorter hue</option>
12996
- <option value="oklch-longer">Longer hue</option>
12997
- <option value="oklch-increasing">Increasing hue</option>
12998
- <option value="oklch-decreasing">Decreasing hue</option>
12999
- </optgroup>
13000
- </fig-dropdown>
13001
- </fig-field>
13002
- <div class="fig-fill-picker-gradient-stops">
13003
- <fig-header class="fig-fill-picker-gradient-stops-header" borderless>
13004
- <span>Stops</span>
13005
- <fig-button icon variant="ghost" class="fig-fill-picker-gradient-add" title="Add stop">
13006
- <fig-icon name="add"></fig-icon>
13007
- </fig-button>
13008
- </fig-header>
13009
- <div class="fig-fill-picker-gradient-stops-list"></div>
13010
- </div>
13011
- `;
13012
-
13013
- this.#updateGradientUI();
13014
- this.#setupGradientEvents(container);
13015
- }
13016
-
13017
- #setupGradientEvents(container) {
13018
- // Type dropdown
13019
- const typeDropdown = container.querySelector(
13020
- ".fig-fill-picker-gradient-type",
13021
- );
13022
- const getDropdownValue = (event) =>
13023
- event.currentTarget?.value ?? event.target?.value ?? event.detail;
13024
-
13025
- const handleTypeChange = (e) => {
13026
- this.#gradient.type = getDropdownValue(e);
13027
- this.#updateGradientUI();
13028
- this.#emitInput();
13029
- };
13030
- typeDropdown.addEventListener("input", handleTypeChange);
13031
- typeDropdown.addEventListener("change", handleTypeChange);
13032
-
13033
- const interpolationDropdown = container.querySelector(
13034
- ".fig-fill-picker-gradient-space",
13035
- );
13036
- const handleInterpolationChange = (e) => {
13037
- const val = getDropdownValue(e);
13038
- let space = val;
13039
- let hue = "shorter";
13040
- if (val.startsWith("oklch-")) {
13041
- space = "oklch";
13042
- hue = val.slice(6);
13043
- }
13044
- this.#gradient = normalizeGradientConfig({
13045
- ...this.#gradient,
13046
- interpolationSpace: space,
13047
- hueInterpolation: hue,
13048
- });
13049
- this.#updateGradientUI();
13050
- this.#emitInput();
13051
- };
13052
- interpolationDropdown?.addEventListener("input", handleInterpolationChange);
13053
- interpolationDropdown?.addEventListener(
13054
- "change",
13055
- handleInterpolationChange,
13056
- );
13057
-
13058
- // Angle input
13059
- const angleInput = container.querySelector(
13060
- ".fig-fill-picker-gradient-angle",
13061
- );
13062
- angleInput.addEventListener("input", (e) => {
13063
- const pickerAngle = parseFloat(e.target.value) || 0;
13064
- this.#gradient.angle = (pickerAngle + 90) % 360;
13065
- this.#updateGradientPreview();
13066
- this.#emitInput();
13067
- });
13068
-
13069
- // Center X/Y inputs
13070
- const cxInput = container.querySelector(".fig-fill-picker-gradient-cx");
13071
- const cyInput = container.querySelector(".fig-fill-picker-gradient-cy");
13072
- cxInput?.addEventListener("input", (e) => {
13073
- this.#gradient.centerX = parseFloat(e.target.value) || 50;
13074
- this.#updateGradientPreview();
13075
- this.#emitInput();
13076
- });
13077
- cyInput?.addEventListener("input", (e) => {
13078
- this.#gradient.centerY = parseFloat(e.target.value) || 50;
13079
- this.#updateGradientPreview();
13080
- this.#emitInput();
13081
- });
13082
-
13083
- // Flip button
13084
- container
13085
- .querySelector(".fig-fill-picker-gradient-flip")
13086
- .addEventListener("click", () => {
13087
- this.#gradient.stops.forEach((stop) => {
13088
- stop.position = 100 - stop.position;
13089
- });
13090
- this.#gradient.stops.sort((a, b) => a.position - b.position);
13091
- this.#updateGradientUI();
13092
- this.#emitInput();
13093
- });
13094
-
13095
- // Add stop button
13096
- container
13097
- .querySelector(".fig-fill-picker-gradient-add")
13098
- .addEventListener("click", () => {
13099
- const midPosition = 50;
13100
- this.#gradient.stops.push({
13101
- position: midPosition,
13102
- color: "#888888",
13103
- opacity: 100,
13104
- });
13105
- this.#gradient.stops.sort((a, b) => a.position - b.position);
13106
- this.#updateGradientUI();
13107
- this.#emitInput();
13108
- });
13109
-
13110
- // Embedded gradient bar input
13111
- const gradientBarInput = container.querySelector(
13112
- ".fig-fill-picker-gradient-bar-input",
13113
- );
13114
- if (gradientBarInput) {
13115
- const syncFromBarInput = (e) => {
13116
- e.stopPropagation();
13117
- const detail = e.detail;
13118
- if (!detail?.gradient) return;
13119
- this.#gradient = normalizeGradientConfig({
13120
- ...this.#gradient,
13121
- ...detail.gradient,
13122
- });
13123
- this.#updateChit();
13124
- this.#updateGradientStopsList();
13125
- };
13126
- gradientBarInput.addEventListener("input", (e) => {
13127
- syncFromBarInput(e);
13128
- this.#emitInput();
13129
- });
13130
- gradientBarInput.addEventListener("change", (e) => {
13131
- syncFromBarInput(e);
13132
- this.#emitChange();
13133
- });
13134
- }
13135
- }
13136
-
13137
- #updateGradientUI() {
13138
- if (!this.#dialog) return;
13139
-
13140
- const container = this.#dialog.querySelector('[data-tab="gradient"]');
13141
- if (!container) return;
13142
- this.#gradient = normalizeGradientConfig(this.#gradient);
13143
-
13144
- // Show/hide angle vs center inputs
13145
- const angleInput = container.querySelector(
13146
- ".fig-fill-picker-gradient-angle",
13147
- );
13148
- const centerInputs = container.querySelector(
13149
- ".fig-fill-picker-gradient-center",
13150
- );
13151
-
13152
- if (this.#gradient.type === "radial") {
13153
- angleInput.style.display = "none";
13154
- centerInputs.style.display = "flex";
13155
- } else {
13156
- angleInput.style.display = "block";
13157
- centerInputs.style.display = "none";
13158
- // Sync angle input value (convert CSS angle to picker angle)
13159
- const pickerAngle = (this.#gradient.angle - 90 + 360) % 360;
13160
- angleInput.setAttribute("value", pickerAngle);
13161
- }
13162
-
13163
- const interpolationDropdown = container.querySelector(
13164
- ".fig-fill-picker-gradient-space",
13165
- );
13166
- if (interpolationDropdown) {
13167
- interpolationDropdown.value =
13168
- this.#gradient.interpolationSpace === "oklch"
13169
- ? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
13170
- : this.#gradient.interpolationSpace;
13171
- }
13172
-
13173
- this.#updateGradientPreview();
13174
- this.#updateGradientStopsList();
13175
- }
13176
-
13177
- #updateGradientPreview() {
13178
- if (!this.#dialog) return;
13179
-
13180
- const barInput = this.#dialog.querySelector(
13181
- ".fig-fill-picker-gradient-bar-input",
13182
- );
13183
- if (barInput) {
13184
- barInput.setAttribute(
13185
- "value",
13186
- JSON.stringify({
13187
- type: "gradient",
13188
- gradient: gradientToValueShape(this.#gradient),
13189
- }),
13190
- );
13191
- }
13192
-
13193
- this.#updateChit();
13194
- }
13195
-
13196
- #updateGradientStopsList() {
13197
- if (!this.#dialog) return;
13198
-
13199
- const list = this.#dialog.querySelector(
13200
- ".fig-fill-picker-gradient-stops-list",
13201
- );
13202
- if (!list) return;
13203
-
13204
- const existingRows = list.querySelectorAll(
13205
- ".fig-fill-picker-gradient-stop-row",
13206
- );
13207
-
13208
- if (existingRows.length === this.#gradient.stops.length) {
13209
- this.#gradient.stops.forEach((stop, index) => {
13210
- const row = existingRows[index];
13211
- row.dataset.index = index;
13212
- const posInput = row.querySelector(".fig-fill-picker-stop-position");
13213
- if (posInput) posInput.setAttribute("value", stop.position);
13214
- const colorInput = row.querySelector(".fig-fill-picker-stop-color");
13215
- if (colorInput) colorInput.setAttribute("value", stop.color);
13216
- const removeBtn = row.querySelector(".fig-fill-picker-stop-remove");
13217
- if (removeBtn) {
13218
- if (this.#gradient.stops.length <= 2)
13219
- removeBtn.setAttribute("disabled", "");
13220
- else removeBtn.removeAttribute("disabled");
13221
- }
13222
- });
13223
- return;
13224
- }
13225
-
13226
- this.#rebuildGradientStopsList(list);
13227
- }
13228
-
13229
- #rebuildGradientStopsList(list) {
13230
- list.innerHTML = this.#gradient.stops
13231
- .map(
13232
- (stop, index) => `
13233
- <fig-field class="fig-fill-picker-gradient-stop-row" direction="horizontal" data-index="${index}">
13234
- <fig-input-number class="fig-fill-picker-stop-position" min="0" max="100" value="${
13235
- stop.position
13236
- }" units="%"></fig-input-number>
13237
- <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${
13238
- stop.color
13239
- }"></fig-input-color>
13240
- <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
13241
- this.#gradient.stops.length <= 2 ? "disabled" : ""
13242
- }>
13243
- <fig-icon name="minus"></fig-icon>
13244
- </fig-button>
13245
- </fig-field>
13246
- `,
13247
- )
13248
- .join("");
13249
-
13250
- list
13251
- .querySelectorAll(".fig-fill-picker-gradient-stop-row")
13252
- .forEach((row) => {
13253
- const index = parseInt(row.dataset.index);
13254
-
13255
- row
13256
- .querySelector(".fig-fill-picker-stop-position")
13257
- .addEventListener("input", (e) => {
13258
- this.#gradient.stops[index].position =
13259
- parseFloat(e.target.value) || 0;
13260
- this.#updateGradientPreview();
13261
- this.#emitInput();
13262
- });
13263
-
13264
- const stopColor = row.querySelector(".fig-fill-picker-stop-color");
13265
- const stopFillPicker = stopColor.querySelector("fig-fill-picker");
13266
- if (stopFillPicker) {
13267
- stopFillPicker.anchorElement = this.#dialog;
13268
- } else {
13269
- requestAnimationFrame(() => {
13270
- const fp = stopColor.querySelector("fig-fill-picker");
13271
- if (fp) fp.anchorElement = this.#dialog;
13272
- });
13273
- }
13274
-
13275
- stopColor.addEventListener("input", (e) => {
13276
- this.#gradient.stops[index].color =
13277
- e.target.hexOpaque || e.target.value;
13278
- const a = e.detail?.rgba?.a;
13279
- if (a !== undefined) {
13280
- this.#gradient.stops[index].opacity = Math.round(a * 100);
13281
- }
13282
- this.#updateGradientPreview();
13283
- this.#emitInput();
13284
- });
13285
-
13286
- row
13287
- .querySelector(".fig-fill-picker-stop-remove")
13288
- .addEventListener("click", () => {
13289
- if (this.#gradient.stops.length > 2) {
13290
- this.#gradient.stops.splice(index, 1);
13291
- this.#updateGradientUI();
13292
- this.#emitInput();
13293
- }
13294
- });
13295
- });
13296
- }
13297
-
13298
- #buildGradientCSS(interpolationSpaceOverride, includeInterpolation = true) {
13299
- const gradient = normalizeGradientConfig({
13300
- ...this.#gradient,
13301
- interpolationSpace:
13302
- interpolationSpaceOverride ?? this.#gradient.interpolationSpace,
13303
- });
13304
- const isP3 = this.#gamut === "display-p3";
13305
- const stops = gradient.stops
13306
- .map((s) => {
13307
- const alpha = (s.opacity ?? 100) / 100;
13308
- const color = isP3
13309
- ? this.#hexToP3(s.color, alpha)
13310
- : this.#hexToRGBA(s.color, alpha);
13311
- return `${color} ${s.position}%`;
13312
- })
13313
- .join(", ");
13314
- const interpolation = includeInterpolation
13315
- ? ` ${gradientInterpolationClause(gradient)}`
13316
- : "";
13317
- switch (gradient.type) {
13318
- case "linear":
13319
- return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
13320
- case "radial":
13321
- return `radial-gradient(circle at ${gradient.centerX}% ${gradient.centerY}%${interpolation}, ${stops})`;
13322
- case "angular":
13323
- return `conic-gradient(from ${gradient.angle}deg${interpolation}, ${stops})`;
13324
- default:
13325
- return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
13326
- }
13327
- }
13328
-
13329
- static #gradientSupportCache = new Map();
13330
- #testGradientSupport(css) {
13331
- const cached = FigFillPicker.#gradientSupportCache.get(css);
13332
- if (cached !== undefined) return cached;
13333
- const el = document.createElement("div");
13334
- el.style.background = css;
13335
- const result = !!el.style.background;
13336
- FigFillPicker.#gradientSupportCache.set(css, result);
13337
- return result;
13338
- }
13339
-
13340
- #getGradientCSS() {
13341
- const preferred = this.#buildGradientCSS(undefined, true);
13342
- if (this.#testGradientSupport(preferred)) return preferred;
13343
-
13344
- const oklabFallback = this.#buildGradientCSS("oklab", true);
13345
- if (this.#testGradientSupport(oklabFallback)) return oklabFallback;
13346
-
13347
- return this.#buildGradientCSS("oklab", false);
13348
- }
13349
-
13350
- // ============ IMAGE TAB ============
13351
- #initImageTab() {
13352
- const container = this.#dialog.querySelector('[data-tab="image"]');
13353
- const experimental = this.getAttribute("experimental");
13354
- const expAttr = experimental ? `experimental="${experimental}"` : "";
13355
-
13356
- container.innerHTML = `
13357
- <fig-field class="fig-fill-picker-media-header" direction="horizontal">
13358
- <fig-dropdown class="fig-fill-picker-scale-mode" ${expAttr} value="${
13359
- this.#image.scaleMode
13360
- }">
13361
- <option value="fill" selected>Fill</option>
13362
- <option value="fit">Fit</option>
13363
- <option value="crop">Crop</option>
13364
- <option value="tile">Tile</option>
13365
- </fig-dropdown>
13366
- <fig-input-number class="fig-fill-picker-scale" min="1" max="200" value="${
13367
- this.#image.scale
13368
- }" units="%" ${
13369
- this.#image.scaleMode === "tile" ? "" : 'style="display: none;"'
13370
- }></fig-input-number>
13371
- </fig-field>
13372
- <fig-image class="fig-fill-picker-media-preview fig-fill-picker-image-preview" upload="true" label="Upload from computer" size="auto" aspect-ratio="1/1" fit="cover" checkerboard="true"></fig-image>
13373
- `;
13374
-
13375
- this.#setupImageEvents(container);
13376
- }
13377
-
13378
- #setupImageEvents(container) {
13379
- const scaleModeDropdown = container.querySelector(
13380
- ".fig-fill-picker-scale-mode",
13381
- );
13382
- const scaleInput = container.querySelector(".fig-fill-picker-scale");
13383
- const preview = container.querySelector(".fig-fill-picker-image-preview");
13384
-
13385
- scaleModeDropdown.addEventListener("change", (e) => {
13386
- this.#image.scaleMode = e.target.value;
13387
- scaleInput.style.display = e.target.value === "tile" ? "block" : "none";
13388
- this.#updateImagePreview(preview);
13389
- this.#updateChit();
13390
- this.#emitInput();
13391
- });
13392
-
13393
- scaleInput.addEventListener("input", (e) => {
13394
- this.#image.scale = parseFloat(e.target.value) || 100;
13395
- this.#updateImagePreview(preview);
13396
- this.#updateChit();
13397
- this.#emitInput();
13398
- });
13399
-
13400
- preview.addEventListener("loaded", (e) => {
13401
- const src = e.detail?.src || preview.src;
13402
- if (!src) return;
13403
- this.#image.url = src;
13404
- this.#updateImagePreview(preview);
13405
- this.#updateChit();
13406
- this.#emitInput();
13407
- });
13408
-
13409
- preview.addEventListener("change", () => {
13410
- if (preview.src) return;
13411
- this.#image.url = null;
13412
- this.#updateImagePreview(preview);
13413
- this.#updateChit();
13414
- this.#emitInput();
13415
- });
13416
-
13417
- this.#updateImagePreview(preview);
13418
- }
13419
-
13420
- #updateImagePreview(element) {
13421
- if (!this.#image.url) {
13422
- element.removeAttribute("src");
13423
- element.classList.remove("has-media", "is-tiled");
13424
- element.style.backgroundImage = "";
13425
- element.style.backgroundPosition = "";
13426
- element.style.backgroundRepeat = "";
13427
- element.style.backgroundSize = "";
13428
- return;
13429
- }
13430
-
13431
- element.setAttribute("src", this.#image.url);
13432
- element.classList.add("has-media");
13433
- element.style.backgroundImage = "";
13434
- element.style.backgroundPosition = "";
13435
- element.style.backgroundRepeat = "";
13436
- element.style.backgroundSize = "";
13437
- element.mediaEl?.style.removeProperty("opacity");
13438
-
13439
- const fileInput = element.querySelector("fig-input-file[data-generated]");
13440
- if (fileInput) {
13441
- fileInput.setAttribute("label", "Replace");
13442
- fileInput.removeAttribute("url");
13443
- }
13444
-
13445
- switch (this.#image.scaleMode) {
13446
- case "fill":
13447
- element.classList.remove("is-tiled");
13448
- element.setAttribute("fit", "cover");
13449
- break;
13450
- case "crop":
13451
- element.classList.remove("is-tiled");
13452
- element.setAttribute("fit", "cover");
13453
- break;
13454
- case "fit":
13455
- element.classList.remove("is-tiled");
13456
- element.setAttribute("fit", "contain");
13457
- break;
13458
- case "tile":
13459
- element.classList.add("is-tiled");
13460
- element.setAttribute("fit", "none");
13461
- element.style.backgroundImage = `url(${this.#image.url})`;
13462
- element.style.backgroundPosition = "top left";
13463
- element.style.backgroundSize = `${this.#image.scale}%`;
13464
- element.style.backgroundRepeat = "repeat";
13465
- if (element.mediaEl) element.mediaEl.style.opacity = "0";
13466
- break;
13467
- }
13468
- }
13469
-
13470
- // For video elements (still uses object-fit)
13471
- #updateVideoPreviewStyle(element) {
13472
- if (element.tagName === "FIG-MEDIA") {
13473
- if (!this.#video.url) {
13474
- element.removeAttribute("src");
13475
- element.classList.remove("has-media");
13476
- return;
13477
- }
13478
-
13479
- element.setAttribute("src", this.#video.url);
13480
- element.classList.add("has-media");
13481
-
13482
- const fileInput = element.querySelector("fig-input-file[data-generated]");
13483
- if (fileInput) {
13484
- fileInput.setAttribute("label", "Replace");
13485
- fileInput.removeAttribute("url");
13486
- }
13487
-
13488
- switch (this.#video.scaleMode) {
13489
- case "fill":
13490
- case "crop":
13491
- element.setAttribute("fit", "cover");
13492
- break;
13493
- case "fit":
13494
- element.setAttribute("fit", "contain");
13495
- break;
13496
- }
13497
- return;
13498
- }
13499
-
13500
- element.style.objectPosition = "center";
13501
- element.style.width = "100%";
13502
- element.style.height = "100%";
13503
-
13504
- switch (this.#video.scaleMode) {
13505
- case "fill":
13506
- case "crop":
13507
- element.style.objectFit = "cover";
13508
- break;
13509
- case "fit":
13510
- element.style.objectFit = "contain";
13511
- break;
13512
- }
13513
- }
13514
-
13515
- // ============ VIDEO TAB ============
13516
- #initVideoTab() {
13517
- const container = this.#dialog.querySelector('[data-tab="video"]');
13518
- const experimental = this.getAttribute("experimental");
13519
- const expAttr = experimental ? `experimental="${experimental}"` : "";
13520
-
13521
- container.innerHTML = `
13522
- <fig-field class="fig-fill-picker-media-header" direction="horizontal">
13523
- <fig-dropdown class="fig-fill-picker-scale-mode" ${expAttr} value="${
13524
- this.#video.scaleMode
13525
- }">
13526
- <option value="fill" selected>Fill</option>
13527
- <option value="fit">Fit</option>
13528
- <option value="crop">Crop</option>
13529
- </fig-dropdown>
13530
- </fig-field>
13531
- <fig-media class="fig-fill-picker-media-preview fig-fill-picker-video-preview" type="video" upload="true" label="Upload from computer" size="auto" aspect-ratio="1/1" fit="cover" checkerboard="true" autoplay="true" muted="true" loop="true"></fig-media>
13532
- `;
13533
-
13534
- this.#setupVideoEvents(container);
13535
- }
13536
-
13537
- #setupVideoEvents(container) {
13538
- const scaleModeDropdown = container.querySelector(
13539
- ".fig-fill-picker-scale-mode",
13540
- );
13541
- const preview = container.querySelector(".fig-fill-picker-video-preview");
13542
-
13543
- scaleModeDropdown.addEventListener("change", (e) => {
13544
- this.#video.scaleMode = e.target.value;
13545
- this.#updateVideoPreviewStyle(preview);
13546
- this.#updateChit();
13547
- this.#emitInput();
13548
- });
13549
-
13550
- preview.addEventListener("loaded", (e) => {
13551
- const src = e.detail?.src || preview.src;
13552
- if (!src) return;
13553
- this.#video.url = src;
13554
- this.#updateVideoPreviewStyle(preview);
13555
- preview.play?.();
13556
- this.#updateChit();
13557
- this.#emitInput();
13558
- });
13559
-
13560
- preview.addEventListener("change", () => {
13561
- if (preview.src) return;
13562
- this.#video.url = null;
13563
- this.#updateVideoPreviewStyle(preview);
13564
- this.#updateChit();
13565
- this.#emitInput();
13566
- });
13567
-
13568
- this.#updateVideoPreviewStyle(preview);
13569
- }
13570
-
13571
- // ============ WEBCAM TAB ============
13572
- #initWebcamTab() {
13573
- const container = this.#dialog.querySelector('[data-tab="webcam"]');
13574
- const experimental = this.getAttribute("experimental");
13575
- const expAttr = experimental ? `experimental="${experimental}"` : "";
13576
-
13577
- container.innerHTML = `
13578
- <div class="fig-fill-picker-webcam-preview">
13579
- <div class="fig-fill-picker-checkerboard"></div>
13580
- <video class="fig-fill-picker-webcam-video" autoplay muted playsinline></video>
13581
- <div class="fig-fill-picker-webcam-status">
13582
- <span>Camera access required</span>
13583
- </div>
13584
- </div>
13585
- <fig-field class="fig-fill-picker-webcam-controls" direction="horizontal">
13586
- <fig-dropdown class="fig-fill-picker-camera-select" ${expAttr} style="display: none;">
13587
- </fig-dropdown>
13588
- <fig-button class="fig-fill-picker-webcam-capture" variant="primary">
13589
- Capture
13590
- </fig-button>
13591
- </fig-field>
13592
- `;
13593
-
13594
- this.#setupWebcamEvents(container);
13595
- }
13596
-
13597
- #setupWebcamEvents(container) {
13598
- const video = container.querySelector(".fig-fill-picker-webcam-video");
13599
- const status = container.querySelector(".fig-fill-picker-webcam-status");
13600
- const captureBtn = container.querySelector(
13601
- ".fig-fill-picker-webcam-capture",
13602
- );
13603
- const cameraSelect = container.querySelector(
13604
- ".fig-fill-picker-camera-select",
13605
- );
13606
-
13607
- const startWebcam = async (deviceId = null) => {
13608
- try {
13609
- const constraints = {
13610
- video: deviceId ? { deviceId: { exact: deviceId } } : true,
13611
- };
13612
-
13613
- if (this.#webcam.stream) {
13614
- this.#webcam.stream.getTracks().forEach((track) => track.stop());
13615
- }
13616
-
13617
- this.#webcam.stream =
13618
- await navigator.mediaDevices.getUserMedia(constraints);
13619
- video.srcObject = this.#webcam.stream;
13620
- video.style.display = "block";
13621
- status.style.display = "none";
13622
-
13623
- // Enumerate cameras
13624
- const devices = await navigator.mediaDevices.enumerateDevices();
13625
- const cameras = devices.filter((d) => d.kind === "videoinput");
13626
-
13627
- if (cameras.length > 1) {
13628
- cameraSelect.style.display = "block";
13629
- cameraSelect.innerHTML = cameras
13630
- .map(
13631
- (cam, i) =>
13632
- `<option value="${cam.deviceId}">${
13633
- cam.label || `Camera ${i + 1}`
13634
- }</option>`,
13635
- )
13636
- .join("");
13637
- }
13638
- } catch (err) {
13639
- console.error("Webcam error:", err.name, err.message);
13640
- let message = "Camera access denied";
13641
- if (err.name === "NotAllowedError") {
13642
- message = "Camera permission denied";
13643
- } else if (err.name === "NotFoundError") {
13644
- message = "No camera found";
13645
- } else if (err.name === "NotReadableError") {
13646
- message = "Camera in use by another app";
13647
- } else if (err.name === "OverconstrainedError") {
13648
- message = "Camera constraints not supported";
13649
- } else if (!window.isSecureContext) {
13650
- message = "Camera requires secure context";
13651
- }
13652
- status.innerHTML = `<span>${message}</span>`;
13653
- status.style.display = "flex";
13654
- video.style.display = "none";
13655
- }
13656
- };
13657
-
13658
- this.#webcamTabObserver = new MutationObserver(() => {
13659
- if (container.style.display !== "none" && !this.#webcam.stream) {
13660
- startWebcam();
13661
- }
13662
- });
13663
- this.#webcamTabObserver.observe(container, {
13664
- attributes: true,
13665
- attributeFilter: ["style"],
13666
- });
13667
-
13668
- cameraSelect.addEventListener("change", (e) => {
13669
- startWebcam(e.target.value);
13670
- });
13671
-
13672
- captureBtn.addEventListener("click", () => {
13673
- if (!this.#webcam.stream) return;
13674
-
13675
- const canvas = document.createElement("canvas");
13676
- canvas.width = video.videoWidth;
13677
- canvas.height = video.videoHeight;
13678
- canvas.getContext("2d").drawImage(video, 0, 0);
13679
-
13680
- this.#webcam.snapshot = canvas.toDataURL("image/png");
13681
- this.#image.url = this.#webcam.snapshot;
13682
- this.#fillType = "image";
13683
- this.#updateChit();
13684
- this.#emitInput();
13685
-
13686
- // Switch to image tab to show result
13687
- this.#switchTab("image");
13688
- const tabs = this.#dialog.querySelector("fig-tabs");
13689
- tabs.value = "image";
13690
- });
13691
- }
13692
-
13693
- // ============ COLOR CONVERSION UTILITIES ============
13694
- #hsvToRGB(hsv) {
13695
- const h = hsv.h / 360;
13696
- const s = hsv.s / 100;
13697
- const v = hsv.v / 100;
13698
-
13699
- let r, g, b;
13700
- const i = Math.floor(h * 6);
13701
- const f = h * 6 - i;
13702
- const p = v * (1 - s);
13703
- const q = v * (1 - f * s);
13704
- const t = v * (1 - (1 - f) * s);
13705
-
13706
- switch (i % 6) {
13707
- case 0:
13708
- r = v;
13709
- g = t;
13710
- b = p;
13711
- break;
13712
- case 1:
13713
- r = q;
13714
- g = v;
13715
- b = p;
13716
- break;
13717
- case 2:
13718
- r = p;
13719
- g = v;
13720
- b = t;
13721
- break;
13722
- case 3:
13723
- r = p;
13724
- g = q;
13725
- b = v;
13726
- break;
13727
- case 4:
13728
- r = t;
13729
- g = p;
13730
- b = v;
13731
- break;
13732
- case 5:
13733
- r = v;
13734
- g = p;
13735
- b = q;
13736
- break;
13737
- }
13738
-
13739
- return {
13740
- r: Math.round(r * 255),
13741
- g: Math.round(g * 255),
13742
- b: Math.round(b * 255),
13743
- };
13744
- }
13745
-
13746
- #rgbToHSV(rgb) {
13747
- const r = rgb.r / 255;
13748
- const g = rgb.g / 255;
13749
- const b = rgb.b / 255;
13750
-
13751
- const max = Math.max(r, g, b);
13752
- const min = Math.min(r, g, b);
13753
- const d = max - min;
13754
-
13755
- let h = 0;
13756
- const s = max === 0 ? 0 : d / max;
13757
- const v = max;
13758
-
13759
- if (max !== min) {
13760
- switch (max) {
13761
- case r:
13762
- h = (g - b) / d + (g < b ? 6 : 0);
13763
- break;
13764
- case g:
13765
- h = (b - r) / d + 2;
13766
- break;
13767
- case b:
13768
- h = (r - g) / d + 4;
13769
- break;
13770
- }
13771
- h /= 6;
13772
- }
13773
-
13774
- return {
13775
- h: h * 360,
13776
- s: s * 100,
13777
- v: v * 100,
13778
- a: 1,
13779
- };
13780
- }
13781
-
13782
- #hsvToHex(hsv) {
13783
- // Safety check for valid HSV object
13784
- if (
13785
- !hsv ||
13786
- typeof hsv.h !== "number" ||
13787
- typeof hsv.s !== "number" ||
13788
- typeof hsv.v !== "number"
13789
- ) {
13790
- return "#D9D9D9"; // Default gray
13791
- }
13792
- const rgb = this.#hsvToRGB(hsv);
13793
- const toHex = (n) => {
13794
- const val = isNaN(n) ? 217 : Math.max(0, Math.min(255, Math.round(n)));
13795
- return val.toString(16).padStart(2, "0");
13796
- };
13797
- return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
13798
- }
13799
-
13800
- #hexToHSV(hex) {
13801
- const r = parseInt(hex.slice(1, 3), 16);
13802
- const g = parseInt(hex.slice(3, 5), 16);
13803
- const b = parseInt(hex.slice(5, 7), 16);
13804
- return this.#rgbToHSV({ r, g, b });
13805
- }
13806
-
13807
- #hexToRGBA(hex, alpha = 1) {
13808
- const r = parseInt(hex.slice(1, 3), 16);
13809
- const g = parseInt(hex.slice(3, 5), 16);
13810
- const b = parseInt(hex.slice(5, 7), 16);
13811
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
13812
- }
13813
-
13814
- #hexToP3(hex, alpha = 1) {
13815
- const r = +(parseInt(hex.slice(1, 3), 16) / 255).toFixed(4);
13816
- const g = +(parseInt(hex.slice(3, 5), 16) / 255).toFixed(4);
13817
- const b = +(parseInt(hex.slice(5, 7), 16) / 255).toFixed(4);
13818
- return `color(display-p3 ${r} ${g} ${b} / ${alpha})`;
13819
- }
13820
-
13821
- #rgbToHSL(rgb) {
13822
- const r = rgb.r / 255;
13823
- const g = rgb.g / 255;
13824
- const b = rgb.b / 255;
13825
-
13826
- const max = Math.max(r, g, b);
13827
- const min = Math.min(r, g, b);
13828
- let h, s;
13829
- const l = (max + min) / 2;
13830
-
13831
- if (max === min) {
13832
- h = s = 0;
13833
- } else {
13834
- const d = max - min;
13835
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
13836
-
13837
- switch (max) {
13838
- case r:
13839
- h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
13840
- break;
13841
- case g:
13842
- h = ((b - r) / d + 2) / 6;
13843
- break;
13844
- case b:
13845
- h = ((r - g) / d + 4) / 6;
13846
- break;
13847
- }
13848
- }
13849
-
13850
- return { h: h * 360, s: s * 100, l: l * 100 };
13851
- }
13852
-
13853
- #hslToRGB(hsl) {
13854
- const h = hsl.h / 360;
13855
- const s = hsl.s / 100;
13856
- const l = hsl.l / 100;
13857
-
13858
- let r, g, b;
13859
-
13860
- if (s === 0) {
13861
- r = g = b = l;
13862
- } else {
13863
- const hue2rgb = (p, q, t) => {
13864
- if (t < 0) t += 1;
13865
- if (t > 1) t -= 1;
13866
- if (t < 1 / 6) return p + (q - p) * 6 * t;
13867
- if (t < 1 / 2) return q;
13868
- if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
13869
- return p;
13870
- };
13871
-
13872
- const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
13873
- const p = 2 * l - q;
13874
-
13875
- r = hue2rgb(p, q, h + 1 / 3);
13876
- g = hue2rgb(p, q, h);
13877
- b = hue2rgb(p, q, h - 1 / 3);
13878
- }
13879
-
13880
- return {
13881
- r: Math.round(r * 255),
13882
- g: Math.round(g * 255),
13883
- b: Math.round(b * 255),
13884
- };
13885
- }
13886
-
13887
- // OKLAB/OKLCH conversions (simplified)
13888
- #rgbToOKLAB(rgb) {
13889
- // Convert to linear sRGB
13890
- const toLinear = (c) => {
13891
- c = c / 255;
13892
- return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
13893
- };
13894
-
13895
- const r = toLinear(rgb.r);
13896
- const g = toLinear(rgb.g);
13897
- const b = toLinear(rgb.b);
13898
-
13899
- // Convert to LMS
13900
- const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
13901
- const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
13902
- const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
13903
-
13904
- // Convert to Oklab
13905
- const l_ = Math.cbrt(l);
13906
- const m_ = Math.cbrt(m);
13907
- const s_ = Math.cbrt(s);
13908
-
13909
- return {
13910
- l: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
13911
- a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
13912
- b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
13913
- };
13914
- }
13915
-
13916
- #rgbToOKLCH(rgb) {
13917
- const lab = this.#rgbToOKLAB(rgb);
13918
- return {
13919
- l: lab.l,
13920
- c: Math.sqrt(lab.a * lab.a + lab.b * lab.b),
13921
- h: ((Math.atan2(lab.b, lab.a) * 180) / Math.PI + 360) % 360,
13922
- };
13923
- }
13924
-
13925
- #oklabToRGB(lab) {
13926
- const l_ = lab.l + 0.3963377774 * lab.a + 0.2158037573 * lab.b;
13927
- const m_ = lab.l - 0.1055613458 * lab.a - 0.0638541728 * lab.b;
13928
- const s_ = lab.l - 0.0894841775 * lab.a - 1.291485548 * lab.b;
13929
-
13930
- const l = l_ * l_ * l_;
13931
- const m = m_ * m_ * m_;
13932
- const s = s_ * s_ * s_;
13933
-
13934
- const toSRGB = (c) => {
13935
- const v =
13936
- c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
13937
- return Math.round(Math.max(0, Math.min(1, v)) * 255);
13938
- };
13939
-
13940
- return {
13941
- r: toSRGB(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
13942
- g: toSRGB(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
13943
- b: toSRGB(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
13944
- };
13945
- }
13946
-
13947
- #oklchToRGB(lch) {
13948
- const hRad = (lch.h * Math.PI) / 180;
13949
- return this.#oklabToRGB({
13950
- l: lch.l,
13951
- a: lch.c * Math.cos(hRad),
13952
- b: lch.c * Math.sin(hRad),
13953
- });
13954
- }
13955
-
13956
- // ============ EVENT EMITTERS ============
13957
- #emitInput() {
13958
- this.#updateChit();
13959
- this.dispatchEvent(
13960
- new CustomEvent("input", {
13961
- bubbles: true,
13962
- detail: this.value,
13963
- }),
13964
- );
13965
- }
13966
-
13967
- #emitChange() {
13968
- this.dispatchEvent(
13969
- new CustomEvent("change", {
13970
- bubbles: true,
13971
- detail: this.value,
13972
- }),
13973
- );
13974
- }
13975
-
13976
- // ============ PUBLIC API ============
13977
- get value() {
13978
- const base = { type: this.#fillType, colorSpace: this.#gamut };
13979
-
13980
- switch (this.#fillType) {
13981
- case "solid":
13982
- return {
13983
- ...base,
13984
- color: this.#hsvToHex(this.#color),
13985
- alpha: this.#color.a,
13986
- hsv: { ...this.#color },
13987
- };
13988
- case "gradient":
13989
- return {
13990
- ...base,
13991
- gradient: gradientToValueShape(this.#gradient),
13992
- css: this.#getGradientCSS(),
13993
- };
13994
- case "image":
13995
- return {
13996
- ...base,
13997
- image: { ...this.#image },
13998
- };
13999
- case "video":
14000
- return {
14001
- ...base,
14002
- video: { ...this.#video },
14003
- };
14004
- case "webcam":
14005
- return {
14006
- ...base,
14007
- image: { url: this.#webcam.snapshot, scaleMode: "fill", scale: 50 },
14008
- };
14009
- default:
14010
- return { ...base, ...this.#customData[this.#fillType] };
14011
- }
14012
- }
14013
-
14014
- set value(val) {
14015
- if (typeof val === "string") {
14016
- this.setAttribute("value", val);
14017
- } else {
14018
- this.setAttribute("value", JSON.stringify(val));
14019
- }
14020
- }
14021
-
14022
- attributeChangedCallback(name, oldValue, newValue) {
14023
- if (oldValue === newValue) return;
14024
-
14025
- switch (name) {
14026
- case "value":
14027
- this.#parseValue();
14028
- this.#updateChit();
14029
- if (this.#dialog) {
14030
- // Update dialog UI if open - but don't rebuild if user is dragging
14031
- if (!this.#isDraggingColor) {
14032
- // Just update the handle position and color inputs without rebuilding
14033
- this.#updateHandlePosition();
14034
- this.#updateColorInputs();
14035
- // Update hue slider
14036
- if (this.#hueSlider) {
14037
- this.#hueSlider.setAttribute("value", this.#color.h);
14038
- }
14039
- // Update opacity slider
14040
- if (this.#opacitySlider) {
14041
- this.#opacitySlider.setAttribute("value", this.#color.a * 100);
14042
- this.#opacitySlider.setAttribute(
14043
- "color",
14044
- this.#hsvToHex(this.#color),
14045
- );
14046
- }
14047
- }
14048
- }
14049
- break;
14050
- case "disabled":
14051
- // Handled in click listener
14052
- break;
14053
- }
14054
- }
14055
- }
14056
- customElements.define("fig-fill-picker", FigFillPicker);
14057
-
14058
- /* Color Tip */
14059
- /**
14060
- * A compact solid-color tip that wraps fig-fill-picker.
14061
- * @attr {string} value - Solid color string (hex/rgb/hsl/named)
14062
- * @attr {boolean} selected - Whether the tip is selected
14063
- * @attr {boolean} disabled - Whether the tip is disabled
14064
- * @fires input - While color changes
14065
- * @fires change - When color is committed
14066
- */
14067
- class FigColorTip extends HTMLElement {
14068
- #fillPicker = null;
14069
- #chit = null;
14070
- #chitSelectedObserver = null;
14071
- #boundHandleInput = this.#handlePickerInput.bind(this);
14072
- #boundHandleChange = this.#handlePickerChange.bind(this);
12414
+ #chitSelectedObserver = null;
12415
+ #boundHandleInput = this.#handlePickerInput.bind(this);
12416
+ #boundHandleChange = this.#handlePickerChange.bind(this);
14073
12417
 
14074
12418
  static get observedAttributes() {
14075
12419
  return ["value", "selected", "disabled", "alpha", "control"];
@@ -14094,6 +12438,10 @@ class FigColorTip extends HTMLElement {
14094
12438
  this.#fillPicker.removeEventListener("input", this.#boundHandleInput);
14095
12439
  this.#fillPicker.removeEventListener("change", this.#boundHandleChange);
14096
12440
  }
12441
+ if (this.#chit) {
12442
+ this.#chit.removeEventListener("input", this.#boundHandleInput);
12443
+ this.#chit.removeEventListener("change", this.#boundHandleChange);
12444
+ }
14097
12445
  if (this.#chitSelectedObserver) {
14098
12446
  this.#chitSelectedObserver.disconnect();
14099
12447
  this.#chitSelectedObserver = null;
@@ -14151,16 +12499,22 @@ class FigColorTip extends HTMLElement {
14151
12499
  opacity: Math.round(alpha * 100),
14152
12500
  })
14153
12501
  : JSON.stringify({ type: "solid", color });
14154
- this.innerHTML = `
14155
- <fig-fill-picker mode="solid" ${alphaAttr} value='${pickerValue}'>
14156
- <fig-chit background="${color}"></fig-chit>
14157
- </fig-fill-picker>`;
12502
+ const chitAlphaAttr = alpha < 1 ? ` alpha="${alpha}"` : "";
12503
+ this.innerHTML = hasFigFillPicker()
12504
+ ? `<fig-fill-picker mode="solid" ${alphaAttr} value='${pickerValue}'>
12505
+ <fig-chit background="${color}"${chitAlphaAttr}></fig-chit>
12506
+ </fig-fill-picker>`
12507
+ : `<fig-chit background="${color}"${chitAlphaAttr}></fig-chit>`;
14158
12508
 
14159
12509
  this.#fillPicker = this.querySelector("fig-fill-picker");
14160
12510
  this.#chit = this.querySelector("fig-chit");
14161
12511
  this.#teardownListeners();
14162
12512
  this.#fillPicker?.addEventListener("input", this.#boundHandleInput);
14163
12513
  this.#fillPicker?.addEventListener("change", this.#boundHandleChange);
12514
+ if (!this.#fillPicker) {
12515
+ this.#chit?.addEventListener("input", this.#boundHandleInput);
12516
+ this.#chit?.addEventListener("change", this.#boundHandleChange);
12517
+ }
14164
12518
  this.#observeChitSelected();
14165
12519
  }
14166
12520
 
@@ -14187,8 +12541,13 @@ class FigColorTip extends HTMLElement {
14187
12541
  #extractAlpha(colorValue) {
14188
12542
  if (!colorValue) return 1;
14189
12543
  const v = String(colorValue).trim();
14190
- if (v.startsWith("#") && v.length === 9) {
14191
- return parseInt(v.slice(7, 9), 16) / 255;
12544
+ const hex = v.replace(/^#/, "");
12545
+ if (/^[0-9a-f]{4}$/i.test(hex)) {
12546
+ const a = hex[3];
12547
+ return parseInt(`${a}${a}`, 16) / 255;
12548
+ }
12549
+ if (/^[0-9a-f]{8}$/i.test(hex)) {
12550
+ return parseInt(hex.slice(6, 8), 16) / 255;
14192
12551
  }
14193
12552
  const rgbaMatch = v.match(
14194
12553
  /rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/i,
@@ -14215,6 +12574,9 @@ class FigColorTip extends HTMLElement {
14215
12574
  if (value.startsWith("#")) {
14216
12575
  return this.#normalizeHex(value);
14217
12576
  }
12577
+ if (/^[0-9a-f]{3,4}$|^[0-9a-f]{6}$|^[0-9a-f]{8}$/i.test(value)) {
12578
+ return this.#normalizeHex(value);
12579
+ }
14218
12580
 
14219
12581
  try {
14220
12582
  const { ctx } = figGetSharedCanvas(1, 1);
@@ -14268,6 +12630,11 @@ class FigColorTip extends HTMLElement {
14268
12630
 
14269
12631
  if (this.#chit) {
14270
12632
  this.#chit.setAttribute("background", color);
12633
+ if (alpha < 1) {
12634
+ this.#chit.setAttribute("alpha", String(alpha));
12635
+ } else {
12636
+ this.#chit.removeAttribute("alpha");
12637
+ }
14271
12638
  if (this.hasAttribute("disabled")) {
14272
12639
  this.#chit.setAttribute("disabled", "");
14273
12640
  } else {
@@ -14306,12 +12673,12 @@ class FigColorTip extends HTMLElement {
14306
12673
 
14307
12674
  #handlePickerInput(event) {
14308
12675
  event.stopPropagation();
14309
- this.#updateColorFromPicker(event.detail, "input");
12676
+ this.#updateColorFromPicker(event.detail || { color: event.target?.value }, "input");
14310
12677
  }
14311
12678
 
14312
12679
  #handlePickerChange(event) {
14313
12680
  event.stopPropagation();
14314
- this.#updateColorFromPicker(event.detail, "change");
12681
+ this.#updateColorFromPicker(event.detail || { color: event.target?.value }, "change");
14315
12682
  }
14316
12683
 
14317
12684
  attributeChangedCallback(name, oldValue, newValue) {
@@ -14998,6 +13365,7 @@ class FigHandle extends HTMLElement {
14998
13365
  #applyingValue = false;
14999
13366
  #colorTip = null;
15000
13367
  #directColorPicker = null;
13368
+ #nativeColorInput = null;
15001
13369
  #hitAreaEl = null;
15002
13370
 
15003
13371
  get #controlMode() {
@@ -15240,6 +13608,7 @@ class FigHandle extends HTMLElement {
15240
13608
  this.#teardownDrag();
15241
13609
  this.#hideColorTip();
15242
13610
  this.#removeDirectColorPicker();
13611
+ this.#removeNativeColorInput();
15243
13612
  if (this.#hitAreaEl) {
15244
13613
  this.#hitAreaEl.remove();
15245
13614
  this.#hitAreaEl = null;
@@ -15566,6 +13935,7 @@ class FigHandle extends HTMLElement {
15566
13935
  }
15567
13936
 
15568
13937
  #ensureDirectColorPicker() {
13938
+ if (!hasFigFillPicker()) return null;
15569
13939
  if (this.#directColorPicker) return this.#directColorPicker;
15570
13940
 
15571
13941
  const picker = document.createElement("fig-fill-picker");
@@ -15591,6 +13961,10 @@ class FigHandle extends HTMLElement {
15591
13961
  if (this.hasAttribute("disabled")) return;
15592
13962
  this.#hideColorTip();
15593
13963
  const picker = this.#ensureDirectColorPicker();
13964
+ if (!picker) {
13965
+ this.#openNativeColorPicker();
13966
+ return;
13967
+ }
15594
13968
  this.setAttribute("selected", "");
15595
13969
  this.#syncDirectColorPickerValue();
15596
13970
  picker.open();
@@ -15607,6 +13981,40 @@ class FigHandle extends HTMLElement {
15607
13981
  this.removeAttribute("selected");
15608
13982
  }
15609
13983
 
13984
+ #ensureNativeColorInput() {
13985
+ if (this.#nativeColorInput) return this.#nativeColorInput;
13986
+ const input = document.createElement("input");
13987
+ input.type = "color";
13988
+ input.tabIndex = -1;
13989
+ input.setAttribute("aria-hidden", "true");
13990
+ input.style.position = "fixed";
13991
+ input.style.inlineSize = "1px";
13992
+ input.style.blockSize = "1px";
13993
+ input.style.opacity = "0";
13994
+ input.style.pointerEvents = "none";
13995
+ input.addEventListener("input", this.#handleNativeColorInput);
13996
+ input.addEventListener("change", this.#handleNativeColorChange);
13997
+ this.appendChild(input);
13998
+ this.#nativeColorInput = input;
13999
+ return input;
14000
+ }
14001
+
14002
+ #openNativeColorPicker() {
14003
+ const input = this.#ensureNativeColorInput();
14004
+ const { color } = this.#normalizeColorForPicker();
14005
+ input.value = color;
14006
+ this.setAttribute("selected", "");
14007
+ input.click();
14008
+ }
14009
+
14010
+ #removeNativeColorInput() {
14011
+ if (!this.#nativeColorInput) return;
14012
+ this.#nativeColorInput.removeEventListener("input", this.#handleNativeColorInput);
14013
+ this.#nativeColorInput.removeEventListener("change", this.#handleNativeColorChange);
14014
+ this.#nativeColorInput.remove();
14015
+ this.#nativeColorInput = null;
14016
+ }
14017
+
15610
14018
  #closeColorPickerForDrag() {
15611
14019
  if (this.getAttribute("type") !== "color") return;
15612
14020
  this.#hideColorTip();
@@ -15684,6 +14092,36 @@ class FigHandle extends HTMLElement {
15684
14092
  );
15685
14093
  };
15686
14094
 
14095
+ #detailFromNativeColor(value) {
14096
+ const { opacity } = this.#normalizeColorForPicker();
14097
+ return opacity < 100 ? { color: value, opacity } : { color: value };
14098
+ }
14099
+
14100
+ #handleNativeColorInput = (e) => {
14101
+ e.stopPropagation();
14102
+ const detail = this.#detailFromNativeColor(e.target.value);
14103
+ this.setAttribute("color", this.#colorWithOpacity(detail.color, detail.opacity));
14104
+ this.dispatchEvent(
14105
+ new CustomEvent("input", {
14106
+ bubbles: true,
14107
+ detail,
14108
+ }),
14109
+ );
14110
+ };
14111
+
14112
+ #handleNativeColorChange = (e) => {
14113
+ e.stopPropagation();
14114
+ const detail = this.#detailFromNativeColor(e.target.value);
14115
+ this.setAttribute("color", this.#colorWithOpacity(detail.color, detail.opacity));
14116
+ this.removeAttribute("selected");
14117
+ this.dispatchEvent(
14118
+ new CustomEvent("change", {
14119
+ bubbles: true,
14120
+ detail,
14121
+ }),
14122
+ );
14123
+ };
14124
+
15687
14125
  #handleDirectColorPickerClose = () => {
15688
14126
  this.removeAttribute("selected");
15689
14127
  };