@rogieking/figui3 4.15.10 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig.js CHANGED
@@ -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
@@ -3584,6 +3632,7 @@ customElements.define("fig-options", FigOptions);
3584
3632
  class FigSlider extends HTMLElement {
3585
3633
  #isInteracting = false;
3586
3634
  #showEmptyTextValue = false;
3635
+ #value = "";
3587
3636
  // Private fields declarations
3588
3637
  #typeDefaults = {
3589
3638
  range: { min: 0, max: 100, step: 1 },
@@ -3628,8 +3677,7 @@ class FigSlider extends HTMLElement {
3628
3677
  const rawValue = this.getAttribute("value");
3629
3678
  this.type = this.getAttribute("type") || "range";
3630
3679
  this.variant = this.getAttribute("variant") || "default";
3631
- this.text =
3632
- this.hasAttribute("text") && this.getAttribute("text") !== "false";
3680
+ this.text = this.getAttribute("text") !== "false";
3633
3681
  this.units = this.getAttribute("units") || "";
3634
3682
  this.transform = Number(this.getAttribute("transform") || 1);
3635
3683
  this.disabled = this.getAttribute("disabled") ? true : false;
@@ -3779,6 +3827,29 @@ class FigSlider extends HTMLElement {
3779
3827
  this.#regenerateInnerHTML();
3780
3828
  }
3781
3829
 
3830
+ get value() {
3831
+ if (this.#value !== "") return this.#value;
3832
+ const rawValue = this.getAttribute("value");
3833
+ if (rawValue !== null) return String(this.#normalizeSliderValue(rawValue));
3834
+ return "";
3835
+ }
3836
+
3837
+ set value(value) {
3838
+ const normalized = String(this.#normalizeSliderValue(value));
3839
+ this.#value = normalized;
3840
+ if (this.input && this.input.value !== normalized) {
3841
+ this.input.value = normalized;
3842
+ this.input.setAttribute("aria-valuenow", normalized);
3843
+ }
3844
+ if (this.figInputNumber) {
3845
+ this.figInputNumber.setAttribute(
3846
+ "value",
3847
+ this.#showEmptyTextValue ? "" : normalized,
3848
+ );
3849
+ }
3850
+ if (this.input) this.#syncProperties();
3851
+ }
3852
+
3782
3853
  disconnectedCallback() {
3783
3854
  if (this.input) {
3784
3855
  this.input.removeEventListener("input", this.#boundHandleInput);
@@ -3997,7 +4068,7 @@ class FigSlider extends HTMLElement {
3997
4068
  this.#regenerateInnerHTML();
3998
4069
  break;
3999
4070
  case "text":
4000
- this.text = newValue !== null && newValue !== "false";
4071
+ this.text = newValue !== "false";
4001
4072
  this.#regenerateInnerHTML();
4002
4073
  break;
4003
4074
  default:
@@ -4024,11 +4095,13 @@ customElements.define("fig-slider", FigSlider);
4024
4095
  */
4025
4096
  class FigInputText extends HTMLElement {
4026
4097
  #isInteracting = false;
4098
+ #passwordVisible = false;
4027
4099
  #boundMouseMove;
4028
4100
  #boundMouseUp;
4029
4101
  #boundWindowBlur;
4030
4102
  #boundMouseDown;
4031
4103
  #boundInputChange;
4104
+ #boundNativeInput;
4032
4105
 
4033
4106
  constructor() {
4034
4107
  super();
@@ -4041,6 +4114,9 @@ class FigInputText extends HTMLElement {
4041
4114
  e.stopPropagation();
4042
4115
  this.#handleInputChange(e);
4043
4116
  };
4117
+ this.#boundNativeInput = () => {
4118
+ this.#syncSearchClearVisibility();
4119
+ };
4044
4120
  }
4045
4121
 
4046
4122
  connectedCallback() {
@@ -4109,6 +4185,10 @@ class FigInputText extends HTMLElement {
4109
4185
 
4110
4186
  this.input = this.querySelector("input,textarea");
4111
4187
  this.input.readOnly = this.readonly;
4188
+ this.#syncSearchPrefix();
4189
+ this.#syncSearchClear();
4190
+ this.#syncSearchClearVisibility();
4191
+ this.#syncPasswordToggle();
4112
4192
 
4113
4193
  if (this.type === "number") {
4114
4194
  if (this.getAttribute("min")) {
@@ -4124,12 +4204,15 @@ class FigInputText extends HTMLElement {
4124
4204
  }
4125
4205
  this.input.removeEventListener("change", this.#boundInputChange);
4126
4206
  this.input.addEventListener("change", this.#boundInputChange);
4207
+ this.input.removeEventListener("input", this.#boundNativeInput);
4208
+ this.input.addEventListener("input", this.#boundNativeInput);
4127
4209
  });
4128
4210
  }
4129
4211
 
4130
4212
  disconnectedCallback() {
4131
4213
  if (this.input) {
4132
4214
  this.input.removeEventListener("change", this.#boundInputChange);
4215
+ this.input.removeEventListener("input", this.#boundNativeInput);
4133
4216
  }
4134
4217
  this.removeEventListener("pointerdown", this.#boundMouseDown);
4135
4218
  window.removeEventListener("pointermove", this.#boundMouseMove);
@@ -4140,6 +4223,132 @@ class FigInputText extends HTMLElement {
4140
4223
  focus() {
4141
4224
  this.input.focus();
4142
4225
  }
4226
+ #syncSearchPrefix() {
4227
+ const generated = this.querySelector(
4228
+ '[slot="prepend"][data-generated="search-prefix"]',
4229
+ );
4230
+ if (this.type !== "search") {
4231
+ generated?.remove();
4232
+ return;
4233
+ }
4234
+ const prepend = this.querySelector('[slot="prepend"]');
4235
+ if (prepend && prepend !== generated) return;
4236
+ if (generated) return;
4237
+
4238
+ const icon = createFigIcon("search");
4239
+ icon.setAttribute("slot", "prepend");
4240
+ icon.setAttribute("data-generated", "search-prefix");
4241
+ icon.setAttribute("color", "var(--figma-color-icon)");
4242
+ icon.addEventListener("click", this.focus.bind(this));
4243
+ this.prepend(icon);
4244
+ }
4245
+ #syncSearchClear() {
4246
+ const generated = this.querySelector(
4247
+ '[slot="append"][data-generated="search-clear"]',
4248
+ );
4249
+ if (this.type !== "search") {
4250
+ generated?.remove();
4251
+ return;
4252
+ }
4253
+ const append = this.querySelector('[slot="append"]');
4254
+ if (append && append !== generated) return;
4255
+ if (generated) return;
4256
+
4257
+ const wrapper = document.createElement("span");
4258
+ wrapper.setAttribute("slot", "append");
4259
+ wrapper.setAttribute("data-generated", "search-clear");
4260
+
4261
+ const tooltip = document.createElement("fig-tooltip");
4262
+ tooltip.setAttribute("text", "Clear search");
4263
+
4264
+ const button = document.createElement("fig-button");
4265
+ button.setAttribute("variant", "ghost");
4266
+ button.setAttribute("icon", "");
4267
+ button.setAttribute("aria-label", "Clear search");
4268
+
4269
+ const icon = createFigIcon("", { size: "small" });
4270
+ icon.setAttribute("color", "var(--figma-color-icon-secondary)");
4271
+ button.append(icon);
4272
+ tooltip.append(button);
4273
+ wrapper.append(tooltip);
4274
+ this.append(wrapper);
4275
+ icon.style.setProperty("--icon", "var(--icon-16-close)");
4276
+
4277
+ button.addEventListener("click", (e) => {
4278
+ e.preventDefault();
4279
+ e.stopPropagation();
4280
+ if (!this.input || this.input.value === "") {
4281
+ this.focus();
4282
+ return;
4283
+ }
4284
+ this.value = "";
4285
+ this.input.value = "";
4286
+ this.dispatchEvent(new CustomEvent("input", { detail: "", bubbles: true }));
4287
+ this.dispatchEvent(new CustomEvent("change", { detail: "", bubbles: true }));
4288
+ this.#syncSearchClearVisibility();
4289
+ this.focus();
4290
+ });
4291
+ }
4292
+ #syncSearchClearVisibility() {
4293
+ if (this.type !== "search") {
4294
+ this.removeAttribute("data-search-has-value");
4295
+ return;
4296
+ }
4297
+ this.toggleAttribute("data-search-has-value", !!this.input?.value);
4298
+ }
4299
+ #syncPasswordToggle() {
4300
+ const generated = this.querySelector(
4301
+ '[slot="append"][data-generated="password-toggle"]',
4302
+ );
4303
+ if (this.type !== "password") {
4304
+ generated?.remove();
4305
+ this.#passwordVisible = false;
4306
+ return;
4307
+ }
4308
+ const append = this.querySelector('[slot="append"]');
4309
+ if (append && append !== generated) return;
4310
+ if (generated) {
4311
+ this.#updatePasswordToggle(generated);
4312
+ return;
4313
+ }
4314
+
4315
+ const wrapper = document.createElement("span");
4316
+ wrapper.setAttribute("slot", "append");
4317
+ wrapper.setAttribute("data-generated", "password-toggle");
4318
+
4319
+ const tooltip = document.createElement("fig-tooltip");
4320
+ const button = document.createElement("fig-button");
4321
+ button.setAttribute("variant", "ghost");
4322
+ button.setAttribute("icon", "");
4323
+
4324
+ const icon = createFigIcon("visible", { size: "small" });
4325
+ icon.setAttribute("color", "var(--figma-color-icon-secondary)");
4326
+ button.append(icon);
4327
+ tooltip.append(button);
4328
+ wrapper.append(tooltip);
4329
+ this.append(wrapper);
4330
+ this.#updatePasswordToggle(wrapper);
4331
+
4332
+ button.addEventListener("click", (e) => {
4333
+ e.preventDefault();
4334
+ e.stopPropagation();
4335
+ this.#passwordVisible = !this.#passwordVisible;
4336
+ if (this.input) {
4337
+ this.input.type = this.#passwordVisible ? "text" : "password";
4338
+ }
4339
+ this.#updatePasswordToggle(wrapper);
4340
+ this.focus();
4341
+ });
4342
+ }
4343
+ #updatePasswordToggle(wrapper) {
4344
+ const tooltip = wrapper.querySelector("fig-tooltip");
4345
+ const button = wrapper.querySelector("fig-button");
4346
+ const icon = wrapper.querySelector("fig-icon");
4347
+ const label = this.#passwordVisible ? "Hide password" : "Show password";
4348
+ tooltip?.setAttribute("text", label);
4349
+ button?.setAttribute("aria-label", label);
4350
+ icon?.setAttribute("name", this.#passwordVisible ? "visible" : "hidden");
4351
+ }
4143
4352
  #transformNumber(value) {
4144
4353
  if (value === "") return "";
4145
4354
  let transformed = Number(value) * (this.transform || 1);
@@ -4157,6 +4366,7 @@ class FigInputText extends HTMLElement {
4157
4366
  }
4158
4367
  this.value = value;
4159
4368
  this.input.value = valueTransformed;
4369
+ this.#syncSearchClearVisibility();
4160
4370
  this.dispatchEvent(
4161
4371
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4162
4372
  );
@@ -4293,6 +4503,7 @@ class FigInputText extends HTMLElement {
4293
4503
  this.value = value;
4294
4504
  this.input.value = value;
4295
4505
  }
4506
+ this.#syncSearchClearVisibility();
4296
4507
  break;
4297
4508
  case "min":
4298
4509
  case "max":
@@ -4310,6 +4521,14 @@ class FigInputText extends HTMLElement {
4310
4521
  this.placeholder = newValue ?? "";
4311
4522
  this.input.placeholder = this.placeholder;
4312
4523
  break;
4524
+ case "type":
4525
+ this.type = newValue || "text";
4526
+ this.input.type = this.type;
4527
+ this.#syncSearchPrefix();
4528
+ this.#syncSearchClear();
4529
+ this.#syncSearchClearVisibility();
4530
+ this.#syncPasswordToggle();
4531
+ break;
4313
4532
  default:
4314
4533
  this[name] = this.input[name] = newValue;
4315
4534
  break;
@@ -4382,7 +4601,7 @@ class FigInputNumber extends HTMLElement {
4382
4601
  e.preventDefault();
4383
4602
  e.stopPropagation();
4384
4603
  const btn = e.target.closest("button");
4385
- if (!btn || this.disabled) return;
4604
+ if (!btn || this.disabled || btn.disabled) return;
4386
4605
  const dir = btn.classList.contains("fig-stepper-up") ? 1 : -1;
4387
4606
  this.#stepValue(dir);
4388
4607
  this.input.focus();
@@ -4392,6 +4611,31 @@ class FigInputNumber extends HTMLElement {
4392
4611
  this.#stepperEl.remove();
4393
4612
  this.#stepperEl = null;
4394
4613
  }
4614
+ this.#syncStepperState();
4615
+ }
4616
+
4617
+ #syncStepperState() {
4618
+ if (!this.#stepperEl) return;
4619
+ const up = this.#stepperEl.querySelector(".fig-stepper-up");
4620
+ const down = this.#stepperEl.querySelector(".fig-stepper-down");
4621
+ if (!up || !down) return;
4622
+
4623
+ const numericValue = this.input
4624
+ ? this.#getNumericValue(this.input.value)
4625
+ : this.value;
4626
+ const current =
4627
+ numericValue !== "" && numericValue !== null && numericValue !== undefined
4628
+ ? Number(numericValue) / (this.transform || 1)
4629
+ : Number(this.value);
4630
+ const hasCurrent = Number.isFinite(current);
4631
+ const disabled = Boolean(this.disabled);
4632
+ const atMin =
4633
+ hasCurrent && typeof this.min === "number" && current <= this.min;
4634
+ const atMax =
4635
+ hasCurrent && typeof this.max === "number" && current >= this.max;
4636
+
4637
+ up.disabled = disabled || atMax;
4638
+ down.disabled = disabled || atMin;
4395
4639
  }
4396
4640
 
4397
4641
  #stepValue(direction) {
@@ -4403,6 +4647,7 @@ class FigInputNumber extends HTMLElement {
4403
4647
  value = this.#sanitizeInput(value, false);
4404
4648
  this.value = value;
4405
4649
  this.input.value = this.#formatWithUnit(this.value);
4650
+ this.#syncStepperState();
4406
4651
  this.dispatchEvent(
4407
4652
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4408
4653
  );
@@ -4513,6 +4758,7 @@ class FigInputNumber extends HTMLElement {
4513
4758
  const disabledAttr = this.getAttribute("disabled");
4514
4759
  this.disabled = this.input.disabled = disabledAttr !== "false";
4515
4760
  }
4761
+ this.#syncStepperState();
4516
4762
 
4517
4763
  this.addEventListener("pointerdown", this.#boundMouseDown);
4518
4764
  this.input.removeEventListener("change", this.#boundInputChange);
@@ -4624,6 +4870,7 @@ class FigInputNumber extends HTMLElement {
4624
4870
  this.value = "";
4625
4871
  e.target.value = "";
4626
4872
  }
4873
+ this.#syncStepperState();
4627
4874
  this.dispatchEvent(
4628
4875
  new CustomEvent("change", { detail: this.value, bubbles: true }),
4629
4876
  );
@@ -4649,6 +4896,7 @@ class FigInputNumber extends HTMLElement {
4649
4896
  value = this.#sanitizeInput(value, false);
4650
4897
  this.value = value;
4651
4898
  this.input.value = this.#formatWithUnit(this.value);
4899
+ this.#syncStepperState();
4652
4900
 
4653
4901
  this.dispatchEvent(
4654
4902
  new CustomEvent("input", { detail: this.value, bubbles: true }),
@@ -4665,6 +4913,7 @@ class FigInputNumber extends HTMLElement {
4665
4913
  } else {
4666
4914
  this.value = "";
4667
4915
  }
4916
+ this.#syncStepperState();
4668
4917
  this.dispatchEvent(
4669
4918
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4670
4919
  );
@@ -4682,6 +4931,7 @@ class FigInputNumber extends HTMLElement {
4682
4931
  this.value = "";
4683
4932
  e.target.value = "";
4684
4933
  }
4934
+ this.#syncStepperState();
4685
4935
  this.dispatchEvent(
4686
4936
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4687
4937
  );
@@ -4702,6 +4952,7 @@ class FigInputNumber extends HTMLElement {
4702
4952
  value = this.#sanitizeInput(value, false);
4703
4953
  this.value = value;
4704
4954
  this.input.value = this.#formatWithUnit(this.value);
4955
+ this.#syncStepperState();
4705
4956
  this.dispatchEvent(
4706
4957
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4707
4958
  );
@@ -4784,6 +5035,7 @@ class FigInputNumber extends HTMLElement {
4784
5035
  case "disabled":
4785
5036
  this.disabled = this.input.disabled =
4786
5037
  newValue !== null && newValue !== "false";
5038
+ this.#syncStepperState();
4787
5039
  break;
4788
5040
  case "units":
4789
5041
  this.#rawUnits = newValue || "";
@@ -4816,15 +5068,18 @@ class FigInputNumber extends HTMLElement {
4816
5068
  }
4817
5069
  this.value = value;
4818
5070
  this.input.value = this.#formatWithUnit(this.value);
5071
+ this.#syncStepperState();
4819
5072
  break;
4820
5073
  case "min":
4821
5074
  case "max":
4822
5075
  case "step":
4823
5076
  if (newValue === null || newValue === "") {
4824
5077
  this[name] = undefined;
5078
+ this.#syncStepperState();
4825
5079
  break;
4826
5080
  }
4827
5081
  this[name] = Number(newValue);
5082
+ this.#syncStepperState();
4828
5083
  break;
4829
5084
  case "steppers": {
4830
5085
  const hasSteppers = newValue !== null && newValue !== "false";
@@ -5018,17 +5273,13 @@ class FigInputColor extends HTMLElement {
5018
5273
  #fillPicker;
5019
5274
  #textInput;
5020
5275
  #alphaInput;
5276
+ #suppressNativeColorClick = false;
5277
+ #pendingFillPickerPointerOpen = false;
5278
+ #nativeColorClickTimer = null;
5021
5279
  constructor() {
5022
5280
  super();
5023
5281
  }
5024
5282
 
5025
- get picker() {
5026
- return this.getAttribute("picker") || "native";
5027
- }
5028
- set picker(value) {
5029
- this.setAttribute("picker", value);
5030
- }
5031
-
5032
5283
  get alpha() {
5033
5284
  return this.getAttribute("alpha");
5034
5285
  }
@@ -5040,17 +5291,21 @@ class FigInputColor extends HTMLElement {
5040
5291
  }
5041
5292
  }
5042
5293
 
5043
- #buildFillPickerAttrs() {
5294
+ #fillPickerAttrs() {
5044
5295
  const attrs = {};
5045
5296
  const experimental = this.getAttribute("experimental");
5046
5297
  if (experimental) attrs["experimental"] = experimental;
5047
- // picker-* attributes forwarded to fill picker (except anchor, handled programmatically)
5048
5298
  for (const { name, value } of this.attributes) {
5049
5299
  if (name.startsWith("picker-") && name !== "picker-anchor") {
5050
5300
  attrs[name.slice(7)] = value;
5051
5301
  }
5052
5302
  }
5053
5303
  if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
5304
+ return attrs;
5305
+ }
5306
+
5307
+ #buildFillPickerAttrs() {
5308
+ const attrs = this.#fillPickerAttrs();
5054
5309
  return Object.entries(attrs)
5055
5310
  .map(([k, v]) => `${k}="${v}"`)
5056
5311
  .join(" ");
@@ -5069,10 +5324,7 @@ class FigInputColor extends HTMLElement {
5069
5324
  #buildUI() {
5070
5325
  this.#setValues(this.getAttribute("value"));
5071
5326
 
5072
- const useFigmaPicker = this.picker === "figma";
5073
- const hidePicker = this.picker === "false";
5074
- const showAlpha = this.getAttribute("alpha") === "true";
5075
- const fpAttrs = this.#buildFillPickerAttrs();
5327
+ const showAlpha = this.getAttribute("alpha") !== "false";
5076
5328
  const disabled = this.#disabled;
5077
5329
  const disabledAttr = disabled ? " disabled" : "";
5078
5330
 
@@ -5097,32 +5349,14 @@ class FigInputColor extends HTMLElement {
5097
5349
  }
5098
5350
 
5099
5351
  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
- }
5352
+ swatchElement = `<fig-chit background="${this.hexOpaque}" alpha="${this.rgba.a}"${disabledAttr}></fig-chit>`;
5109
5353
 
5110
5354
  html = `<div class="input-combo">
5111
5355
  ${swatchElement}
5112
5356
  ${label}
5113
5357
  </div>`;
5114
5358
  } 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
- }
5359
+ html = `<fig-chit background="${this.hexOpaque}" alpha="${this.rgba.a}"${disabledAttr}></fig-chit>`;
5126
5360
  }
5127
5361
  this.innerHTML = html;
5128
5362
 
@@ -5143,31 +5377,16 @@ class FigInputColor extends HTMLElement {
5143
5377
  swatchInput?.setAttribute("disabled", "");
5144
5378
  if (swatchInput) swatchInput.style.pointerEvents = "none";
5145
5379
  }
5380
+ this.#swatch.addEventListener("pointerdown", this.#handleSwatchPointerDown.bind(this), {
5381
+ capture: true,
5382
+ });
5383
+ this.#swatch.addEventListener("click", this.#handleSwatchClick.bind(this), {
5384
+ capture: true,
5385
+ });
5386
+ swatchInput?.addEventListener("keydown", this.#handleSwatchKeyDown.bind(this));
5146
5387
  this.#swatch.addEventListener("input", this.#handleInput.bind(this));
5147
5388
  }
5148
5389
 
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
5390
  if (this.#textInput) {
5172
5391
  const hex = this.rgbAlphaToHex(this.rgba, 1);
5173
5392
  // Display without # prefix
@@ -5197,8 +5416,103 @@ class FigInputColor extends HTMLElement {
5197
5416
  }
5198
5417
  });
5199
5418
  }
5419
+
5420
+ #syncFillPicker() {
5421
+ if (!this.#fillPicker) return;
5422
+ for (const [name, value] of Object.entries(this.#fillPickerAttrs())) {
5423
+ this.#fillPicker.setAttribute(name, value);
5424
+ }
5425
+ this.#fillPicker.setAttribute("mode", "solid");
5426
+ if (this.getAttribute("alpha") !== "false") {
5427
+ this.#fillPicker.removeAttribute("alpha");
5428
+ } else {
5429
+ this.#fillPicker.setAttribute("alpha", "false");
5430
+ }
5431
+ if (this.hasAttribute("disabled")) {
5432
+ this.#fillPicker.setAttribute("disabled", "");
5433
+ } else {
5434
+ this.#fillPicker.removeAttribute("disabled");
5435
+ }
5436
+ this.#fillPicker.anchorElement = this;
5437
+ this.#fillPicker.setAttribute(
5438
+ "value",
5439
+ JSON.stringify({
5440
+ type: "solid",
5441
+ color: this.hexOpaque,
5442
+ opacity: this.#alphaPercent,
5443
+ }),
5444
+ );
5445
+ }
5446
+
5447
+ #ensureFillPicker() {
5448
+ if (!hasFigFillPicker()) return null;
5449
+ if (this.#fillPicker?.isConnected) {
5450
+ this.#syncFillPicker();
5451
+ return this.#fillPicker;
5452
+ }
5453
+
5454
+ const picker = document.createElement("fig-fill-picker");
5455
+ picker.innerHTML = "<span hidden></span>";
5456
+ picker.addEventListener("input", this.#handleFillPickerInput.bind(this));
5457
+ picker.addEventListener("change", this.#handleChange.bind(this));
5458
+ this.appendChild(picker);
5459
+ this.#fillPicker = picker;
5460
+ this.#syncFillPicker();
5461
+ return picker;
5462
+ }
5463
+
5464
+ #openFillPicker() {
5465
+ if (this.hasAttribute("disabled") || this.hasAttribute("swatch-disabled")) return false;
5466
+ const picker = this.#ensureFillPicker();
5467
+ if (!picker) return false;
5468
+ requestAnimationFrame(() => picker.open?.());
5469
+ return true;
5470
+ }
5471
+
5472
+ #cancelNativeColorEvent(event) {
5473
+ event.preventDefault();
5474
+ event.stopPropagation();
5475
+ event.stopImmediatePropagation?.();
5476
+ }
5477
+
5478
+ #handleSwatchPointerDown(event) {
5479
+ if (!hasFigFillPicker()) return;
5480
+ if (this.hasAttribute("disabled") || this.hasAttribute("swatch-disabled")) return;
5481
+ this.#pendingFillPickerPointerOpen = true;
5482
+ this.#suppressNativeColorClick = true;
5483
+ if (this.#nativeColorClickTimer) clearTimeout(this.#nativeColorClickTimer);
5484
+ this.#nativeColorClickTimer = setTimeout(() => {
5485
+ this.#suppressNativeColorClick = false;
5486
+ this.#pendingFillPickerPointerOpen = false;
5487
+ this.#nativeColorClickTimer = null;
5488
+ }, 500);
5489
+ this.#cancelNativeColorEvent(event);
5490
+ }
5491
+
5492
+ #handleSwatchClick(event) {
5493
+ if (!this.#suppressNativeColorClick) return;
5494
+ this.#suppressNativeColorClick = false;
5495
+ if (this.#nativeColorClickTimer) {
5496
+ clearTimeout(this.#nativeColorClickTimer);
5497
+ this.#nativeColorClickTimer = null;
5498
+ }
5499
+ this.#cancelNativeColorEvent(event);
5500
+ if (this.#pendingFillPickerPointerOpen) {
5501
+ this.#pendingFillPickerPointerOpen = false;
5502
+ this.#openFillPicker();
5503
+ }
5504
+ }
5505
+
5506
+ #handleSwatchKeyDown(event) {
5507
+ if (event.key !== "Enter" && event.key !== " ") return;
5508
+ if (!hasFigFillPicker()) return;
5509
+ if (!this.#openFillPicker()) return;
5510
+ this.#cancelNativeColorEvent(event);
5511
+ }
5512
+
5200
5513
  #setValues(hexValue) {
5201
- this.rgba = this.convertToRGBA(hexValue);
5514
+ const colorValue = hexValue || "#D9D9D9";
5515
+ this.rgba = this.convertToRGBA(colorValue);
5202
5516
  this.value = this.rgbAlphaToHex(
5203
5517
  {
5204
5518
  r: isNaN(this.rgba.r) ? 0 : this.rgba.r,
@@ -5209,9 +5523,7 @@ class FigInputColor extends HTMLElement {
5209
5523
  );
5210
5524
  this.hexWithAlpha = this.value.toUpperCase();
5211
5525
  this.hexOpaque = this.hexWithAlpha.slice(0, 7);
5212
- if (hexValue.length > 7) {
5213
- this.#alphaPercent = (this.rgba.a * 100).toFixed(0);
5214
- }
5526
+ this.#alphaPercent = colorValue.length > 7 ? (this.rgba.a * 100).toFixed(0) : 100;
5215
5527
  this.style.setProperty("--alpha", this.rgba.a);
5216
5528
  }
5217
5529
 
@@ -5335,7 +5647,6 @@ class FigInputColor extends HTMLElement {
5335
5647
  "value",
5336
5648
  "style",
5337
5649
  "mode",
5338
- "picker",
5339
5650
  "experimental",
5340
5651
  "alpha",
5341
5652
  "text",
@@ -5382,13 +5693,7 @@ class FigInputColor extends HTMLElement {
5382
5693
  // Emitting here causes infinite loops with React and other frameworks.
5383
5694
  break;
5384
5695
  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
5696
+ this.#syncFillPicker();
5392
5697
  break;
5393
5698
  case "alpha":
5394
5699
  case "text":
@@ -5414,8 +5719,7 @@ class FigInputColor extends HTMLElement {
5414
5719
  else child.removeAttribute("disabled");
5415
5720
  }
5416
5721
  if (this.#fillPicker) {
5417
- if (disabled) this.#fillPicker.setAttribute("disabled", "");
5418
- else this.#fillPicker.removeAttribute("disabled");
5722
+ this.#syncFillPicker();
5419
5723
  }
5420
5724
  }
5421
5725
 
@@ -5900,6 +6204,46 @@ class FigInputFill extends HTMLElement {
5900
6204
  .join(" ");
5901
6205
  }
5902
6206
 
6207
+ #fillPickerChitBackground() {
6208
+ switch (this.#fillType) {
6209
+ case "solid":
6210
+ return this.#solid.color;
6211
+ case "gradient": {
6212
+ const sorted = [...this.#gradient.stops].sort(
6213
+ (a, b) => a.position - b.position,
6214
+ );
6215
+ const stops = sorted
6216
+ .map((stop) => {
6217
+ const alpha = (stop.opacity ?? 100) / 100;
6218
+ if (alpha >= 1) return `${stop.color} ${stop.position}%`;
6219
+ const { r, g, b } = figHexToRGB(stop.color);
6220
+ return `rgba(${r}, ${g}, ${b}, ${alpha}) ${stop.position}%`;
6221
+ })
6222
+ .join(", ");
6223
+ return `linear-gradient(${this.#gradient.angle}deg ${gradientInterpolationClause(this.#gradient)}, ${stops})`;
6224
+ }
6225
+ case "image":
6226
+ return this.#image.url ? `url(${this.#image.url})` : "#D9D9D9";
6227
+ default:
6228
+ return "#D9D9D9";
6229
+ }
6230
+ }
6231
+
6232
+ #fillPickerChitAlpha() {
6233
+ switch (this.#fillType) {
6234
+ case "solid":
6235
+ return this.#solid.alpha;
6236
+ case "image":
6237
+ return this.#image.opacity ?? 1;
6238
+ case "video":
6239
+ return this.#video.opacity ?? 1;
6240
+ case "webcam":
6241
+ return this.#webcam.opacity ?? 1;
6242
+ default:
6243
+ return 1;
6244
+ }
6245
+ }
6246
+
5903
6247
  #syncDisabled() {
5904
6248
  const disabled = this.hasAttribute("disabled");
5905
6249
  for (const child of [
@@ -5982,7 +6326,9 @@ class FigInputFill extends HTMLElement {
5982
6326
  <div class="input-combo">
5983
6327
  <fig-fill-picker ${fpAttrs} value='${fillPickerValue}' ${
5984
6328
  disabled ? "disabled" : ""
5985
- }></fig-fill-picker>
6329
+ }>
6330
+ <fig-chit background="${this.#fillPickerChitBackground()}" alpha="${this.#fillPickerChitAlpha()}"${disabled ? " disabled" : ""}></fig-chit>
6331
+ </fig-fill-picker>
5986
6332
  ${controlsHtml}
5987
6333
  </div>`;
5988
6334
 
@@ -7097,7 +7443,7 @@ class FigInputGradient extends HTMLElement {
7097
7443
  const disabled = this.hasAttribute("disabled");
7098
7444
  const mode = this.#editMode;
7099
7445
 
7100
- if (mode === "picker") {
7446
+ if (mode === "picker" && hasFigFillPicker()) {
7101
7447
  const experimental = this.getAttribute("experimental");
7102
7448
  const expAttr = experimental ? ` experimental="${experimental}"` : "";
7103
7449
  const gradientValue = JSON.stringify(this.value);
@@ -7113,11 +7459,11 @@ class FigInputGradient extends HTMLElement {
7113
7459
 
7114
7460
  this.innerHTML = `
7115
7461
  <fig-chit background="${this.#buildGradientCSS()}"${disabled ? " disabled" : ""}></fig-chit>
7116
- ${mode === "true" ? `<div class="fig-input-gradient-track">${this.#buildStopHandles()}</div>` : ""}`;
7462
+ ${mode === "true" || mode === "picker" ? `<div class="fig-input-gradient-track">${this.#buildStopHandles()}</div>` : ""}`;
7117
7463
  this.#chit = this.querySelector("fig-chit");
7118
7464
  this.#track = this.querySelector(".fig-input-gradient-track");
7119
7465
 
7120
- if (mode === "true") {
7466
+ if (mode === "true" || mode === "picker") {
7121
7467
  this.#setupGhostHandle();
7122
7468
  this.#setupEventListeners();
7123
7469
  requestAnimationFrame(() => this.#repositionHandles());
@@ -8450,6 +8796,7 @@ class FigMedia extends HTMLElement {
8450
8796
  #mediaEl = null;
8451
8797
  #fileInput = null;
8452
8798
  #blobUrl = null;
8799
+ #previewSrc = null;
8453
8800
  #file = null;
8454
8801
  #boundHandleFileInput = this.#handleFileInput.bind(this);
8455
8802
  #boundHandleMediaPlay = this.#handleMediaPlay.bind(this);
@@ -8502,6 +8849,10 @@ class FigMedia extends HTMLElement {
8502
8849
  return this.#file;
8503
8850
  }
8504
8851
 
8852
+ #currentMediaSrc() {
8853
+ return this.#previewSrc || this.#src || "";
8854
+ }
8855
+
8505
8856
  /**
8506
8857
  * Returns a base64 data URL for the loaded image.
8507
8858
  * Requires a CORS-clean image (same-origin or with appropriate Access-Control headers);
@@ -8509,7 +8860,7 @@ class FigMedia extends HTMLElement {
8509
8860
  */
8510
8861
  async getBase64() {
8511
8862
  if (this.mediaKind !== "image") return null;
8512
- if (!this.#src) return null;
8863
+ if (!this.#currentMediaSrc()) return null;
8513
8864
  if (!this.#mediaEl) return null;
8514
8865
  try {
8515
8866
  if (typeof this.#mediaEl.decode === "function") {
@@ -8565,6 +8916,7 @@ class FigMedia extends HTMLElement {
8565
8916
  URL.revokeObjectURL(this.#blobUrl);
8566
8917
  this.#blobUrl = null;
8567
8918
  }
8919
+ this.#previewSrc = null;
8568
8920
  }
8569
8921
 
8570
8922
  #removeMediaElementListeners() {
@@ -8642,7 +8994,7 @@ class FigMedia extends HTMLElement {
8642
8994
  #syncGeneratedMediaElement() {
8643
8995
  if (!this.#mediaEl) return;
8644
8996
  if (!this.#mediaEl.hasAttribute("data-generated")) return;
8645
- const src = this.#src || "";
8997
+ const src = this.#currentMediaSrc();
8646
8998
  if (this.#mediaEl.getAttribute("src") !== src) {
8647
8999
  if (src) {
8648
9000
  this.#mediaEl.setAttribute("src", src);
@@ -8827,7 +9179,11 @@ class FigMedia extends HTMLElement {
8827
9179
  fi.setAttribute("variant", "overlay");
8828
9180
  const defaultLabel = this.getAttribute("label") || "Upload";
8829
9181
  fi.setAttribute("label", this.#src ? "Replace" : defaultLabel);
8830
- if (this.#src) fi.setAttribute("url", this.#src);
9182
+ if (this.#file?.name) {
9183
+ fi.setAttribute("filename", this.#file.name);
9184
+ } else if (this.#src) {
9185
+ fi.setAttribute("url", this.#src);
9186
+ }
8831
9187
  fi.addEventListener("change", this.#boundHandleFileInput);
8832
9188
  this.append(fi);
8833
9189
  this.#fileInput = fi;
@@ -8844,6 +9200,7 @@ class FigMedia extends HTMLElement {
8844
9200
  #handleFileInput(e) {
8845
9201
  if (e.target !== this.#fileInput) return;
8846
9202
  const file = e.detail?.files?.[0];
9203
+ const cleared = e.detail?.cleared === true;
8847
9204
 
8848
9205
  if (!file) {
8849
9206
  if (this.#blobUrl) {
@@ -8851,7 +9208,19 @@ class FigMedia extends HTMLElement {
8851
9208
  this.#blobUrl = null;
8852
9209
  }
8853
9210
  this.#file = null;
8854
- this.removeAttribute("src");
9211
+ this.#previewSrc = null;
9212
+ if (cleared) this.src = "";
9213
+ this.#syncGeneratedMediaElement();
9214
+ if (this.#fileInput) {
9215
+ const defaultLabel = this.getAttribute("label") || "Upload";
9216
+ this.#fileInput.setAttribute("label", this.#src ? "Replace" : defaultLabel);
9217
+ this.#fileInput.removeAttribute("filename");
9218
+ if (this.#src) {
9219
+ this.#fileInput.setAttribute("url", this.#src);
9220
+ } else {
9221
+ this.#fileInput.removeAttribute("url");
9222
+ }
9223
+ }
8855
9224
  this.dispatchEvent(
8856
9225
  new CustomEvent("change", { bubbles: true, cancelable: true }),
8857
9226
  );
@@ -8863,8 +9232,9 @@ class FigMedia extends HTMLElement {
8863
9232
  }
8864
9233
  this.#file = file;
8865
9234
  this.#blobUrl = URL.createObjectURL(file);
9235
+ this.#previewSrc = this.#blobUrl;
8866
9236
 
8867
- this.setAttribute("src", this.#blobUrl);
9237
+ this.#syncGeneratedMediaElement();
8868
9238
 
8869
9239
  this.dispatchEvent(
8870
9240
  new CustomEvent("loaded", {
@@ -8878,9 +9248,8 @@ class FigMedia extends HTMLElement {
8878
9248
  );
8879
9249
 
8880
9250
  if (this.#fileInput) {
8881
- this.#fileInput.removeEventListener("change", this.#boundHandleFileInput);
8882
- this.#fileInput.clear();
8883
- this.#fileInput.addEventListener("change", this.#boundHandleFileInput);
9251
+ this.#fileInput.removeAttribute("url");
9252
+ this.#fileInput.setAttribute("filename", file.name);
8884
9253
  this.#fileInput.setAttribute("label", "Replace");
8885
9254
  }
8886
9255
  }
@@ -8917,10 +9286,14 @@ class FigMedia extends HTMLElement {
8917
9286
  if (oldValue === newValue) return;
8918
9287
 
8919
9288
  if (name === "src") {
8920
- this.#src = newValue || "";
8921
- if (this.#blobUrl && this.#src !== this.#blobUrl) {
9289
+ const nextSrc = newValue || "";
9290
+ const isCurrentPreviewBlob =
9291
+ nextSrc && (nextSrc === this.#previewSrc || nextSrc === this.#blobUrl);
9292
+ this.#src = nextSrc;
9293
+ if (this.#blobUrl && !isCurrentPreviewBlob) {
8922
9294
  URL.revokeObjectURL(this.#blobUrl);
8923
9295
  this.#blobUrl = null;
9296
+ this.#previewSrc = null;
8924
9297
  this.#file = null;
8925
9298
  }
8926
9299
  this.#syncGeneratedMediaElement();
@@ -8928,9 +9301,16 @@ class FigMedia extends HTMLElement {
8928
9301
  const defaultLabel = this.getAttribute("label") || "Upload";
8929
9302
  this.#fileInput.setAttribute("label", this.#src ? "Replace" : defaultLabel);
8930
9303
  if (this.#src) {
8931
- this.#fileInput.setAttribute("url", this.#src);
9304
+ if (this.#file?.name) {
9305
+ this.#fileInput.removeAttribute("url");
9306
+ this.#fileInput.setAttribute("filename", this.#file.name);
9307
+ } else {
9308
+ this.#fileInput.setAttribute("url", this.#src);
9309
+ this.#fileInput.removeAttribute("filename");
9310
+ }
8932
9311
  } else {
8933
9312
  this.#fileInput.removeAttribute("url");
9313
+ this.#fileInput.removeAttribute("filename");
8934
9314
  }
8935
9315
  }
8936
9316
  }
@@ -9101,7 +9481,7 @@ class FigMediaControls extends HTMLElement {
9101
9481
  });
9102
9482
 
9103
9483
  const slider = document.createElement("fig-slider");
9104
- slider.setAttribute("variant", "neue");
9484
+ slider.setAttribute("text", "false");
9105
9485
  slider.setAttribute("min", "0");
9106
9486
  slider.setAttribute("max", String(this.duration));
9107
9487
  slider.setAttribute("step", "0.1");
@@ -9197,7 +9577,7 @@ customElements.define("fig-media-controls", FigMediaControls);
9197
9577
 
9198
9578
  /* File Upload Input */
9199
9579
  class FigInputFile extends HTMLElement {
9200
- static observedAttributes = ["accepts", "label", "disabled", "multiple", "variant", "url"];
9580
+ static observedAttributes = ["accepts", "label", "disabled", "multiple", "variant", "url", "filename"];
9201
9581
 
9202
9582
  #fileInput = null;
9203
9583
  #filenameEl = null;
@@ -9211,6 +9591,8 @@ class FigInputFile extends HTMLElement {
9211
9591
  }
9212
9592
 
9213
9593
  get #urlFilename() {
9594
+ const filename = this.getAttribute("filename");
9595
+ if (filename) return filename;
9214
9596
  const url = this.getAttribute("url");
9215
9597
  if (!url) return "";
9216
9598
  try {
@@ -9254,12 +9636,13 @@ class FigInputFile extends HTMLElement {
9254
9636
  this.#files = null;
9255
9637
  if (this.#fileInput) this.#fileInput.value = "";
9256
9638
  this.removeAttribute("url");
9639
+ this.removeAttribute("filename");
9257
9640
  this.#render();
9258
- this.#emitEvents();
9641
+ this.#emitEvents({ cleared: true });
9259
9642
  }
9260
9643
 
9261
- #emitEvents() {
9262
- const detail = { files: this.#files };
9644
+ #emitEvents(extraDetail = {}) {
9645
+ const detail = { files: this.#files, ...extraDetail };
9263
9646
  this.dispatchEvent(new CustomEvent("input", { detail, bubbles: true }));
9264
9647
  this.dispatchEvent(new CustomEvent("change", { detail, bubbles: true }));
9265
9648
  }
@@ -9349,7 +9732,10 @@ class FigInputFile extends HTMLElement {
9349
9732
  this.getAttribute("disabled") !== "false";
9350
9733
  const multiple = this.hasAttribute("multiple");
9351
9734
  const variant = this.getAttribute("variant") || "input";
9352
- const hasFile = (this.#files && this.#files.length > 0) || !!this.getAttribute("url");
9735
+ const hasFile =
9736
+ (this.#files && this.#files.length > 0) ||
9737
+ !!this.getAttribute("url") ||
9738
+ !!this.getAttribute("filename");
9353
9739
 
9354
9740
  this.innerHTML = "";
9355
9741
 
@@ -9395,7 +9781,7 @@ class FigInputFile extends HTMLElement {
9395
9781
  const clearTooltip = document.createElement("fig-tooltip");
9396
9782
  clearTooltip.setAttribute("text", "Remove");
9397
9783
  this.#clearBtn = document.createElement("fig-button");
9398
- this.#clearBtn.setAttribute("variant", "ghost");
9784
+ this.#clearBtn.setAttribute("variant", variant === "overlay" ? "overlay" : "ghost");
9399
9785
  this.#clearBtn.setAttribute("icon", "true");
9400
9786
  this.#clearBtn.className = "fig-input-file-clear";
9401
9787
  if (disabled) this.#clearBtn.setAttribute("disabled", "");
@@ -9445,7 +9831,7 @@ customElements.define("fig-input-file", FigInputFile);
9445
9831
  * A bezier / spring easing curve editor with draggable control points.
9446
9832
  * @attr {string} value - Bezier: "0.42, 0, 0.58, 1" or Spring: "spring(200, 15, 1)"
9447
9833
  * @attr {number} precision - Decimal places for output values (default 2)
9448
- * @attr {boolean} dropdown - Show a preset dropdown selector
9834
+ * @attr {boolean} edit - Show the editor and custom preset options (default true; set "false" for presets only)
9449
9835
  */
9450
9836
  class FigEasingCurve extends HTMLElement {
9451
9837
  #cp1 = { x: 0.42, y: 0 };
@@ -9463,6 +9849,7 @@ class FigEasingCurve extends HTMLElement {
9463
9849
  #bezierEndpointStart = null;
9464
9850
  #bezierEndpointEnd = null;
9465
9851
  #dropdown = null;
9852
+ #valueInput = null;
9466
9853
  #presetName = null;
9467
9854
  #targetLine = null;
9468
9855
  #springDuration = 0.8;
@@ -9544,7 +9931,7 @@ class FigEasingCurve extends HTMLElement {
9544
9931
  ];
9545
9932
 
9546
9933
  static get observedAttributes() {
9547
- return ["value", "precision", "aspect-ratio"];
9934
+ return ["value", "precision", "aspect-ratio", "edit"];
9548
9935
  }
9549
9936
 
9550
9937
  connectedCallback() {
@@ -9566,6 +9953,8 @@ class FigEasingCurve extends HTMLElement {
9566
9953
  }
9567
9954
 
9568
9955
  attributeChangedCallback(name, oldValue, newValue) {
9956
+ if (oldValue === newValue) return;
9957
+
9569
9958
  if (name === "aspect-ratio") {
9570
9959
  figSyncCssVar(this, "--aspect-ratio", newValue);
9571
9960
  if (this.#svg) {
@@ -9575,16 +9964,21 @@ class FigEasingCurve extends HTMLElement {
9575
9964
  return;
9576
9965
  }
9577
9966
 
9578
- if (!this.#svg) return;
9967
+ if (name === "edit") {
9968
+ if (this.isConnected) this.#render();
9969
+ return;
9970
+ }
9971
+
9579
9972
  if (name === "value" && newValue) {
9580
9973
  const prevMode = this.#mode;
9581
9974
  this.#parseValue(newValue);
9582
9975
  this.#presetName = this.#matchPreset();
9583
- if (prevMode !== this.#mode) {
9976
+ if (prevMode !== this.#mode && this.#isEditEnabled()) {
9584
9977
  this.#render();
9585
9978
  } else {
9586
- this.#updatePaths();
9979
+ if (this.#svg) this.#updatePaths();
9587
9980
  this.#syncDropdown();
9981
+ this.#syncValueInput();
9588
9982
  }
9589
9983
  }
9590
9984
  if (name === "precision") {
@@ -9635,7 +10029,7 @@ class FigEasingCurve extends HTMLElement {
9635
10029
  this.#spring.stiffness = parseFloat(springMatch[1]);
9636
10030
  this.#spring.damping = parseFloat(springMatch[2]);
9637
10031
  this.#spring.mass = parseFloat(springMatch[3]);
9638
- return;
10032
+ return true;
9639
10033
  }
9640
10034
  const parts = str.split(",").map((s) => parseFloat(s.trim()));
9641
10035
  if (parts.length >= 4 && parts.every((n) => !isNaN(n))) {
@@ -9644,7 +10038,9 @@ class FigEasingCurve extends HTMLElement {
9644
10038
  this.#cp1.y = parts[1];
9645
10039
  this.#cp2.x = parts[2];
9646
10040
  this.#cp2.y = parts[3];
10041
+ return true;
9647
10042
  }
10043
+ return false;
9648
10044
  }
9649
10045
 
9650
10046
  #matchPreset() {
@@ -9777,23 +10173,38 @@ class FigEasingCurve extends HTMLElement {
9777
10173
 
9778
10174
  // --- Rendering ---
9779
10175
 
10176
+ #isEditEnabled() {
10177
+ return this.getAttribute("edit") !== "false";
10178
+ }
10179
+
9780
10180
  #render() {
9781
10181
  this.classList.toggle("spring-mode", this.#mode === "spring");
9782
10182
  this.classList.toggle("bezier-mode", this.#mode !== "spring");
9783
10183
  this.#syncMetricsFromCSS();
9784
10184
  this.innerHTML = this.#getInnerHTML();
9785
10185
  this.#cacheRefs();
9786
- this.#syncHandleSizes();
9787
- this.#syncViewportSize();
9788
- this.#updatePaths();
10186
+ if (this.#svg) {
10187
+ this.#syncHandleSizes();
10188
+ this.#syncViewportSize();
10189
+ this.#updatePaths();
10190
+ }
10191
+ this.#syncValueInput();
9789
10192
  this.#setupEvents();
9790
10193
  }
9791
10194
 
10195
+ static #escapeAttribute(value) {
10196
+ return String(value)
10197
+ .replace(/&/g, "&amp;")
10198
+ .replace(/"/g, "&quot;")
10199
+ .replace(/</g, "&lt;")
10200
+ .replace(/>/g, "&gt;");
10201
+ }
10202
+
9792
10203
  #getDropdownHTML() {
9793
- if (this.getAttribute("dropdown") !== "true") return "";
9794
10204
  let optionsHTML = "";
9795
10205
  let currentGroup = undefined;
9796
10206
  for (const p of FigEasingCurve.PRESETS) {
10207
+ if (!this.#isEditEnabled() && !p.value && !p.spring) continue;
9797
10208
  if (p.group !== currentGroup) {
9798
10209
  if (currentGroup !== undefined) optionsHTML += `</optgroup>`;
9799
10210
  if (p.group) optionsHTML += `<optgroup label="${p.group}">`;
@@ -9822,6 +10233,8 @@ class FigEasingCurve extends HTMLElement {
9822
10233
  #getInnerHTML() {
9823
10234
  const size = 200;
9824
10235
  const dropdown = this.#getDropdownHTML();
10236
+ if (!this.#isEditEnabled()) return dropdown;
10237
+ const valueInput = `<fig-input-text class="fig-easing-curve-value-input" value="${FigEasingCurve.#escapeAttribute(this.value)}" full></fig-input-text>`;
9825
10238
 
9826
10239
  if (this.#mode === "spring") {
9827
10240
  const targetY = 40;
@@ -9833,7 +10246,7 @@ class FigEasingCurve extends HTMLElement {
9833
10246
  <path class="fig-easing-curve-path"/>
9834
10247
  <foreignObject class="fig-easing-curve-handle" data-handle="bounce" width="20" height="20"><fig-handle size="small"></fig-handle></foreignObject>
9835
10248
  <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>`;
10249
+ </svg></div>${valueInput}`;
9837
10250
  }
9838
10251
 
9839
10252
  return `${dropdown}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
@@ -9846,7 +10259,7 @@ class FigEasingCurve extends HTMLElement {
9846
10259
  <circle class="fig-easing-curve-endpoint" data-endpoint="end" r="${this.#bezierEndpointRadius}"/>
9847
10260
  <foreignObject class="fig-easing-curve-handle" data-handle="1" width="20" height="20"><fig-handle size="small"></fig-handle></foreignObject>
9848
10261
  <foreignObject class="fig-easing-curve-handle" data-handle="2" width="20" height="20"><fig-handle size="small"></fig-handle></foreignObject>
9849
- </svg></div>`;
10262
+ </svg></div>${valueInput}`;
9850
10263
  }
9851
10264
 
9852
10265
  #readCssNumber(name, fallback) {
@@ -9881,6 +10294,7 @@ class FigEasingCurve extends HTMLElement {
9881
10294
  this.#bezierEndpointStart = this.querySelector('[data-endpoint="start"]');
9882
10295
  this.#bezierEndpointEnd = this.querySelector('[data-endpoint="end"]');
9883
10296
  this.#dropdown = this.querySelector(".fig-easing-curve-dropdown");
10297
+ this.#valueInput = this.querySelector(".fig-easing-curve-value-input");
9884
10298
  this.#targetLine = this.querySelector(".fig-easing-curve-target");
9885
10299
  this.#bounds = this.querySelector(".fig-easing-curve-bounds");
9886
10300
  this.#diagonal = this.querySelector(".fig-easing-curve-diagonal");
@@ -9964,6 +10378,17 @@ class FigEasingCurve extends HTMLElement {
9964
10378
  }
9965
10379
  }
9966
10380
 
10381
+ #syncActiveBezierArm() {
10382
+ this.#line1?.classList.toggle(
10383
+ "is-active",
10384
+ this.#mode === "bezier" && this.#isDragging === 1,
10385
+ );
10386
+ this.#line2?.classList.toggle(
10387
+ "is-active",
10388
+ this.#mode === "bezier" && this.#isDragging === 2,
10389
+ );
10390
+ }
10391
+
9967
10392
  #updateBezierPaths() {
9968
10393
  if (this.#bounds) {
9969
10394
  this.#bounds.setAttribute("x", "0");
@@ -10096,6 +10521,47 @@ class FigEasingCurve extends HTMLElement {
10096
10521
  this.#refreshCustomPresetIcons();
10097
10522
  }
10098
10523
 
10524
+ #syncValueInput() {
10525
+ if (!this.#valueInput) return;
10526
+ this.#valueInput.setAttribute("value", this.value);
10527
+ }
10528
+
10529
+ #parseManualBezierValue(value) {
10530
+ const parts = value.split(",").map((part) => Number.parseFloat(part.trim()));
10531
+ if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
10532
+ return null;
10533
+ }
10534
+ if (parts[0] < 0 || parts[0] > 1 || parts[2] < 0 || parts[2] > 1) {
10535
+ return null;
10536
+ }
10537
+ return parts;
10538
+ }
10539
+
10540
+ #applyManualValue(value, eventType) {
10541
+ const parts = this.#parseManualBezierValue(value);
10542
+ if (!parts) {
10543
+ if (eventType === "change") this.#syncValueInput();
10544
+ return;
10545
+ }
10546
+
10547
+ const prevMode = this.#mode;
10548
+ this.#mode = "bezier";
10549
+ this.#cp1.x = parts[0];
10550
+ this.#cp1.y = parts[1];
10551
+ this.#cp2.x = parts[2];
10552
+ this.#cp2.y = parts[3];
10553
+
10554
+ this.#presetName = this.#matchPreset();
10555
+ if (prevMode !== this.#mode) {
10556
+ this.#render();
10557
+ } else {
10558
+ this.#updatePaths();
10559
+ this.#syncDropdown();
10560
+ if (eventType === "change") this.#syncValueInput();
10561
+ }
10562
+ this.#emit(eventType);
10563
+ }
10564
+
10099
10565
  #setOptionIconByValue(root, optionValue, icon) {
10100
10566
  if (!root) return;
10101
10567
  for (const option of root.querySelectorAll("option")) {
@@ -10107,6 +10573,7 @@ class FigEasingCurve extends HTMLElement {
10107
10573
 
10108
10574
  #refreshCustomPresetIcons() {
10109
10575
  if (!this.#dropdown) return;
10576
+ if (!this.#isEditEnabled()) return;
10110
10577
  const bezierIcon = FigEasingCurve.curveIcon(
10111
10578
  this.#cp1.x,
10112
10579
  this.#cp1.y,
@@ -10147,7 +10614,7 @@ class FigEasingCurve extends HTMLElement {
10147
10614
  }
10148
10615
 
10149
10616
  #setupEvents() {
10150
- if (this.#mode === "bezier") {
10617
+ if (this.#svg && this.#mode === "bezier") {
10151
10618
  this.#handle1.addEventListener("pointerdown", (e) =>
10152
10619
  this.#startBezierDrag(e, 1),
10153
10620
  );
@@ -10165,7 +10632,7 @@ class FigEasingCurve extends HTMLElement {
10165
10632
  this.#startBezierDrag(e, this.#bezierHandleForClientHalf(e));
10166
10633
  });
10167
10634
  }
10168
- } else {
10635
+ } else if (this.#svg) {
10169
10636
  this.#handle1.addEventListener("pointerdown", (e) => {
10170
10637
  e.stopPropagation();
10171
10638
  this.#startSpringDrag(e, "bounce");
@@ -10204,8 +10671,9 @@ class FigEasingCurve extends HTMLElement {
10204
10671
  if (this.#mode !== "bezier") {
10205
10672
  this.#mode = "bezier";
10206
10673
  this.#render();
10207
- } else {
10674
+ } else if (this.#svg) {
10208
10675
  this.#updatePaths();
10676
+ this.#syncValueInput();
10209
10677
  }
10210
10678
  } else if (preset.type === "spring") {
10211
10679
  if (preset.spring) {
@@ -10215,14 +10683,30 @@ class FigEasingCurve extends HTMLElement {
10215
10683
  if (this.#mode !== "spring") {
10216
10684
  this.#mode = "spring";
10217
10685
  this.#render();
10218
- } else {
10686
+ } else if (this.#svg) {
10219
10687
  this.#updatePaths();
10688
+ this.#syncValueInput();
10220
10689
  }
10221
10690
  }
10222
10691
  this.#emit("input");
10223
10692
  this.#emit("change");
10224
10693
  });
10225
10694
  }
10695
+
10696
+ if (this.#valueInput) {
10697
+ this.#valueInput.addEventListener("input", (e) => {
10698
+ e.stopPropagation();
10699
+ const value = e.detail ?? e.target?.value;
10700
+ if (typeof value !== "string") return;
10701
+ this.#applyManualValue(value, "input");
10702
+ });
10703
+ this.#valueInput.addEventListener("change", (e) => {
10704
+ e.stopPropagation();
10705
+ const value = e.detail ?? e.target?.value;
10706
+ if (typeof value !== "string") return;
10707
+ this.#applyManualValue(value, "change");
10708
+ });
10709
+ }
10226
10710
  }
10227
10711
 
10228
10712
  #clientToSVG(e) {
@@ -10243,6 +10727,7 @@ class FigEasingCurve extends HTMLElement {
10243
10727
  #startBezierDrag(e, handle) {
10244
10728
  e.preventDefault();
10245
10729
  this.#isDragging = handle;
10730
+ this.#syncActiveBezierArm();
10246
10731
 
10247
10732
  const onMove = (e) => {
10248
10733
  if (!this.#isDragging) return;
@@ -10263,11 +10748,13 @@ class FigEasingCurve extends HTMLElement {
10263
10748
  this.#updatePaths();
10264
10749
  this.#presetName = this.#matchPreset();
10265
10750
  this.#syncDropdown();
10751
+ this.#syncValueInput();
10266
10752
  this.#emit("input");
10267
10753
  };
10268
10754
 
10269
10755
  const onUp = () => {
10270
10756
  this.#isDragging = null;
10757
+ this.#syncActiveBezierArm();
10271
10758
  document.removeEventListener("pointermove", onMove);
10272
10759
  document.removeEventListener("pointerup", onUp);
10273
10760
  this.#emit("change");
@@ -10311,6 +10798,7 @@ class FigEasingCurve extends HTMLElement {
10311
10798
  this.#updatePaths();
10312
10799
  this.#presetName = this.#matchPreset();
10313
10800
  this.#syncDropdown();
10801
+ this.#syncValueInput();
10314
10802
  this.#emit("input");
10315
10803
  };
10316
10804
 
@@ -11818,11 +12306,34 @@ customElements.define("fig-spinner", FigSpinner);
11818
12306
  /**
11819
12307
  * A styled visual preview layer for arbitrary content such as images, canvas,
11820
12308
  * video, SVG, or custom rendered surfaces.
12309
+ * @attr {string} fit - CSS object-fit value for direct media children
11821
12310
  */
11822
- class FigPreview extends HTMLElement {}
12311
+ class FigPreview extends HTMLElement {
12312
+ static get observedAttributes() {
12313
+ return ["fit"];
12314
+ }
12315
+
12316
+ connectedCallback() {
12317
+ this.#syncFit();
12318
+ }
12319
+
12320
+ attributeChangedCallback(name, oldValue, newValue) {
12321
+ if (oldValue === newValue) return;
12322
+ if (name === "fit") this.#syncFit();
12323
+ }
12324
+
12325
+ #syncFit() {
12326
+ const fit = this.getAttribute("fit");
12327
+ if (fit) {
12328
+ this.style.setProperty("--fig-preview-fit", fit);
12329
+ } else {
12330
+ this.style.removeProperty("--fig-preview-fit");
12331
+ }
12332
+ }
12333
+ }
11823
12334
  customElements.define("fig-preview", FigPreview);
11824
12335
 
11825
- /** @type {Record<string, string>} */
12336
+ /** @type {Record<string, string | { medium: string, small: string }>} */
11826
12337
  const FIG_ICON_TOKENS = {
11827
12338
  chevron: "--icon-16-chevron",
11828
12339
  checkmark: "--icon-16-checkmark",
@@ -11834,16 +12345,25 @@ const FIG_ICON_TOKENS = {
11834
12345
  minus: "--icon-24-minus",
11835
12346
  back: "--icon-24-back",
11836
12347
  forward: "--icon-24-forward",
11837
- close: "--icon-24-close",
12348
+ close: { medium: "--icon-24-close", small: "--icon-16-close" },
11838
12349
  rotate: "--icon-24-rotate",
11839
12350
  swap: "--icon-24-swap",
11840
12351
  play: "--icon-24-play",
11841
12352
  pause: "--icon-24-pause",
12353
+ search: "--icon-24-search",
12354
+ visible: { medium: "--icon-24-visible", small: "--icon-16-visible" },
12355
+ hidden: { medium: "--icon-24-hidden", small: "--icon-16-hidden" },
11842
12356
  };
11843
12357
 
11844
- function figIconCssVar(name) {
12358
+ function figIconCssVar(name, size = "medium") {
11845
12359
  const token = name && FIG_ICON_TOKENS[name];
11846
- return token ? `var(${token})` : "";
12360
+ if (!token) return "";
12361
+
12362
+ const tokenName =
12363
+ typeof token === "string"
12364
+ ? token
12365
+ : token[size === "small" ? "small" : "medium"];
12366
+ return tokenName ? `var(${tokenName})` : "";
11847
12367
  }
11848
12368
 
11849
12369
  /**
@@ -11867,11 +12387,11 @@ class FigIcon extends HTMLElement {
11867
12387
 
11868
12388
  #sync() {
11869
12389
  const iconName = this.getAttribute("name");
11870
- const cssVar = figIconCssVar(iconName);
12390
+ const size = this.getAttribute("size") || "medium";
12391
+ const cssVar = figIconCssVar(iconName, size);
11871
12392
  if (cssVar) this.style.setProperty("--icon", cssVar);
11872
12393
  else this.style.removeProperty("--icon");
11873
12394
 
11874
- const size = this.getAttribute("size") || "medium";
11875
12395
  if (size === "small") {
11876
12396
  this.style.setProperty("--size", "var(--spacer-3)");
11877
12397
  } else {
@@ -11901,2159 +12421,7 @@ customElements.define("fig-button-combo", FigButtonCombo);
11901
12421
  class FigInputCombo extends HTMLElement {}
11902
12422
  customElements.define("fig-input-combo", FigInputCombo);
11903
12423
 
11904
- // FigFillPicker
11905
- /**
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")
11913
- */
11914
- class FigFillPicker extends HTMLElement {
11915
- #trigger = null;
11916
- #chit = null;
11917
- #dialog = null;
11918
- #activeTab = "solid";
11919
- anchorElement = null;
11920
12424
 
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
12425
 
14058
12426
  /* Color Tip */
14059
12427
  /**
@@ -14094,6 +12462,10 @@ class FigColorTip extends HTMLElement {
14094
12462
  this.#fillPicker.removeEventListener("input", this.#boundHandleInput);
14095
12463
  this.#fillPicker.removeEventListener("change", this.#boundHandleChange);
14096
12464
  }
12465
+ if (this.#chit) {
12466
+ this.#chit.removeEventListener("input", this.#boundHandleInput);
12467
+ this.#chit.removeEventListener("change", this.#boundHandleChange);
12468
+ }
14097
12469
  if (this.#chitSelectedObserver) {
14098
12470
  this.#chitSelectedObserver.disconnect();
14099
12471
  this.#chitSelectedObserver = null;
@@ -14151,16 +12523,22 @@ class FigColorTip extends HTMLElement {
14151
12523
  opacity: Math.round(alpha * 100),
14152
12524
  })
14153
12525
  : 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>`;
12526
+ const chitAlphaAttr = alpha < 1 ? ` alpha="${alpha}"` : "";
12527
+ this.innerHTML = hasFigFillPicker()
12528
+ ? `<fig-fill-picker mode="solid" ${alphaAttr} value='${pickerValue}'>
12529
+ <fig-chit background="${color}"${chitAlphaAttr}></fig-chit>
12530
+ </fig-fill-picker>`
12531
+ : `<fig-chit background="${color}"${chitAlphaAttr}></fig-chit>`;
14158
12532
 
14159
12533
  this.#fillPicker = this.querySelector("fig-fill-picker");
14160
12534
  this.#chit = this.querySelector("fig-chit");
14161
12535
  this.#teardownListeners();
14162
12536
  this.#fillPicker?.addEventListener("input", this.#boundHandleInput);
14163
12537
  this.#fillPicker?.addEventListener("change", this.#boundHandleChange);
12538
+ if (!this.#fillPicker) {
12539
+ this.#chit?.addEventListener("input", this.#boundHandleInput);
12540
+ this.#chit?.addEventListener("change", this.#boundHandleChange);
12541
+ }
14164
12542
  this.#observeChitSelected();
14165
12543
  }
14166
12544
 
@@ -14187,8 +12565,13 @@ class FigColorTip extends HTMLElement {
14187
12565
  #extractAlpha(colorValue) {
14188
12566
  if (!colorValue) return 1;
14189
12567
  const v = String(colorValue).trim();
14190
- if (v.startsWith("#") && v.length === 9) {
14191
- return parseInt(v.slice(7, 9), 16) / 255;
12568
+ const hex = v.replace(/^#/, "");
12569
+ if (/^[0-9a-f]{4}$/i.test(hex)) {
12570
+ const a = hex[3];
12571
+ return parseInt(`${a}${a}`, 16) / 255;
12572
+ }
12573
+ if (/^[0-9a-f]{8}$/i.test(hex)) {
12574
+ return parseInt(hex.slice(6, 8), 16) / 255;
14192
12575
  }
14193
12576
  const rgbaMatch = v.match(
14194
12577
  /rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/i,
@@ -14215,6 +12598,9 @@ class FigColorTip extends HTMLElement {
14215
12598
  if (value.startsWith("#")) {
14216
12599
  return this.#normalizeHex(value);
14217
12600
  }
12601
+ if (/^[0-9a-f]{3,4}$|^[0-9a-f]{6}$|^[0-9a-f]{8}$/i.test(value)) {
12602
+ return this.#normalizeHex(value);
12603
+ }
14218
12604
 
14219
12605
  try {
14220
12606
  const { ctx } = figGetSharedCanvas(1, 1);
@@ -14268,6 +12654,11 @@ class FigColorTip extends HTMLElement {
14268
12654
 
14269
12655
  if (this.#chit) {
14270
12656
  this.#chit.setAttribute("background", color);
12657
+ if (alpha < 1) {
12658
+ this.#chit.setAttribute("alpha", String(alpha));
12659
+ } else {
12660
+ this.#chit.removeAttribute("alpha");
12661
+ }
14271
12662
  if (this.hasAttribute("disabled")) {
14272
12663
  this.#chit.setAttribute("disabled", "");
14273
12664
  } else {
@@ -14306,12 +12697,12 @@ class FigColorTip extends HTMLElement {
14306
12697
 
14307
12698
  #handlePickerInput(event) {
14308
12699
  event.stopPropagation();
14309
- this.#updateColorFromPicker(event.detail, "input");
12700
+ this.#updateColorFromPicker(event.detail || { color: event.target?.value }, "input");
14310
12701
  }
14311
12702
 
14312
12703
  #handlePickerChange(event) {
14313
12704
  event.stopPropagation();
14314
- this.#updateColorFromPicker(event.detail, "change");
12705
+ this.#updateColorFromPicker(event.detail || { color: event.target?.value }, "change");
14315
12706
  }
14316
12707
 
14317
12708
  attributeChangedCallback(name, oldValue, newValue) {
@@ -14998,6 +13389,7 @@ class FigHandle extends HTMLElement {
14998
13389
  #applyingValue = false;
14999
13390
  #colorTip = null;
15000
13391
  #directColorPicker = null;
13392
+ #nativeColorInput = null;
15001
13393
  #hitAreaEl = null;
15002
13394
 
15003
13395
  get #controlMode() {
@@ -15240,6 +13632,7 @@ class FigHandle extends HTMLElement {
15240
13632
  this.#teardownDrag();
15241
13633
  this.#hideColorTip();
15242
13634
  this.#removeDirectColorPicker();
13635
+ this.#removeNativeColorInput();
15243
13636
  if (this.#hitAreaEl) {
15244
13637
  this.#hitAreaEl.remove();
15245
13638
  this.#hitAreaEl = null;
@@ -15566,6 +13959,7 @@ class FigHandle extends HTMLElement {
15566
13959
  }
15567
13960
 
15568
13961
  #ensureDirectColorPicker() {
13962
+ if (!hasFigFillPicker()) return null;
15569
13963
  if (this.#directColorPicker) return this.#directColorPicker;
15570
13964
 
15571
13965
  const picker = document.createElement("fig-fill-picker");
@@ -15591,6 +13985,10 @@ class FigHandle extends HTMLElement {
15591
13985
  if (this.hasAttribute("disabled")) return;
15592
13986
  this.#hideColorTip();
15593
13987
  const picker = this.#ensureDirectColorPicker();
13988
+ if (!picker) {
13989
+ this.#openNativeColorPicker();
13990
+ return;
13991
+ }
15594
13992
  this.setAttribute("selected", "");
15595
13993
  this.#syncDirectColorPickerValue();
15596
13994
  picker.open();
@@ -15607,6 +14005,40 @@ class FigHandle extends HTMLElement {
15607
14005
  this.removeAttribute("selected");
15608
14006
  }
15609
14007
 
14008
+ #ensureNativeColorInput() {
14009
+ if (this.#nativeColorInput) return this.#nativeColorInput;
14010
+ const input = document.createElement("input");
14011
+ input.type = "color";
14012
+ input.tabIndex = -1;
14013
+ input.setAttribute("aria-hidden", "true");
14014
+ input.style.position = "fixed";
14015
+ input.style.inlineSize = "1px";
14016
+ input.style.blockSize = "1px";
14017
+ input.style.opacity = "0";
14018
+ input.style.pointerEvents = "none";
14019
+ input.addEventListener("input", this.#handleNativeColorInput);
14020
+ input.addEventListener("change", this.#handleNativeColorChange);
14021
+ this.appendChild(input);
14022
+ this.#nativeColorInput = input;
14023
+ return input;
14024
+ }
14025
+
14026
+ #openNativeColorPicker() {
14027
+ const input = this.#ensureNativeColorInput();
14028
+ const { color } = this.#normalizeColorForPicker();
14029
+ input.value = color;
14030
+ this.setAttribute("selected", "");
14031
+ input.click();
14032
+ }
14033
+
14034
+ #removeNativeColorInput() {
14035
+ if (!this.#nativeColorInput) return;
14036
+ this.#nativeColorInput.removeEventListener("input", this.#handleNativeColorInput);
14037
+ this.#nativeColorInput.removeEventListener("change", this.#handleNativeColorChange);
14038
+ this.#nativeColorInput.remove();
14039
+ this.#nativeColorInput = null;
14040
+ }
14041
+
15610
14042
  #closeColorPickerForDrag() {
15611
14043
  if (this.getAttribute("type") !== "color") return;
15612
14044
  this.#hideColorTip();
@@ -15684,6 +14116,36 @@ class FigHandle extends HTMLElement {
15684
14116
  );
15685
14117
  };
15686
14118
 
14119
+ #detailFromNativeColor(value) {
14120
+ const { opacity } = this.#normalizeColorForPicker();
14121
+ return opacity < 100 ? { color: value, opacity } : { color: value };
14122
+ }
14123
+
14124
+ #handleNativeColorInput = (e) => {
14125
+ e.stopPropagation();
14126
+ const detail = this.#detailFromNativeColor(e.target.value);
14127
+ this.setAttribute("color", this.#colorWithOpacity(detail.color, detail.opacity));
14128
+ this.dispatchEvent(
14129
+ new CustomEvent("input", {
14130
+ bubbles: true,
14131
+ detail,
14132
+ }),
14133
+ );
14134
+ };
14135
+
14136
+ #handleNativeColorChange = (e) => {
14137
+ e.stopPropagation();
14138
+ const detail = this.#detailFromNativeColor(e.target.value);
14139
+ this.setAttribute("color", this.#colorWithOpacity(detail.color, detail.opacity));
14140
+ this.removeAttribute("selected");
14141
+ this.dispatchEvent(
14142
+ new CustomEvent("change", {
14143
+ bubbles: true,
14144
+ detail,
14145
+ }),
14146
+ );
14147
+ };
14148
+
15687
14149
  #handleDirectColorPickerClose = () => {
15688
14150
  this.removeAttribute("selected");
15689
14151
  };