@rogieking/figui3 3.23.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +3 -4
  2. package/components.css +15 -331
  3. package/fig.js +1088 -2730
  4. package/package.json +1 -1
package/fig.js CHANGED
@@ -4472,268 +4472,6 @@ class FigField extends HTMLElement {
4472
4472
  }
4473
4473
  customElements.define("fig-field", FigField);
4474
4474
 
4475
- /* Field + Slider wrapper */
4476
- class FigFieldSlider extends HTMLElement {
4477
- #field = null;
4478
- #label = null;
4479
- #slider = null;
4480
- #observer = null;
4481
- #managedSliderAttrs = new Set();
4482
- #steppersSyncFrame = 0;
4483
- #boundHandleSliderInput = null;
4484
- #boundHandleSliderChange = null;
4485
- #ignoredSliderAttrs = new Set(["variant", "color", "text", "full"]);
4486
-
4487
- static get observedAttributes() {
4488
- return ["label", "direction"];
4489
- }
4490
-
4491
- connectedCallback() {
4492
- if (!this.#field) {
4493
- this.#initialize();
4494
- }
4495
-
4496
- this.#syncField();
4497
- this.#syncSliderAttributes();
4498
- this.#bindSliderEvents();
4499
-
4500
- if (!this.#observer) {
4501
- this.#observer = new MutationObserver((mutations) => {
4502
- let syncField = false;
4503
- let syncSlider = false;
4504
-
4505
- for (const mutation of mutations) {
4506
- if (mutation.type === "attributes") {
4507
- if (
4508
- mutation.attributeName &&
4509
- this.#ignoredSliderAttrs.has(mutation.attributeName)
4510
- ) {
4511
- continue;
4512
- }
4513
- if (
4514
- mutation.attributeName === "label" ||
4515
- mutation.attributeName === "direction"
4516
- ) {
4517
- syncField = true;
4518
- } else {
4519
- syncSlider = true;
4520
- }
4521
- }
4522
- }
4523
-
4524
- if (syncField) this.#syncField();
4525
- if (syncSlider) this.#syncSliderAttributes();
4526
- });
4527
- }
4528
-
4529
- this.#observer.observe(this, { attributes: true });
4530
- }
4531
-
4532
- disconnectedCallback() {
4533
- this.#observer?.disconnect();
4534
- if (this.#steppersSyncFrame) {
4535
- cancelAnimationFrame(this.#steppersSyncFrame);
4536
- this.#steppersSyncFrame = 0;
4537
- }
4538
- this.#unbindSliderEvents();
4539
- }
4540
-
4541
- attributeChangedCallback(name, oldValue, newValue) {
4542
- if (oldValue === newValue || !this.#field) return;
4543
- if (name === "label" || name === "direction") {
4544
- this.#syncField();
4545
- }
4546
- }
4547
-
4548
- #initialize() {
4549
- const initialChildren = Array.from(this.childNodes).filter((node) => {
4550
- return (
4551
- node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim())
4552
- );
4553
- });
4554
-
4555
- const field = document.createElement("fig-field");
4556
- const label = document.createElement("label");
4557
- const slider = document.createElement("fig-slider");
4558
- slider.setAttribute("text", "true");
4559
- for (const attrName of this.#getForwardedSliderAttrNames()) {
4560
- const value = this.getAttribute(attrName);
4561
- slider.setAttribute(attrName, value ?? "");
4562
- }
4563
-
4564
- field.append(label, slider);
4565
-
4566
- this.#field = field;
4567
- this.#label = label;
4568
- this.#slider = slider;
4569
-
4570
- this.replaceChildren(field);
4571
-
4572
- for (const node of initialChildren) {
4573
- this.#slider.appendChild(node);
4574
- }
4575
- }
4576
-
4577
- #syncField() {
4578
- if (!this.#field || !this.#label) return;
4579
- const hasLabelAttr = this.hasAttribute("label");
4580
- const rawLabel = this.getAttribute("label");
4581
- const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
4582
-
4583
- if (isBlankLabel) {
4584
- if (this.#label.parentElement === this.#field) {
4585
- this.#label.remove();
4586
- }
4587
- } else {
4588
- this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
4589
- if (this.#label.parentElement !== this.#field) {
4590
- this.#field.prepend(this.#label);
4591
- }
4592
- }
4593
-
4594
- this.#field.setAttribute(
4595
- "direction",
4596
- this.getAttribute("direction") || "horizontal",
4597
- );
4598
- }
4599
-
4600
- #syncSliderAttributes() {
4601
- if (!this.#slider) return;
4602
- const hostAttrs = this.#getForwardedSliderAttrNames();
4603
-
4604
- const nextManaged = new Set(hostAttrs.filter((name) => name !== "text"));
4605
-
4606
- for (const attrName of this.#managedSliderAttrs) {
4607
- if (!nextManaged.has(attrName)) {
4608
- this.#slider.removeAttribute(attrName);
4609
- }
4610
- }
4611
-
4612
- for (const attrName of hostAttrs) {
4613
- if (attrName === "text") continue;
4614
- const value = this.getAttribute(attrName);
4615
- this.#slider.setAttribute(attrName, value ?? "");
4616
- }
4617
-
4618
- this.#slider.removeAttribute("variant");
4619
- this.#slider.removeAttribute("color");
4620
- this.#slider.removeAttribute("transform");
4621
- this.#slider.removeAttribute("full");
4622
- this.#slider.setAttribute("text", "true");
4623
-
4624
- const sliderType = (this.getAttribute("type") || "range").toLowerCase();
4625
- if (sliderType === "delta" || sliderType === "stepper") {
4626
- this.#slider.setAttribute(
4627
- "default",
4628
- this.getAttribute("default") ?? "50",
4629
- );
4630
- } else if (!this.hasAttribute("default")) {
4631
- this.#slider.removeAttribute("default");
4632
- }
4633
- if (sliderType === "stepper") {
4634
- this.#slider.setAttribute("step", this.getAttribute("step") ?? "10");
4635
- } else if (!this.hasAttribute("step")) {
4636
- this.#slider.removeAttribute("step");
4637
- }
4638
- if (sliderType === "opacity") {
4639
- this.#slider.style.setProperty(
4640
- "--color",
4641
- "var(--figma-color-bg-tertiary)",
4642
- );
4643
- } else {
4644
- this.#slider.style.removeProperty("--color");
4645
- }
4646
-
4647
- this.#managedSliderAttrs = nextManaged;
4648
- this.#queueSteppersSync();
4649
- }
4650
-
4651
- #getForwardedSliderAttrNames() {
4652
- const reserved = new Set([
4653
- "label",
4654
- "direction",
4655
- "oninput",
4656
- "onchange",
4657
- "steppers",
4658
- ]);
4659
- return this.getAttributeNames().filter(
4660
- (name) => !reserved.has(name) && !this.#ignoredSliderAttrs.has(name),
4661
- );
4662
- }
4663
-
4664
- #queueSteppersSync() {
4665
- if (this.#steppersSyncFrame) {
4666
- cancelAnimationFrame(this.#steppersSyncFrame);
4667
- }
4668
- this.#steppersSyncFrame = requestAnimationFrame(() => {
4669
- this.#steppersSyncFrame = 0;
4670
- this.#syncSteppersToNumberInput();
4671
- });
4672
- }
4673
-
4674
- #syncSteppersToNumberInput() {
4675
- if (!this.#slider) return;
4676
- const numberInput = this.#slider.querySelector("fig-input-number");
4677
- if (!numberInput) return;
4678
-
4679
- const hasSteppers =
4680
- this.hasAttribute("steppers") &&
4681
- this.getAttribute("steppers") !== "false";
4682
- if (!hasSteppers) {
4683
- numberInput.removeAttribute("steppers");
4684
- return;
4685
- }
4686
-
4687
- const steppersValue = this.getAttribute("steppers");
4688
- numberInput.setAttribute("steppers", steppersValue ?? "");
4689
- }
4690
-
4691
- #bindSliderEvents() {
4692
- if (!this.#slider) return;
4693
- if (!this.#boundHandleSliderInput) {
4694
- this.#boundHandleSliderInput = this.#forwardSliderEvent.bind(
4695
- this,
4696
- "input",
4697
- );
4698
- }
4699
- if (!this.#boundHandleSliderChange) {
4700
- this.#boundHandleSliderChange = this.#forwardSliderEvent.bind(
4701
- this,
4702
- "change",
4703
- );
4704
- }
4705
- this.#slider.addEventListener("input", this.#boundHandleSliderInput);
4706
- this.#slider.addEventListener("change", this.#boundHandleSliderChange);
4707
- }
4708
-
4709
- #unbindSliderEvents() {
4710
- if (!this.#slider) return;
4711
- if (this.#boundHandleSliderInput) {
4712
- this.#slider.removeEventListener("input", this.#boundHandleSliderInput);
4713
- }
4714
- if (this.#boundHandleSliderChange) {
4715
- this.#slider.removeEventListener("change", this.#boundHandleSliderChange);
4716
- }
4717
- }
4718
-
4719
- #forwardSliderEvent(type, event) {
4720
- event.stopPropagation();
4721
- const detail =
4722
- event instanceof CustomEvent && event.detail !== undefined
4723
- ? event.detail
4724
- : this.#slider?.value;
4725
- this.dispatchEvent(
4726
- new CustomEvent(type, {
4727
- detail,
4728
- bubbles: true,
4729
- cancelable: true,
4730
- composed: true,
4731
- }),
4732
- );
4733
- }
4734
- }
4735
- customElements.define("fig-field-slider", FigFieldSlider);
4736
-
4737
4475
  /* Color swatch */
4738
4476
  class FigInputColor extends HTMLElement {
4739
4477
  rgba;
@@ -7695,142 +7433,206 @@ figDefineCustomizedBuiltIn("fig-toast", FigToast, { extends: "dialog" });
7695
7433
  * @attr {string} options - Comma-separated list of dropdown options
7696
7434
  * @attr {string} placeholder - Placeholder text for the input
7697
7435
  * @attr {string} value - The current input value
7436
+ * @attr {boolean} disabled - Disables the input and dropdown button
7437
+ * @attr {string} experimental - Feature flag passed to internal fig-dropdown
7698
7438
  */
7699
7439
  class FigComboInput extends HTMLElement {
7440
+ static observedAttributes = [
7441
+ "options",
7442
+ "placeholder",
7443
+ "value",
7444
+ "disabled",
7445
+ "experimental",
7446
+ ];
7447
+
7700
7448
  #usesCustomDropdown = false;
7701
- #boundHandleSelectInput = null;
7449
+ #input = null;
7450
+ #dropdown = null;
7451
+ #button = null;
7452
+ #customDropdown = null;
7453
+ #internalUpdate = false;
7702
7454
 
7703
- constructor() {
7704
- super();
7705
- this.#boundHandleSelectInput = this.handleSelectInput.bind(this);
7455
+ #boundHandleDropdownInput = this.#handleDropdownInput.bind(this);
7456
+ #boundHandleTextInput = this.#handleTextInput.bind(this);
7457
+ #boundHandleTextChange = this.#handleTextChange.bind(this);
7458
+
7459
+ get value() {
7460
+ return this.getAttribute("value") || "";
7706
7461
  }
7707
- getOptionsFromAttribute() {
7708
- return (this.getAttribute("options") || "").split(",");
7462
+
7463
+ set value(val) {
7464
+ this.setAttribute("value", val ?? "");
7709
7465
  }
7466
+
7710
7467
  connectedCallback() {
7711
- const customDropdown =
7468
+ this.#customDropdown =
7712
7469
  Array.from(this.children).find(
7713
7470
  (child) => child.tagName === "FIG-DROPDOWN",
7714
7471
  ) || null;
7715
- this.#usesCustomDropdown = customDropdown !== null;
7716
- if (customDropdown) {
7717
- customDropdown.remove();
7472
+ this.#usesCustomDropdown = this.#customDropdown !== null;
7473
+ if (this.#customDropdown) {
7474
+ this.#customDropdown.remove();
7718
7475
  }
7719
7476
 
7720
- this.options = this.getOptionsFromAttribute();
7721
- this.placeholder = this.getAttribute("placeholder") || "";
7722
- this.value = this.getAttribute("value") || "";
7477
+ this.#render();
7478
+ this.#setupListeners();
7479
+
7480
+ if (this.hasAttribute("disabled")) {
7481
+ this.#applyDisabled(true);
7482
+ }
7483
+ }
7484
+
7485
+ disconnectedCallback() {
7486
+ this.#teardownListeners();
7487
+ }
7488
+
7489
+ #render() {
7490
+ const options = this.#getOptions();
7491
+ const placeholder = this.getAttribute("placeholder") || "";
7492
+ const currentValue = this.value;
7723
7493
  const experimental = this.getAttribute("experimental");
7724
- const expAttr = experimental ? `experimental="${experimental}"` : "";
7494
+ const expAttr = experimental ? ` experimental="${experimental}"` : "";
7495
+
7725
7496
  const dropdownHTML = this.#usesCustomDropdown
7726
7497
  ? ""
7727
- : `<fig-dropdown type="dropdown" ${expAttr}>
7728
- ${this.options
7729
- .map((option) => `<option>${option}</option>`)
7730
- .join("")}
7731
- </fig-dropdown>`;
7498
+ : `<fig-dropdown type="dropdown"${expAttr}>${options.map((o) => `<option>${o.trim()}</option>`).join("")}</fig-dropdown>`;
7499
+
7732
7500
  this.innerHTML = `<div class="input-combo">
7733
- <fig-input-text placeholder="${this.placeholder}">
7734
- </fig-input-text>
7735
- <fig-button type="select" variant="input" icon>
7736
- <svg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
7737
- <path d='M5.87868 7.12132L8 9.24264L10.1213 7.12132' stroke='currentColor' stroke-opacity="0.9" stroke-linecap='round'/>
7738
- </svg>
7739
- ${dropdownHTML}
7740
- </fig-button>
7741
- </div>`;
7742
- requestAnimationFrame(() => {
7743
- this.input = this.querySelector("fig-input-text");
7744
- const button = this.querySelector("fig-button");
7501
+ <fig-input-text placeholder="${placeholder}" value="${currentValue}"></fig-input-text>
7502
+ <fig-button type="select" variant="input" icon>
7503
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
7504
+ <path d="M5.87868 7.12132L8 9.24264L10.1213 7.12132" stroke="currentColor" stroke-opacity="0.9" stroke-linecap="round"/>
7505
+ </svg>
7506
+ ${dropdownHTML}
7507
+ </fig-button>
7508
+ </div>`;
7745
7509
 
7746
- if (this.#usesCustomDropdown && customDropdown && button) {
7747
- if (!customDropdown.hasAttribute("type")) {
7748
- customDropdown.setAttribute("type", "dropdown");
7749
- }
7750
- if (experimental) {
7751
- customDropdown.setAttribute("experimental", experimental);
7752
- }
7753
- button.append(customDropdown);
7510
+ this.#input = this.querySelector("fig-input-text");
7511
+ this.#button = this.querySelector("fig-button");
7512
+
7513
+ if (this.#usesCustomDropdown && this.#customDropdown && this.#button) {
7514
+ if (!this.#customDropdown.hasAttribute("type")) {
7515
+ this.#customDropdown.setAttribute("type", "dropdown");
7754
7516
  }
7755
- this.dropdown = this.querySelector("fig-dropdown");
7517
+ if (experimental) {
7518
+ this.#customDropdown.setAttribute("experimental", experimental);
7519
+ }
7520
+ this.#button.append(this.#customDropdown);
7521
+ }
7756
7522
 
7757
- this.dropdown?.removeEventListener("input", this.#boundHandleSelectInput);
7758
- this.dropdown?.addEventListener("input", this.#boundHandleSelectInput);
7523
+ this.#dropdown = this.querySelector("fig-dropdown");
7524
+ }
7759
7525
 
7760
- if (this.input) {
7761
- this.input.setAttribute("value", this.value);
7762
- }
7526
+ #setupListeners() {
7527
+ this.#dropdown?.addEventListener("input", this.#boundHandleDropdownInput);
7528
+ this.#input?.addEventListener("input", this.#boundHandleTextInput);
7529
+ this.#input?.addEventListener("change", this.#boundHandleTextChange);
7530
+ }
7763
7531
 
7764
- // Apply initial disabled state
7765
- if (this.hasAttribute("disabled")) {
7766
- this.#applyDisabled(true);
7767
- }
7768
- });
7532
+ #teardownListeners() {
7533
+ this.#dropdown?.removeEventListener("input", this.#boundHandleDropdownInput);
7534
+ this.#input?.removeEventListener("input", this.#boundHandleTextInput);
7535
+ this.#input?.removeEventListener("change", this.#boundHandleTextChange);
7769
7536
  }
7770
- disconnectedCallback() {
7771
- this.dropdown?.removeEventListener("input", this.#boundHandleSelectInput);
7537
+
7538
+ #handleDropdownInput(e) {
7539
+ e.stopPropagation();
7540
+ const val = e.target.closest("fig-dropdown")?.value ?? "";
7541
+ this.#internalUpdate = true;
7542
+ this.setAttribute("value", val);
7543
+ this.#internalUpdate = false;
7544
+ if (this.#input) this.#input.setAttribute("value", val);
7545
+ this.#emitInput();
7546
+ this.#emitChange();
7547
+ }
7548
+
7549
+ #handleTextInput(e) {
7550
+ e.stopPropagation();
7551
+ const val = e.target.value ?? "";
7552
+ this.#internalUpdate = true;
7553
+ this.setAttribute("value", val);
7554
+ this.#internalUpdate = false;
7555
+ this.#emitInput();
7772
7556
  }
7773
- handleSelectInput(e) {
7774
- this.setAttribute("value", e.target.closest("fig-dropdown").value);
7557
+
7558
+ #handleTextChange(e) {
7559
+ e.stopPropagation();
7560
+ const val = e.target.value ?? "";
7561
+ this.#internalUpdate = true;
7562
+ this.setAttribute("value", val);
7563
+ this.#internalUpdate = false;
7564
+ this.#emitChange();
7775
7565
  }
7776
- handleInput(e) {
7777
- this.value = this.input.value;
7566
+
7567
+ #emitInput() {
7568
+ this.dispatchEvent(
7569
+ new CustomEvent("input", {
7570
+ bubbles: true,
7571
+ cancelable: true,
7572
+ detail: { value: this.value },
7573
+ }),
7574
+ );
7778
7575
  }
7779
- static get observedAttributes() {
7780
- return ["options", "placeholder", "value", "disabled", "experimental"];
7576
+
7577
+ #emitChange() {
7578
+ this.dispatchEvent(
7579
+ new CustomEvent("change", {
7580
+ bubbles: true,
7581
+ cancelable: true,
7582
+ detail: { value: this.value },
7583
+ }),
7584
+ );
7781
7585
  }
7782
- focus() {
7783
- this.input.focus();
7586
+
7587
+ #getOptions() {
7588
+ return (this.getAttribute("options") || "")
7589
+ .split(",")
7590
+ .map((s) => s.trim())
7591
+ .filter(Boolean);
7784
7592
  }
7593
+
7785
7594
  #applyDisabled(disabled) {
7786
- if (this.input) {
7787
- if (disabled) {
7788
- this.input.setAttribute("disabled", "");
7789
- } else {
7790
- this.input.removeAttribute("disabled");
7791
- }
7595
+ if (this.#input) {
7596
+ if (disabled) this.#input.setAttribute("disabled", "");
7597
+ else this.#input.removeAttribute("disabled");
7792
7598
  }
7793
- const button = this.querySelector("fig-button");
7794
- if (button) {
7795
- if (disabled) {
7796
- button.setAttribute("disabled", "");
7797
- } else {
7798
- button.removeAttribute("disabled");
7799
- }
7599
+ if (this.#button) {
7600
+ if (disabled) this.#button.setAttribute("disabled", "");
7601
+ else this.#button.removeAttribute("disabled");
7800
7602
  }
7801
7603
  }
7604
+
7605
+ focus() {
7606
+ this.#input?.focus();
7607
+ }
7608
+
7802
7609
  attributeChangedCallback(name, oldValue, newValue) {
7610
+ if (oldValue === newValue) return;
7803
7611
  switch (name) {
7804
7612
  case "options":
7805
- this.options = newValue.split(",");
7806
- if (this.dropdown && !this.#usesCustomDropdown) {
7807
- this.dropdown.innerHTML = this.options
7808
- .map((option) => `<option>${option}</option>`)
7613
+ if (this.#dropdown && !this.#usesCustomDropdown) {
7614
+ const options = this.#getOptions();
7615
+ this.#dropdown.innerHTML = options
7616
+ .map((o) => `<option>${o}</option>`)
7809
7617
  .join("");
7810
7618
  }
7811
7619
  break;
7812
7620
  case "placeholder":
7813
- this.placeholder = newValue;
7814
- if (this.input) {
7815
- this.input.setAttribute("placeholder", newValue);
7816
- }
7621
+ if (this.#input) this.#input.setAttribute("placeholder", newValue || "");
7817
7622
  break;
7818
7623
  case "value":
7819
- this.value = newValue;
7820
- if (this.input) {
7821
- this.input.setAttribute("value", newValue);
7624
+ if (!this.#internalUpdate && this.#input) {
7625
+ this.#input.setAttribute("value", newValue || "");
7822
7626
  }
7823
7627
  break;
7824
7628
  case "disabled":
7825
7629
  this.#applyDisabled(newValue !== null && newValue !== "false");
7826
7630
  break;
7827
7631
  case "experimental":
7828
- if (this.dropdown) {
7829
- if (newValue) {
7830
- this.dropdown.setAttribute("experimental", newValue);
7831
- } else if (!this.#usesCustomDropdown) {
7832
- this.dropdown.removeAttribute("experimental");
7833
- }
7632
+ if (this.#dropdown) {
7633
+ if (newValue) this.#dropdown.setAttribute("experimental", newValue);
7634
+ else if (!this.#usesCustomDropdown)
7635
+ this.#dropdown.removeAttribute("experimental");
7834
7636
  }
7835
7637
  break;
7836
7638
  }
@@ -8007,57 +7809,58 @@ customElements.define("fig-chit", FigChit);
8007
7809
  class FigSwatch extends FigChit {}
8008
7810
  customElements.define("fig-swatch", FigSwatch);
8009
7811
 
8010
- /* Upload */
7812
+ /* Image */
8011
7813
  /**
8012
- * A custom image upload element.
8013
- * @attr {string} src - The current image source URL
8014
- * @attr {boolean} upload - Whether to show the upload button
8015
- * @attr {string} label - The upload button label
8016
- * @attr {string} size - Size of the image preview
7814
+ * @attr {string} src - Image source URL
7815
+ * @attr {boolean} upload - Show upload overlay (generates fig-input-file)
7816
+ * @attr {string} label - Upload button label (default "Upload")
7817
+ * @attr {string} size - small | medium | large | auto
7818
+ * @attr {string} aspect-ratio - CSS aspect-ratio or "auto" (lazy dimension sniff)
7819
+ * @attr {string} fit - CSS object-fit value
7820
+ * @attr {boolean} checkerboard - Show checkerboard behind transparent images
8017
7821
  */
8018
7822
  class FigImage extends HTMLElement {
8019
7823
  #src = null;
7824
+ #chit = null;
7825
+ #fileInput = null;
7826
+ #blobUrl = null;
7827
+ #file = null;
8020
7828
  #boundHandleFileInput = this.#handleFileInput.bind(this);
8021
- #boundHandleDownload = this.#handleDownload.bind(this);
8022
- constructor() {
8023
- super();
7829
+
7830
+ static get observedAttributes() {
7831
+ return ["src", "upload", "aspect-ratio", "fit", "checkerboard"];
8024
7832
  }
8025
- #getInnerHTML() {
8026
- const cb =
8027
- this.hasAttribute("checkerboard") &&
8028
- this.getAttribute("checkerboard") !== "false";
8029
- const bg = this.src
8030
- ? `url(${this.src})`
8031
- : cb
8032
- ? "url()"
8033
- : "var(--figma-color-bg-secondary)";
8034
- return `<fig-chit size="large" data-type="image" background="${bg}" disabled${cb ? " checkerboard" : ""}></fig-chit><div>${
8035
- this.upload
8036
- ? `<fig-button variant="overlay" type="upload">
8037
- ${this.label}
8038
- <input type="file" accept="image/*" />
8039
- </fig-button>`
8040
- : ""
8041
- } ${
8042
- this.download
8043
- ? `<fig-button variant="overlay" icon="true" type="download">
8044
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
8045
- <path d="M17.5 13C17.7761 13 18 13.2239 18 13.5V16.5C18 17.3284 17.3284 18 16.5 18H7.5C6.67157 18 6 17.3284 6 16.5V13.5C6 13.2239 6.22386 13 6.5 13C6.77614 13 7 13.2239 7 13.5V16.5C7 16.7761 7.22386 17 7.5 17H16.5C16.7761 17 17 16.7761 17 16.5V13.5C17 13.2239 17.2239 13 17.5 13ZM12 6C12.2761 6 12.5 6.22386 12.5 6.5V12.293L14.6465 10.1465C14.8417 9.95122 15.1583 9.95122 15.3535 10.1465C15.5488 10.3417 15.5488 10.6583 15.3535 10.8535L12.3535 13.8535C12.2597 13.9473 12.1326 14 12 14C11.9006 14 11.8042 13.9704 11.7227 13.916L11.6465 13.8535L8.64648 10.8535C8.45122 10.6583 8.45122 10.3417 8.64648 10.1465C8.84175 9.95122 9.15825 9.95122 9.35352 10.1465L11.5 12.293V6.5C11.5 6.22386 11.7239 6 12 6Z" fill="black"/>
8046
- </svg></fig-button>`
8047
- : ""
8048
- }</div>`;
7833
+
7834
+ get src() {
7835
+ return this.#src;
7836
+ }
7837
+ set src(value) {
7838
+ this.#src = value;
7839
+ this.setAttribute("src", value);
7840
+ }
7841
+
7842
+ get file() {
7843
+ return this.#file;
8049
7844
  }
7845
+
7846
+ async getBase64() {
7847
+ const src = this.#src;
7848
+ if (!src) return null;
7849
+ const res = await fetch(src);
7850
+ const blob = await res.blob();
7851
+ const bitmap = await createImageBitmap(blob);
7852
+ const canvas = document.createElement("canvas");
7853
+ canvas.width = bitmap.width;
7854
+ canvas.height = bitmap.height;
7855
+ canvas.getContext("2d").drawImage(bitmap, 0, 0);
7856
+ bitmap.close();
7857
+ const dataUrl = canvas.toDataURL();
7858
+ return dataUrl;
7859
+ }
7860
+
8050
7861
  connectedCallback() {
8051
7862
  this.#src = this.getAttribute("src") || "";
8052
- this.upload =
8053
- this.hasAttribute("upload") && this.getAttribute("upload") !== "false";
8054
- this.download =
8055
- this.hasAttribute("download") &&
8056
- this.getAttribute("download") !== "false";
8057
- this.label = this.getAttribute("label") || "Upload";
8058
- this.size = this.getAttribute("size") || "small";
8059
- this.innerHTML = this.#getInnerHTML();
8060
- this.#updateRefs();
7863
+
8061
7864
  const ar = this.getAttribute("aspect-ratio");
8062
7865
  if (ar && ar !== "auto") {
8063
7866
  this.style.setProperty("--aspect-ratio", ar);
@@ -8066,182 +7869,169 @@ class FigImage extends HTMLElement {
8066
7869
  if (fit) {
8067
7870
  this.style.setProperty("--fit", fit);
8068
7871
  }
7872
+
7873
+ if (!this.querySelector("fig-chit")) {
7874
+ const chit = document.createElement("fig-chit");
7875
+ chit.setAttribute("data-generated", "");
7876
+ chit.setAttribute("size", "large");
7877
+ chit.setAttribute("data-type", "image");
7878
+ chit.setAttribute("disabled", "");
7879
+ this.#applyChitBackground(chit);
7880
+ if (this.hasAttribute("checkerboard") && this.getAttribute("checkerboard") !== "false") {
7881
+ chit.setAttribute("checkerboard", "");
7882
+ }
7883
+ this.prepend(chit);
7884
+ }
7885
+ this.#chit = this.querySelector("fig-chit");
7886
+
7887
+ const isUpload = this.hasAttribute("upload") && this.getAttribute("upload") !== "false";
7888
+ if (isUpload && !this.querySelector("fig-input-file[data-generated]")) {
7889
+ this.#createFileInput();
7890
+ }
7891
+
7892
+ if (this.#src && this.getAttribute("aspect-ratio") === "auto") {
7893
+ this.#sniffDimensions(this.#src);
7894
+ }
8069
7895
  }
7896
+
8070
7897
  disconnectedCallback() {
8071
- this.fileInput?.removeEventListener("change", this.#boundHandleFileInput);
8072
- this.downloadButton?.removeEventListener(
8073
- "click",
8074
- this.#boundHandleDownload,
8075
- );
7898
+ this.#fileInput?.removeEventListener("change", this.#boundHandleFileInput);
7899
+ if (this.#blobUrl) {
7900
+ URL.revokeObjectURL(this.#blobUrl);
7901
+ this.#blobUrl = null;
7902
+ }
8076
7903
  }
8077
7904
 
8078
- #updateRefs() {
8079
- requestAnimationFrame(() => {
8080
- this.chit = this.querySelector("fig-chit");
8081
- if (this.upload) {
8082
- this.uploadButton = this.querySelector("fig-button[type='upload']");
8083
- this.fileInput = this.uploadButton?.querySelector("input");
8084
- this.fileInput?.removeEventListener(
8085
- "change",
8086
- this.#boundHandleFileInput,
8087
- );
8088
- this.fileInput?.addEventListener("change", this.#boundHandleFileInput);
8089
- }
8090
- if (this.download) {
8091
- this.downloadButton = this.querySelector("fig-button[type='download']");
8092
- this.downloadButton?.removeEventListener(
8093
- "click",
8094
- this.#boundHandleDownload,
8095
- );
8096
- this.downloadButton?.addEventListener(
8097
- "click",
8098
- this.#boundHandleDownload,
8099
- );
8100
- }
8101
- });
7905
+ #applyChitBackground(chit) {
7906
+ const cb = this.hasAttribute("checkerboard") && this.getAttribute("checkerboard") !== "false";
7907
+ if (this.#src) {
7908
+ chit.setAttribute("background", `url(${this.#src})`);
7909
+ } else {
7910
+ chit.setAttribute("background", cb ? "url()" : "var(--figma-color-bg-secondary)");
7911
+ }
8102
7912
  }
8103
- #handleDownload() {
8104
- //force blob download
8105
- const link = document.createElement("a");
8106
- link.href = this.blob;
8107
- link.download = "image.png";
8108
- link.click();
8109
- }
8110
- async #loadImage(src) {
8111
- // Get blob from canvas
8112
- await new Promise((resolve) => {
8113
- this.image = new Image();
8114
- this.image.crossOrigin = "Anonymous";
8115
- this.image.onload = async () => {
8116
- this.aspectRatio = this.image.width / this.image.height;
8117
- const ar = this.getAttribute("aspect-ratio");
8118
- if (!ar || ar === "auto") {
8119
- this.style.setProperty(
8120
- "--aspect-ratio",
8121
- `${this.image.width}/${this.image.height}`,
8122
- );
8123
- }
8124
- this.dispatchEvent(
8125
- new CustomEvent("loaded", {
8126
- bubbles: true,
8127
- cancelable: true,
8128
- detail: {
8129
- blob: this.blob,
8130
- base64: this.base64,
8131
- },
8132
- }),
8133
- );
8134
- resolve();
8135
-
8136
- // Create canvas to extract blob and base64 from image
8137
- const canvas = document.createElement("canvas");
8138
- const ctx = canvas.getContext("2d");
8139
- canvas.width = this.image.width;
8140
- canvas.height = this.image.height;
8141
- ctx.drawImage(this.image, 0, 0);
8142
-
8143
- // Get base64 from canvas
8144
- this.base64 = canvas.toDataURL();
8145
-
8146
- // Get blob from canvas
8147
- canvas.toBlob((blob) => {
8148
- if (this.blob) {
8149
- URL.revokeObjectURL(this.blob);
8150
- }
8151
- if (blob) {
8152
- this.blob = URL.createObjectURL(blob);
8153
- }
8154
- });
8155
- };
8156
- this.image.src = src;
8157
- });
7913
+
7914
+ #createFileInput() {
7915
+ const fi = document.createElement("fig-input-file");
7916
+ fi.setAttribute("data-generated", "");
7917
+ fi.setAttribute("accepts", "image/*");
7918
+ fi.setAttribute("variant", "overlay");
7919
+ const defaultLabel = this.getAttribute("label") || "Upload";
7920
+ fi.setAttribute("label", this.#src ? "Replace" : defaultLabel);
7921
+ if (this.#src) fi.setAttribute("url", this.#src);
7922
+ fi.addEventListener("change", this.#boundHandleFileInput);
7923
+ this.append(fi);
7924
+ this.#fileInput = fi;
8158
7925
  }
8159
- async #handleFileInput(e) {
8160
- if (this.blob) {
8161
- URL.revokeObjectURL(this.blob);
8162
- }
8163
- this.blob = URL.createObjectURL(e.target.files[0]);
8164
- //set base64 url
8165
- const reader = new FileReader();
8166
- reader.readAsDataURL(e.target.files[0]);
8167
- //await this data url to be set
8168
- await new Promise((resolve) => {
8169
- reader.onload = (e) => {
8170
- this.base64 = e.target.result;
8171
- resolve();
8172
- };
8173
- });
8174
- //emit event for loaded
7926
+
7927
+ #removeFileInput() {
7928
+ if (this.#fileInput) {
7929
+ this.#fileInput.removeEventListener("change", this.#boundHandleFileInput);
7930
+ this.#fileInput.remove();
7931
+ this.#fileInput = null;
7932
+ }
7933
+ }
7934
+
7935
+ #handleFileInput(e) {
7936
+ const file = e.detail?.files?.[0];
7937
+
7938
+ if (!file) {
7939
+ if (this.#blobUrl) {
7940
+ URL.revokeObjectURL(this.#blobUrl);
7941
+ this.#blobUrl = null;
7942
+ }
7943
+ this.#file = null;
7944
+ this.removeAttribute("src");
7945
+ this.dispatchEvent(
7946
+ new CustomEvent("change", { bubbles: true, cancelable: true }),
7947
+ );
7948
+ return;
7949
+ }
7950
+
7951
+ if (this.#blobUrl) {
7952
+ URL.revokeObjectURL(this.#blobUrl);
7953
+ }
7954
+ this.#file = file;
7955
+ this.#blobUrl = URL.createObjectURL(file);
7956
+
7957
+ this.setAttribute("src", this.#blobUrl);
7958
+
8175
7959
  this.dispatchEvent(
8176
7960
  new CustomEvent("loaded", {
8177
7961
  bubbles: true,
8178
7962
  cancelable: true,
8179
- detail: {
8180
- blob: this.blob,
8181
- base64: this.base64,
8182
- },
7963
+ detail: { file, src: this.#blobUrl },
8183
7964
  }),
8184
7965
  );
8185
- //emit for change too
8186
7966
  this.dispatchEvent(
8187
- new CustomEvent("change", {
8188
- bubbles: true,
8189
- cancelable: true,
8190
- }),
7967
+ new CustomEvent("change", { bubbles: true, cancelable: true }),
8191
7968
  );
8192
- this.setAttribute("src", this.blob);
8193
- }
8194
- static get observedAttributes() {
8195
- return ["src", "upload", "download", "aspect-ratio", "fit", "checkerboard"];
8196
- }
8197
- get src() {
8198
- return this.#src;
7969
+
7970
+ if (this.#fileInput) {
7971
+ this.#fileInput.clear();
7972
+ this.#fileInput.setAttribute("label", "Replace");
7973
+ }
8199
7974
  }
8200
- set src(value) {
8201
- this.#src = value;
8202
- this.setAttribute("src", value);
7975
+
7976
+ async #sniffDimensions(src) {
7977
+ try {
7978
+ let blob;
7979
+ if (src.startsWith("blob:")) {
7980
+ const res = await fetch(src);
7981
+ blob = await res.blob();
7982
+ } else {
7983
+ const res = await fetch(src, { mode: "cors" });
7984
+ blob = await res.blob();
7985
+ }
7986
+ const bitmap = await createImageBitmap(blob);
7987
+ this.style.setProperty("--aspect-ratio", `${bitmap.width}/${bitmap.height}`);
7988
+ bitmap.close();
7989
+ } catch {
7990
+ // Non-critical — CSS aspect-ratio fallback handles it
7991
+ }
8203
7992
  }
8204
7993
 
8205
7994
  attributeChangedCallback(name, oldValue, newValue) {
7995
+ if (oldValue === newValue) return;
7996
+
8206
7997
  if (name === "src") {
8207
7998
  this.#src = newValue;
8208
- if (this.chit) {
8209
- const hasCb =
8210
- this.hasAttribute("checkerboard") &&
8211
- this.getAttribute("checkerboard") !== "false";
7999
+ if (this.#chit) {
8000
+ this.#applyChitBackground(this.#chit);
8001
+ }
8002
+ if (this.#fileInput) {
8003
+ const defaultLabel = this.getAttribute("label") || "Upload";
8004
+ this.#fileInput.setAttribute("label", this.#src ? "Replace" : defaultLabel);
8212
8005
  if (this.#src) {
8213
- this.chit.setAttribute("background", `url(${this.#src})`);
8006
+ this.#fileInput.setAttribute("url", this.#src);
8214
8007
  } else {
8215
- this.chit.setAttribute(
8216
- "background",
8217
- hasCb ? "url()" : "var(--figma-color-bg-secondary)",
8218
- );
8008
+ this.#fileInput.removeAttribute("url");
8219
8009
  }
8220
8010
  }
8221
- if (this.#src) {
8222
- this.#loadImage(this.#src);
8011
+ if (this.#src && this.getAttribute("aspect-ratio") === "auto") {
8012
+ this.#sniffDimensions(this.#src);
8223
8013
  }
8224
8014
  }
8015
+
8225
8016
  if (name === "upload") {
8226
- this.upload = newValue !== null && newValue !== "false";
8227
- this.innerHTML = this.#getInnerHTML();
8228
- this.#updateRefs();
8229
- }
8230
- if (name === "download") {
8231
- this.download = newValue !== null && newValue !== "false";
8232
- this.innerHTML = this.#getInnerHTML();
8233
- this.#updateRefs();
8234
- }
8235
- if (name === "size") {
8236
- this.size = newValue;
8017
+ const on = newValue !== null && newValue !== "false";
8018
+ if (on && !this.#fileInput) {
8019
+ this.#createFileInput();
8020
+ } else if (!on) {
8021
+ this.#removeFileInput();
8022
+ }
8237
8023
  }
8024
+
8238
8025
  if (name === "aspect-ratio") {
8239
8026
  if (newValue && newValue !== "auto") {
8240
8027
  this.style.setProperty("--aspect-ratio", newValue);
8241
8028
  } else if (!newValue) {
8242
8029
  this.style.removeProperty("--aspect-ratio");
8030
+ } else if (newValue === "auto" && this.#src) {
8031
+ this.#sniffDimensions(this.#src);
8243
8032
  }
8244
8033
  }
8034
+
8245
8035
  if (name === "fit") {
8246
8036
  if (newValue) {
8247
8037
  this.style.setProperty("--fit", newValue);
@@ -8249,12 +8039,13 @@ class FigImage extends HTMLElement {
8249
8039
  this.style.removeProperty("--fit");
8250
8040
  }
8251
8041
  }
8042
+
8252
8043
  if (name === "checkerboard") {
8253
- if (this.chit) {
8044
+ if (this.#chit) {
8254
8045
  if (newValue !== null && newValue !== "false") {
8255
- this.chit.setAttribute("checkerboard", "");
8046
+ this.#chit.setAttribute("checkerboard", "");
8256
8047
  } else {
8257
- this.chit.removeAttribute("checkerboard");
8048
+ this.#chit.removeAttribute("checkerboard");
8258
8049
  }
8259
8050
  }
8260
8051
  }
@@ -8264,7 +8055,7 @@ customElements.define("fig-image", FigImage);
8264
8055
 
8265
8056
  /* File Upload Input */
8266
8057
  class FigInputFile extends HTMLElement {
8267
- static observedAttributes = ["accepts", "label", "disabled", "multiple"];
8058
+ static observedAttributes = ["accepts", "label", "disabled", "multiple", "variant", "url"];
8268
8059
 
8269
8060
  #fileInput = null;
8270
8061
  #filenameEl = null;
@@ -8277,10 +8068,24 @@ class FigInputFile extends HTMLElement {
8277
8068
  return this.#files;
8278
8069
  }
8279
8070
 
8071
+ get #urlFilename() {
8072
+ const url = this.getAttribute("url");
8073
+ if (!url) return "";
8074
+ try {
8075
+ const path = new URL(url, location.href).pathname;
8076
+ const name = path.split("/").pop();
8077
+ return name ? decodeURIComponent(name) : url;
8078
+ } catch {
8079
+ return url;
8080
+ }
8081
+ }
8082
+
8280
8083
  get value() {
8281
- if (!this.#files || this.#files.length === 0) return "";
8282
- if (this.#files.length === 1) return this.#files[0].name;
8283
- return `${this.#files.length} files`;
8084
+ if (this.#files && this.#files.length > 0) {
8085
+ if (this.#files.length === 1) return this.#files[0].name;
8086
+ return `${this.#files.length} files`;
8087
+ }
8088
+ return this.#urlFilename;
8284
8089
  }
8285
8090
 
8286
8091
  connectedCallback() {
@@ -8306,6 +8111,7 @@ class FigInputFile extends HTMLElement {
8306
8111
  clear() {
8307
8112
  this.#files = null;
8308
8113
  if (this.#fileInput) this.#fileInput.value = "";
8114
+ this.removeAttribute("url");
8309
8115
  this.#render();
8310
8116
  this.#emitEvents();
8311
8117
  }
@@ -8319,6 +8125,7 @@ class FigInputFile extends HTMLElement {
8319
8125
  #onFileChange = () => {
8320
8126
  if (this.#fileInput.files.length > 0) {
8321
8127
  this.#files = this.#fileInput.files;
8128
+ this.removeAttribute("url");
8322
8129
  this.#render();
8323
8130
  this.#emitEvents();
8324
8131
  }
@@ -8331,16 +8138,30 @@ class FigInputFile extends HTMLElement {
8331
8138
 
8332
8139
  #onDragOver = (e) => {
8333
8140
  e.preventDefault();
8334
- this.setAttribute("dragover", "");
8141
+ if (!this.hasAttribute("dragover")) {
8142
+ this.setAttribute("dragover", "");
8143
+ if (this.#uploadBtn) {
8144
+ this.#uploadBtn.dataset.prevText = this.#uploadBtn.textContent;
8145
+ this.#uploadBtn.textContent = "Drop file";
8146
+ }
8147
+ }
8335
8148
  };
8336
8149
 
8337
8150
  #onDragLeave = () => {
8338
8151
  this.removeAttribute("dragover");
8152
+ if (this.#uploadBtn && this.#uploadBtn.dataset.prevText !== undefined) {
8153
+ this.#uploadBtn.textContent = this.#uploadBtn.dataset.prevText;
8154
+ delete this.#uploadBtn.dataset.prevText;
8155
+ }
8339
8156
  };
8340
8157
 
8341
8158
  #onDrop = (e) => {
8342
8159
  e.preventDefault();
8343
8160
  this.removeAttribute("dragover");
8161
+ if (this.#uploadBtn && this.#uploadBtn.dataset.prevText !== undefined) {
8162
+ this.#uploadBtn.textContent = this.#uploadBtn.dataset.prevText;
8163
+ delete this.#uploadBtn.dataset.prevText;
8164
+ }
8344
8165
  if (
8345
8166
  this.hasAttribute("disabled") &&
8346
8167
  this.getAttribute("disabled") !== "false"
@@ -8373,6 +8194,7 @@ class FigInputFile extends HTMLElement {
8373
8194
  if (this.#fileInput) {
8374
8195
  this.#fileInput.files = dt.files;
8375
8196
  }
8197
+ this.removeAttribute("url");
8376
8198
  this.#render();
8377
8199
  this.#emitEvents();
8378
8200
  };
@@ -8384,7 +8206,8 @@ class FigInputFile extends HTMLElement {
8384
8206
  this.hasAttribute("disabled") &&
8385
8207
  this.getAttribute("disabled") !== "false";
8386
8208
  const multiple = this.hasAttribute("multiple");
8387
- const hasFile = this.#files && this.#files.length > 0;
8209
+ const variant = this.getAttribute("variant") || "input";
8210
+ const hasFile = (this.#files && this.#files.length > 0) || !!this.getAttribute("url");
8388
8211
 
8389
8212
  this.innerHTML = "";
8390
8213
 
@@ -8397,7 +8220,7 @@ class FigInputFile extends HTMLElement {
8397
8220
  : "";
8398
8221
 
8399
8222
  this.#uploadBtn = document.createElement("fig-button");
8400
- this.#uploadBtn.setAttribute("variant", "input");
8223
+ this.#uploadBtn.setAttribute("variant", variant);
8401
8224
  this.#uploadBtn.setAttribute("type", "upload");
8402
8225
  this.#uploadBtn.className = "fig-input-file-filename";
8403
8226
  if (disabled) this.#uploadBtn.setAttribute("disabled", "");
@@ -8452,7 +8275,7 @@ class FigInputFile extends HTMLElement {
8452
8275
  }
8453
8276
 
8454
8277
  this.#uploadBtn = document.createElement("fig-button");
8455
- this.#uploadBtn.setAttribute("variant", "input");
8278
+ this.#uploadBtn.setAttribute("variant", variant);
8456
8279
  this.#uploadBtn.setAttribute("type", "upload");
8457
8280
  this.#uploadBtn.textContent = label;
8458
8281
  if (disabled) this.#uploadBtn.setAttribute("disabled", "");
@@ -10561,628 +10384,183 @@ class FigInputJoystick extends HTMLElement {
10561
10384
 
10562
10385
  customElements.define("fig-joystick", FigInputJoystick);
10563
10386
 
10564
- /**
10565
- * A custom angle chooser input element.
10566
- * @attr {number} value - The current angle of the handle in degrees.
10567
- * @attr {number} precision - The number of decimal places for the output.
10568
- * @attr {boolean} text - Whether to display a text input for the angle value.
10569
- * @attr {boolean} dial - Whether to display the circular dial control. Defaults to true.
10570
- * @attr {number} adjacent - The adjacent value of the angle.
10571
- * @attr {number} opposite - The opposite value of the angle.
10572
- * @attr {boolean} rotations - Whether to display a rotation count (×N) when rotations > 1. Defaults to false.
10573
- */
10574
- class FigInputAngle extends HTMLElement {
10575
- // Private fields
10576
- #adjacent;
10577
- #opposite;
10578
- #prevRawAngle = null;
10579
- #boundHandleRawChange;
10580
- #boundHandleMouseDown;
10581
- #boundHandleTouchStart;
10582
- #boundHandleKeyDown;
10583
- #boundHandleKeyUp;
10584
- #boundHandleAngleInput;
10585
-
10586
- constructor() {
10587
- super();
10588
-
10589
- this.angle = 0;
10590
- this.#adjacent = 1;
10591
- this.#opposite = 0;
10592
- this.isDragging = false;
10593
- this.isShiftHeld = false;
10594
- this.handle = null;
10595
- this.angleInput = null;
10596
- this.plane = null;
10597
- this.units = "°";
10598
- this.min = null;
10599
- this.max = null;
10600
- this.dial = true;
10601
- this.showRotations = false;
10602
- this.rotationSpan = null;
10603
-
10604
- this.#boundHandleRawChange = this.#handleRawChange.bind(this);
10605
- this.#boundHandleMouseDown = this.#handleMouseDown.bind(this);
10606
- this.#boundHandleTouchStart = this.#handleTouchStart.bind(this);
10607
- this.#boundHandleKeyDown = this.#handleKeyDown.bind(this);
10608
- this.#boundHandleKeyUp = this.#handleKeyUp.bind(this);
10609
- this.#boundHandleAngleInput = this.#handleAngleInput.bind(this);
10610
- }
10611
10387
 
10388
+ // FigInputAngle moved to fig-lab.js
10389
+ // FigShimmer
10390
+ class FigShimmer extends HTMLElement {
10612
10391
  connectedCallback() {
10613
- requestAnimationFrame(() => {
10614
- this.precision = this.getAttribute("precision") || 1;
10615
- this.precision = parseInt(this.precision);
10616
- this.text = this.getAttribute("text") === "true";
10617
-
10618
- let rawUnits = this.getAttribute("units") || "°";
10619
- if (rawUnits === "deg") rawUnits = "°";
10620
- this.units = rawUnits;
10621
-
10622
- this.min = this.hasAttribute("min")
10623
- ? Number(this.getAttribute("min"))
10624
- : null;
10625
- this.max = this.hasAttribute("max")
10626
- ? Number(this.getAttribute("max"))
10627
- : null;
10628
- this.dial = this.#readBooleanAttribute("dial", true);
10629
- this.showRotations = this.#readRotationsEnabled();
10630
-
10631
- this.#render();
10632
- this.#setupListeners();
10633
-
10634
- this.#syncHandlePosition();
10635
- if (this.text && this.angleInput) {
10636
- this.angleInput.setAttribute(
10637
- "value",
10638
- this.angle.toFixed(this.precision),
10639
- );
10640
- }
10641
- });
10642
- }
10643
-
10644
- disconnectedCallback() {
10645
- this.#cleanupListeners();
10646
- }
10647
-
10648
- #render() {
10649
- this.innerHTML = this.#getInnerHTML();
10650
- }
10651
-
10652
- #readBooleanAttribute(name, defaultValue = false) {
10653
- const value = this.getAttribute(name);
10654
- if (value === null) return defaultValue;
10655
- const normalized = value.trim().toLowerCase();
10656
- if (normalized === "" || normalized === "true") return true;
10657
- if (normalized === "false") return false;
10658
- return true;
10659
- }
10660
-
10661
- #readRotationsEnabled() {
10662
- if (this.hasAttribute("rotations")) {
10663
- return this.#readBooleanAttribute("rotations", false);
10664
- }
10665
- // Backward-compat alias
10666
- if (this.hasAttribute("show-rotations")) {
10667
- return this.#readBooleanAttribute("show-rotations", false);
10392
+ const duration = this.getAttribute("duration");
10393
+ if (duration) {
10394
+ this.style.setProperty("--shimmer-duration", duration);
10668
10395
  }
10669
- return false;
10670
10396
  }
10671
10397
 
10672
- #getInnerHTML() {
10673
- const step = this.#getStepForUnit();
10674
- const minAttr = this.min !== null ? `min="${this.min}"` : "";
10675
- const maxAttr = this.max !== null ? `max="${this.max}"` : "";
10676
- return `
10677
- ${
10678
- this.dial
10679
- ? `<div class="fig-input-angle-plane" tabindex="0">
10680
- <div class="fig-input-angle-handle"></div>
10681
- </div>`
10682
- : ""
10683
- }
10684
- ${
10685
- this.text
10686
- ? `<fig-input-number
10687
- name="angle"
10688
- step="${step}"
10689
- value="${this.angle}"
10690
- ${minAttr}
10691
- ${maxAttr}
10692
- units="${this.units}">
10693
- ${this.showRotations ? `<span slot="append" class="fig-input-angle-rotations"></span>` : ""}
10694
- </fig-input-number>`
10695
- : ""
10696
- }
10697
- `;
10398
+ static get observedAttributes() {
10399
+ return ["duration", "playing"];
10698
10400
  }
10699
10401
 
10700
- #getRotationCount() {
10701
- const degrees = Math.abs(this.#toDegrees(this.angle));
10702
- return Math.floor(degrees / 360);
10402
+ get playing() {
10403
+ return this.getAttribute("playing") !== "false";
10703
10404
  }
10704
10405
 
10705
- #updateRotationDisplay() {
10706
- if (!this.rotationSpan) return;
10707
- const rotations = this.#getRotationCount();
10708
- if (rotations > 1) {
10709
- this.rotationSpan.textContent = `\u00d7${rotations}`;
10710
- this.rotationSpan.style.display = "";
10406
+ set playing(value) {
10407
+ if (value) {
10408
+ this.removeAttribute("playing"); // Default is playing
10711
10409
  } else {
10712
- this.rotationSpan.textContent = "";
10713
- this.rotationSpan.style.display = "none";
10410
+ this.setAttribute("playing", "false");
10714
10411
  }
10715
10412
  }
10716
10413
 
10717
- #getStepForUnit() {
10718
- switch (this.units) {
10719
- case "rad":
10720
- return 0.01;
10721
- case "turn":
10722
- return 0.001;
10723
- default:
10724
- return 0.1;
10414
+ attributeChangedCallback(name, oldValue, newValue) {
10415
+ if (name === "duration") {
10416
+ this.style.setProperty("--shimmer-duration", newValue || "1.5s");
10725
10417
  }
10418
+ // playing is handled purely by CSS attribute selectors
10726
10419
  }
10420
+ }
10421
+ customElements.define("fig-shimmer", FigShimmer);
10727
10422
 
10728
- // --- Unit conversion helpers ---
10423
+ // FigSkeleton
10424
+ class FigSkeleton extends FigShimmer {}
10425
+ customElements.define("fig-skeleton", FigSkeleton);
10729
10426
 
10730
- #toDegrees(value) {
10731
- switch (this.units) {
10732
- case "rad":
10733
- return (value * 180) / Math.PI;
10734
- case "turn":
10735
- return value * 360;
10736
- default:
10737
- return value;
10738
- }
10427
+ // FigLayer
10428
+ class FigLayer extends HTMLElement {
10429
+ static get observedAttributes() {
10430
+ return ["open", "visible"];
10739
10431
  }
10740
10432
 
10741
- #fromDegrees(degrees) {
10742
- switch (this.units) {
10743
- case "rad":
10744
- return (degrees * Math.PI) / 180;
10745
- case "turn":
10746
- return degrees / 360;
10747
- default:
10748
- return degrees;
10749
- }
10433
+ #chevron = null;
10434
+ #boundHandleChevronClick = null;
10435
+
10436
+ connectedCallback() {
10437
+ // Use requestAnimationFrame to ensure child elements have rendered
10438
+ requestAnimationFrame(() => {
10439
+ this.#injectChevron();
10440
+ });
10750
10441
  }
10751
10442
 
10752
- #convertAngle(value, fromUnit, toUnit) {
10753
- // Convert to degrees first
10754
- let degrees;
10755
- switch (fromUnit) {
10756
- case "rad":
10757
- degrees = (value * 180) / Math.PI;
10758
- break;
10759
- case "turn":
10760
- degrees = value * 360;
10761
- break;
10762
- default:
10763
- degrees = value;
10764
- }
10765
- // Convert from degrees to target
10766
- switch (toUnit) {
10767
- case "rad":
10768
- return (degrees * Math.PI) / 180;
10769
- case "turn":
10770
- return degrees / 360;
10771
- default:
10772
- return degrees;
10443
+ disconnectedCallback() {
10444
+ if (this.#chevron && this.#boundHandleChevronClick) {
10445
+ this.#chevron.removeEventListener("click", this.#boundHandleChevronClick);
10773
10446
  }
10774
10447
  }
10775
10448
 
10776
- // --- Event listeners ---
10449
+ #injectChevron() {
10450
+ const row = this.querySelector(":scope > .fig-layer-row");
10451
+ if (!row) return;
10777
10452
 
10778
- #setupListeners() {
10779
- this.handle = this.querySelector(".fig-input-angle-handle");
10780
- this.plane = this.querySelector(".fig-input-angle-plane");
10781
- this.angleInput = this.querySelector("fig-input-number[name='angle']");
10782
- this.rotationSpan = this.querySelector(".fig-input-angle-rotations");
10783
- this.#updateRotationDisplay();
10784
- this.plane?.addEventListener("mousedown", this.#boundHandleMouseDown);
10785
- this.plane?.addEventListener("touchstart", this.#boundHandleTouchStart);
10786
- window.addEventListener("keydown", this.#boundHandleKeyDown);
10787
- window.addEventListener("keyup", this.#boundHandleKeyUp);
10788
- if (this.text && this.angleInput) {
10789
- this.angleInput.addEventListener("input", this.#boundHandleAngleInput);
10790
- }
10791
- this.addEventListener("change", this.#boundHandleRawChange, true);
10792
- }
10453
+ // Check if chevron already exists
10454
+ if (row.querySelector(".fig-layer-chevron")) return;
10793
10455
 
10794
- #cleanupListeners() {
10795
- this.plane?.removeEventListener("mousedown", this.#boundHandleMouseDown);
10796
- this.plane?.removeEventListener("touchstart", this.#boundHandleTouchStart);
10797
- window.removeEventListener("keydown", this.#boundHandleKeyDown);
10798
- window.removeEventListener("keyup", this.#boundHandleKeyUp);
10799
- if (this.text && this.angleInput) {
10800
- this.angleInput.removeEventListener("input", this.#boundHandleAngleInput);
10801
- }
10802
- this.removeEventListener("change", this.#boundHandleRawChange, true);
10803
- }
10456
+ // Always create chevron element - CSS handles visibility via :has(fig-layer)
10457
+ this.#chevron = document.createElement("span");
10458
+ this.#chevron.className = "fig-layer-chevron";
10459
+ row.prepend(this.#chevron);
10804
10460
 
10805
- #handleRawChange(e) {
10806
- // Only intercept native change events from the raw <input> element
10807
- if (!e.target?.matches?.("input")) return;
10808
- const raw = e.target.value;
10809
- const match = raw.match(/^(-?\d*\.?\d+)\s*(turn|rad|deg|°)$/i);
10810
- if (match) {
10811
- const num = parseFloat(match[1]);
10812
- let fromUnit = match[2].toLowerCase();
10813
- if (fromUnit === "deg") fromUnit = "°";
10814
- if (fromUnit !== this.units) {
10815
- const converted = this.#convertAngle(num, fromUnit, this.units);
10816
- e.target.value = String(converted);
10817
- }
10818
- }
10461
+ // Add click listener to chevron only
10462
+ this.#boundHandleChevronClick = this.#handleChevronClick.bind(this);
10463
+ this.#chevron.addEventListener("click", this.#boundHandleChevronClick);
10819
10464
  }
10820
10465
 
10821
- #handleAngleInput(e) {
10466
+ #handleChevronClick(e) {
10822
10467
  e.stopPropagation();
10823
- this.angle = Number(e.target.value);
10824
- this.#calculateAdjacentAndOpposite();
10825
- this.#syncHandlePosition();
10826
- this.#updateRotationDisplay();
10827
- this.#emitInputEvent();
10828
- this.#emitChangeEvent();
10468
+ this.open = !this.open;
10829
10469
  }
10830
10470
 
10831
- // --- Angle calculation ---
10832
-
10833
- #calculateAdjacentAndOpposite() {
10834
- const degrees = this.#toDegrees(this.angle);
10835
- const radians = (degrees * Math.PI) / 180;
10836
- this.#adjacent = Math.cos(radians);
10837
- this.#opposite = Math.sin(radians);
10471
+ get open() {
10472
+ const attr = this.getAttribute("open");
10473
+ return attr !== null && attr !== "false";
10838
10474
  }
10839
10475
 
10840
- #snapToIncrement(angle) {
10841
- if (!this.isShiftHeld) return angle;
10842
- const increment = 45;
10843
- return Math.round(angle / increment) * increment;
10476
+ set open(value) {
10477
+ const oldValue = this.open;
10478
+ if (value) {
10479
+ this.setAttribute("open", "true");
10480
+ } else {
10481
+ this.setAttribute("open", "false");
10482
+ }
10483
+ if (oldValue !== value) {
10484
+ this.dispatchEvent(
10485
+ new CustomEvent("openchange", {
10486
+ detail: { open: value },
10487
+ bubbles: true,
10488
+ }),
10489
+ );
10490
+ }
10844
10491
  }
10845
10492
 
10846
- #getRawAngle(e) {
10847
- const rect = this.plane.getBoundingClientRect();
10848
- const centerX = rect.left + rect.width / 2;
10849
- const centerY = rect.top + rect.height / 2;
10850
- const deltaX = e.clientX - centerX;
10851
- const deltaY = e.clientY - centerY;
10852
- return (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
10493
+ get visible() {
10494
+ const attr = this.getAttribute("visible");
10495
+ return attr !== "false";
10853
10496
  }
10854
10497
 
10855
- #updateAngle(e) {
10856
- let rawAngle = this.#getRawAngle(e);
10857
- // Normalize to 0-360 for snap and positioning
10858
- let normalizedAngle = ((rawAngle % 360) + 360) % 360;
10859
- normalizedAngle = this.#snapToIncrement(normalizedAngle);
10860
-
10861
- const isBounded = this.min !== null || this.max !== null;
10862
-
10863
- if (isBounded) {
10864
- // Bounded: absolute position
10865
- this.angle = this.#fromDegrees(normalizedAngle);
10498
+ set visible(value) {
10499
+ const oldValue = this.visible;
10500
+ if (value) {
10501
+ this.setAttribute("visible", "true");
10866
10502
  } else {
10867
- // Unbounded: cumulative winding
10868
- if (this.#prevRawAngle === null) {
10869
- // First event of this drag — snap to clicked position, preserving revolution
10870
- this.#prevRawAngle = normalizedAngle;
10871
- const currentDeg = this.#toDegrees(this.angle);
10872
- const currentMod = ((currentDeg % 360) + 360) % 360;
10873
- let delta = normalizedAngle - currentMod;
10874
- if (delta > 180) delta -= 360;
10875
- if (delta < -180) delta += 360;
10876
- this.angle += this.#fromDegrees(delta);
10877
- } else {
10878
- // Subsequent events — accumulate delta
10879
- let delta = normalizedAngle - this.#prevRawAngle;
10880
- if (delta > 180) delta -= 360;
10881
- if (delta < -180) delta += 360;
10882
- this.angle += this.#fromDegrees(delta);
10883
- this.#prevRawAngle = normalizedAngle;
10884
- }
10503
+ this.setAttribute("visible", "false");
10504
+ }
10505
+ if (oldValue !== value) {
10506
+ this.dispatchEvent(
10507
+ new CustomEvent("visibilitychange", {
10508
+ detail: { visible: value },
10509
+ bubbles: true,
10510
+ }),
10511
+ );
10885
10512
  }
10513
+ }
10886
10514
 
10887
- this.#calculateAdjacentAndOpposite();
10515
+ attributeChangedCallback(name, oldValue, newValue) {
10516
+ if (oldValue === newValue) return;
10888
10517
 
10889
- this.#syncHandlePosition();
10890
- if (this.text && this.angleInput) {
10891
- this.angleInput.setAttribute("value", this.angle.toFixed(this.precision));
10518
+ if (name === "open") {
10519
+ const isOpen = newValue !== null && newValue !== "false";
10520
+ this.dispatchEvent(
10521
+ new CustomEvent("openchange", {
10522
+ detail: { open: isOpen },
10523
+ bubbles: true,
10524
+ }),
10525
+ );
10892
10526
  }
10893
- this.#updateRotationDisplay();
10894
10527
 
10895
- this.#emitInputEvent();
10528
+ if (name === "visible") {
10529
+ const isVisible = newValue !== "false";
10530
+ this.dispatchEvent(
10531
+ new CustomEvent("visibilitychange", {
10532
+ detail: { visible: isVisible },
10533
+ bubbles: true,
10534
+ }),
10535
+ );
10536
+ }
10896
10537
  }
10538
+ }
10539
+ customElements.define("fig-layer", FigLayer);
10897
10540
 
10898
- // --- Event dispatching ---
10899
-
10900
- #emitInputEvent() {
10901
- this.dispatchEvent(
10902
- new CustomEvent("input", {
10903
- bubbles: true,
10904
- cancelable: true,
10905
- detail: { value: this.value, angle: this.angle },
10906
- }),
10907
- );
10908
- }
10909
-
10910
- #emitChangeEvent() {
10911
- this.dispatchEvent(
10912
- new CustomEvent("change", {
10913
- bubbles: true,
10914
- cancelable: true,
10915
- detail: { value: this.value, angle: this.angle },
10916
- }),
10917
- );
10918
- }
10919
-
10920
- // --- Handle position ---
10921
-
10922
- #syncHandlePosition() {
10923
- if (this.handle) {
10924
- const degrees = this.#toDegrees(this.angle);
10925
- const radians = (degrees * Math.PI) / 180;
10926
- const radius = this.plane.offsetWidth / 2 - this.handle.offsetWidth / 2;
10927
- const x = Math.cos(radians) * radius;
10928
- const y = Math.sin(radians) * radius;
10929
- this.handle.style.transform = `translate(${x}px, ${y}px)`;
10930
- }
10931
- }
10932
-
10933
- // --- Mouse/Touch handlers ---
10934
-
10935
- #handleMouseDown(e) {
10936
- this.isDragging = true;
10937
- this.#prevRawAngle = null;
10938
- this.#updateAngle(e);
10939
-
10940
- const handleMouseMove = (e) => {
10941
- this.plane.classList.add("dragging");
10942
- if (this.isDragging) this.#updateAngle(e);
10943
- };
10944
-
10945
- const handleMouseUp = () => {
10946
- this.isDragging = false;
10947
- this.#prevRawAngle = null;
10948
- this.plane.classList.remove("dragging");
10949
- window.removeEventListener("mousemove", handleMouseMove);
10950
- window.removeEventListener("mouseup", handleMouseUp);
10951
- this.#emitChangeEvent();
10952
- };
10953
-
10954
- window.addEventListener("mousemove", handleMouseMove);
10955
- window.addEventListener("mouseup", handleMouseUp);
10956
- }
10957
-
10958
- #handleTouchStart(e) {
10959
- e.preventDefault();
10960
- this.isDragging = true;
10961
- this.#prevRawAngle = null;
10962
- this.#updateAngle(e.touches[0]);
10963
-
10964
- const handleTouchMove = (e) => {
10965
- this.plane.classList.add("dragging");
10966
- if (this.isDragging) this.#updateAngle(e.touches[0]);
10967
- };
10968
-
10969
- const handleTouchEnd = () => {
10970
- this.isDragging = false;
10971
- this.#prevRawAngle = null;
10972
- this.plane.classList.remove("dragging");
10973
- window.removeEventListener("touchmove", handleTouchMove);
10974
- window.removeEventListener("touchend", handleTouchEnd);
10975
- this.#emitChangeEvent();
10976
- };
10977
-
10978
- window.addEventListener("touchmove", handleTouchMove);
10979
- window.addEventListener("touchend", handleTouchEnd);
10980
- }
10981
-
10982
- // --- Keyboard handlers ---
10983
-
10984
- #handleKeyDown(e) {
10985
- if (e.key === "Shift") this.isShiftHeld = true;
10986
- }
10987
-
10988
- #handleKeyUp(e) {
10989
- if (e.key === "Shift") this.isShiftHeld = false;
10990
- }
10991
-
10992
- focus() {
10993
- this.plane?.focus();
10994
- }
10995
-
10996
- // --- Attributes ---
10997
-
10998
- static get observedAttributes() {
10999
- return [
11000
- "value",
11001
- "precision",
11002
- "text",
11003
- "min",
11004
- "max",
11005
- "units",
11006
- "dial",
11007
- "rotations",
11008
- "show-rotations",
11009
- ];
11010
- }
11011
-
11012
- get value() {
11013
- return this.angle;
11014
- }
11015
-
11016
- get adjacent() {
11017
- return this.#adjacent;
11018
- }
11019
-
11020
- get opposite() {
11021
- return this.#opposite;
11022
- }
11023
-
11024
- set value(value) {
11025
- if (isNaN(value)) {
11026
- console.error("Invalid value: must be a number.");
11027
- return;
11028
- }
11029
- this.angle = value;
11030
- this.#calculateAdjacentAndOpposite();
11031
- this.#syncHandlePosition();
11032
- if (this.angleInput) {
11033
- this.angleInput.setAttribute("value", this.angle.toFixed(this.precision));
11034
- }
11035
- this.#updateRotationDisplay();
11036
- }
11037
-
11038
- attributeChangedCallback(name, oldValue, newValue) {
11039
- switch (name) {
11040
- case "value":
11041
- if (this.isDragging) break;
11042
- this.value = Number(newValue);
11043
- break;
11044
- case "precision":
11045
- this.precision = parseInt(newValue);
11046
- break;
11047
- case "text":
11048
- if (newValue !== oldValue) {
11049
- this.text = newValue?.toLowerCase() === "true";
11050
- if (this.isConnected) {
11051
- this.#render();
11052
- this.#setupListeners();
11053
- this.#syncHandlePosition();
11054
- }
11055
- }
11056
- break;
11057
- case "dial":
11058
- this.dial = this.#readBooleanAttribute("dial", true);
11059
- if (this.isConnected) {
11060
- this.#render();
11061
- this.#setupListeners();
11062
- this.#syncHandlePosition();
11063
- }
11064
- break;
11065
- case "units": {
11066
- let units = newValue || "°";
11067
- if (units === "deg") units = "°";
11068
- this.units = units;
11069
- if (this.isConnected) {
11070
- this.#render();
11071
- this.#setupListeners();
11072
- this.#syncHandlePosition();
11073
- }
11074
- break;
11075
- }
11076
- case "min":
11077
- this.min = newValue !== null ? Number(newValue) : null;
11078
- if (this.isConnected) {
11079
- this.#render();
11080
- this.#setupListeners();
11081
- this.#syncHandlePosition();
11082
- }
11083
- break;
11084
- case "max":
11085
- this.max = newValue !== null ? Number(newValue) : null;
11086
- if (this.isConnected) {
11087
- this.#render();
11088
- this.#setupListeners();
11089
- this.#syncHandlePosition();
11090
- }
11091
- break;
11092
- case "rotations":
11093
- case "show-rotations":
11094
- this.showRotations = this.#readRotationsEnabled();
11095
- if (this.isConnected) {
11096
- this.#render();
11097
- this.#setupListeners();
11098
- this.#syncHandlePosition();
11099
- }
11100
- break;
11101
- }
11102
- }
11103
- }
11104
- customElements.define("fig-input-angle", FigInputAngle);
11105
-
11106
- // FigShimmer
11107
- class FigShimmer extends HTMLElement {
11108
- connectedCallback() {
11109
- const duration = this.getAttribute("duration");
11110
- if (duration) {
11111
- this.style.setProperty("--shimmer-duration", duration);
11112
- }
11113
- }
11114
-
11115
- static get observedAttributes() {
11116
- return ["duration", "playing"];
11117
- }
11118
-
11119
- get playing() {
11120
- return this.getAttribute("playing") !== "false";
11121
- }
11122
-
11123
- set playing(value) {
11124
- if (value) {
11125
- this.removeAttribute("playing"); // Default is playing
11126
- } else {
11127
- this.setAttribute("playing", "false");
11128
- }
11129
- }
11130
-
11131
- attributeChangedCallback(name, oldValue, newValue) {
11132
- if (name === "duration") {
11133
- this.style.setProperty("--shimmer-duration", newValue || "1.5s");
11134
- }
11135
- // playing is handled purely by CSS attribute selectors
11136
- }
11137
- }
11138
- customElements.define("fig-shimmer", FigShimmer);
11139
-
11140
- // FigSkeleton
11141
- class FigSkeleton extends FigShimmer {}
11142
- customElements.define("fig-skeleton", FigSkeleton);
11143
-
11144
- // FigLayer
11145
- class FigLayer extends HTMLElement {
11146
- static get observedAttributes() {
11147
- return ["open", "visible"];
11148
- }
10541
+ // FigGroup
10542
+ class FigGroup extends HTMLElement {
10543
+ static observedAttributes = ["name", "collapsible"];
11149
10544
 
10545
+ #header = null;
11150
10546
  #chevron = null;
11151
- #boundHandleChevronClick = null;
11152
10547
 
11153
10548
  connectedCallback() {
11154
- // Use requestAnimationFrame to ensure child elements have rendered
11155
- requestAnimationFrame(() => {
11156
- this.#injectChevron();
11157
- });
10549
+ requestAnimationFrame(() => this.#render());
11158
10550
  }
11159
10551
 
11160
10552
  disconnectedCallback() {
11161
- if (this.#chevron && this.#boundHandleChevronClick) {
11162
- this.#chevron.removeEventListener("click", this.#boundHandleChevronClick);
10553
+ if (this.#chevron) {
10554
+ this.#chevron.removeEventListener("click", this.#handleToggle);
10555
+ }
10556
+ if (this.#header) {
10557
+ this.#header.removeEventListener("click", this.#handleToggle);
11163
10558
  }
11164
10559
  }
11165
10560
 
11166
- #injectChevron() {
11167
- const row = this.querySelector(":scope > .fig-layer-row");
11168
- if (!row) return;
11169
-
11170
- // Check if chevron already exists
11171
- if (row.querySelector(".fig-layer-chevron")) return;
11172
-
11173
- // Always create chevron element - CSS handles visibility via :has(fig-layer)
11174
- this.#chevron = document.createElement("span");
11175
- this.#chevron.className = "fig-layer-chevron";
11176
- row.prepend(this.#chevron);
11177
-
11178
- // Add click listener to chevron only
11179
- this.#boundHandleChevronClick = this.#handleChevronClick.bind(this);
11180
- this.#chevron.addEventListener("click", this.#boundHandleChevronClick);
11181
- }
11182
-
11183
- #handleChevronClick(e) {
11184
- e.stopPropagation();
11185
- this.open = !this.open;
10561
+ attributeChangedCallback(name, oldValue, newValue) {
10562
+ if (oldValue === newValue) return;
10563
+ this.#render();
11186
10564
  }
11187
10565
 
11188
10566
  get open() {
@@ -11191,146 +10569,51 @@ class FigLayer extends HTMLElement {
11191
10569
  }
11192
10570
 
11193
10571
  set open(value) {
11194
- const oldValue = this.open;
10572
+ const was = this.open;
11195
10573
  if (value) {
11196
10574
  this.setAttribute("open", "true");
11197
10575
  } else {
11198
10576
  this.setAttribute("open", "false");
11199
10577
  }
11200
- if (oldValue !== value) {
10578
+ if (was !== !!value) {
11201
10579
  this.dispatchEvent(
11202
10580
  new CustomEvent("openchange", {
11203
- detail: { open: value },
10581
+ detail: { open: !!value },
11204
10582
  bubbles: true,
11205
10583
  }),
11206
10584
  );
11207
10585
  }
11208
10586
  }
11209
10587
 
11210
- get visible() {
11211
- const attr = this.getAttribute("visible");
11212
- return attr !== "false";
11213
- }
10588
+ #handleToggle = (e) => {
10589
+ e.stopPropagation();
10590
+ this.open = !this.open;
10591
+ };
11214
10592
 
11215
- set visible(value) {
11216
- const oldValue = this.visible;
11217
- if (value) {
11218
- this.setAttribute("visible", "true");
11219
- } else {
11220
- this.setAttribute("visible", "false");
11221
- }
11222
- if (oldValue !== value) {
11223
- this.dispatchEvent(
11224
- new CustomEvent("visibilitychange", {
11225
- detail: { visible: value },
11226
- bubbles: true,
11227
- }),
11228
- );
11229
- }
11230
- }
10593
+ #render() {
10594
+ const isCollapsible = this.hasAttribute("collapsible");
10595
+ const nameAttr = this.getAttribute("name");
10596
+ const label = nameAttr || (isCollapsible ? "Group" : null);
11231
10597
 
11232
- attributeChangedCallback(name, oldValue, newValue) {
11233
- if (oldValue === newValue) return;
10598
+ // Check if user supplied their own fig-header
10599
+ const userHeader = this.querySelector(":scope > fig-header");
11234
10600
 
11235
- if (name === "open") {
11236
- const isOpen = newValue !== null && newValue !== "false";
11237
- this.dispatchEvent(
11238
- new CustomEvent("openchange", {
11239
- detail: { open: isOpen },
11240
- bubbles: true,
11241
- }),
11242
- );
10601
+ if (!label && !isCollapsible && !userHeader) {
10602
+ if (this.#header && this.#header.dataset.generated) {
10603
+ this.#header.remove();
10604
+ this.#header = null;
10605
+ this.#chevron = null;
10606
+ }
10607
+ return;
11243
10608
  }
11244
10609
 
11245
- if (name === "visible") {
11246
- const isVisible = newValue !== "false";
11247
- this.dispatchEvent(
11248
- new CustomEvent("visibilitychange", {
11249
- detail: { visible: isVisible },
11250
- bubbles: true,
11251
- }),
11252
- );
11253
- }
11254
- }
11255
- }
11256
- customElements.define("fig-layer", FigLayer);
11257
-
11258
- // FigGroup
11259
- class FigGroup extends HTMLElement {
11260
- static observedAttributes = ["name", "collapsible"];
11261
-
11262
- #header = null;
11263
- #chevron = null;
11264
-
11265
- connectedCallback() {
11266
- requestAnimationFrame(() => this.#render());
11267
- }
11268
-
11269
- disconnectedCallback() {
11270
- if (this.#chevron) {
11271
- this.#chevron.removeEventListener("click", this.#handleToggle);
11272
- }
11273
- if (this.#header) {
11274
- this.#header.removeEventListener("click", this.#handleToggle);
11275
- }
11276
- }
11277
-
11278
- attributeChangedCallback(name, oldValue, newValue) {
11279
- if (oldValue === newValue) return;
11280
- this.#render();
11281
- }
11282
-
11283
- get open() {
11284
- const attr = this.getAttribute("open");
11285
- return attr !== null && attr !== "false";
11286
- }
11287
-
11288
- set open(value) {
11289
- const was = this.open;
11290
- if (value) {
11291
- this.setAttribute("open", "true");
11292
- } else {
11293
- this.setAttribute("open", "false");
11294
- }
11295
- if (was !== !!value) {
11296
- this.dispatchEvent(
11297
- new CustomEvent("openchange", {
11298
- detail: { open: !!value },
11299
- bubbles: true,
11300
- }),
11301
- );
11302
- }
11303
- }
11304
-
11305
- #handleToggle = (e) => {
11306
- e.stopPropagation();
11307
- this.open = !this.open;
11308
- };
11309
-
11310
- #render() {
11311
- const isCollapsible = this.hasAttribute("collapsible");
11312
- const nameAttr = this.getAttribute("name");
11313
- const label = nameAttr || (isCollapsible ? "Group" : null);
11314
-
11315
- // Check if user supplied their own fig-header
11316
- const userHeader = this.querySelector(":scope > fig-header");
11317
-
11318
- if (!label && !isCollapsible && !userHeader) {
11319
- if (this.#header && this.#header.dataset.generated) {
11320
- this.#header.remove();
11321
- this.#header = null;
11322
- this.#chevron = null;
11323
- }
11324
- return;
11325
- }
11326
-
11327
- if (userHeader) {
11328
- this.#header = userHeader;
11329
- } else if (!this.#header || !this.#header.dataset.generated) {
11330
- this.#header = document.createElement("fig-header");
11331
- this.#header.setAttribute("borderless", "");
11332
- this.#header.dataset.generated = "true";
11333
- this.prepend(this.#header);
10610
+ if (userHeader) {
10611
+ this.#header = userHeader;
10612
+ } else if (!this.#header || !this.#header.dataset.generated) {
10613
+ this.#header = document.createElement("fig-header");
10614
+ this.#header.setAttribute("borderless", "");
10615
+ this.#header.dataset.generated = "true";
10616
+ this.prepend(this.#header);
11334
10617
  }
11335
10618
 
11336
10619
  // Ensure h3 exists inside header
@@ -12414,9 +11697,9 @@ class FigFillPicker extends HTMLElement {
12414
11697
  <option value="angular">Angular</option>
12415
11698
  </fig-dropdown>
12416
11699
  <fig-tooltip text="Rotate gradient">
12417
- <fig-input-angle class="fig-fill-picker-gradient-angle" value="${
11700
+ <fig-input-number class="fig-fill-picker-gradient-angle" value="${
12418
11701
  (this.#gradient.angle - 90 + 360) % 360
12419
- }"></fig-input-angle>
11702
+ }" min="0" max="360" units="°" wrap></fig-input-number>
12420
11703
  </fig-tooltip>
12421
11704
  <div class="fig-fill-picker-gradient-center input-combo" style="display: none;">
12422
11705
  <fig-input-number min="0" max="100" value="${
@@ -12513,7 +11796,6 @@ class FigFillPicker extends HTMLElement {
12513
11796
  );
12514
11797
 
12515
11798
  // Angle input
12516
- // Convert from fig-input-angle coordinates (0° = right) to CSS coordinates (0° = up)
12517
11799
  const angleInput = container.querySelector(
12518
11800
  ".fig-fill-picker-gradient-angle",
12519
11801
  );
@@ -13700,1623 +12982,699 @@ class FigColorTip extends HTMLElement {
13700
12982
  return `#${toHex(rgb[1])}${toHex(rgb[2])}${toHex(rgb[3])}`.toUpperCase();
13701
12983
  }
13702
12984
  } catch {
13703
- // Fall through to default.
13704
- }
13705
-
13706
- return "#D9D9D9";
13707
- }
13708
-
13709
- #syncFromAttributes() {
13710
- const rawAttr = this.getAttribute("value");
13711
- const color = this.#normalizeColor(rawAttr);
13712
- const alpha = this.#extractAlpha(rawAttr);
13713
- if (rawAttr !== color && alpha >= 1) {
13714
- this.setAttribute("value", color);
13715
- return;
13716
- }
13717
-
13718
- if (this.#fillPicker) {
13719
- const pickerVal =
13720
- alpha < 1
13721
- ? { type: "solid", color, opacity: Math.round(alpha * 100) }
13722
- : { type: "solid", color };
13723
- this.#fillPicker.setAttribute("value", JSON.stringify(pickerVal));
13724
- if (this.#alphaEnabled) {
13725
- this.#fillPicker.removeAttribute("alpha");
13726
- } else {
13727
- this.#fillPicker.setAttribute("alpha", "false");
13728
- }
13729
- if (this.hasAttribute("disabled")) {
13730
- this.#fillPicker.setAttribute("disabled", "");
13731
- } else {
13732
- this.#fillPicker.removeAttribute("disabled");
13733
- }
13734
- }
13735
-
13736
- if (this.#chit) {
13737
- this.#chit.setAttribute("background", color);
13738
- if (this.hasAttribute("disabled")) {
13739
- this.#chit.setAttribute("disabled", "");
13740
- } else {
13741
- this.#chit.removeAttribute("disabled");
13742
- }
13743
- }
13744
- }
13745
-
13746
- #updateColorFromPicker(detail, type) {
13747
- const nextColor = this.#normalizeColor(detail?.color);
13748
- const prevColor = this.#normalizeColor(this.getAttribute("value"));
13749
- if (nextColor !== prevColor) {
13750
- this.setAttribute("value", nextColor);
13751
- } else {
13752
- this.#syncFromAttributes();
13753
- }
13754
-
13755
- const eventDetail = { color: this.value };
13756
- if (this.#alphaEnabled) {
13757
- if (detail?.opacity !== undefined) {
13758
- eventDetail.opacity = detail.opacity;
13759
- } else if (detail?.alpha !== undefined) {
13760
- eventDetail.opacity = Math.round(detail.alpha * 100);
13761
- }
13762
- }
13763
-
13764
- this.dispatchEvent(
13765
- new CustomEvent(type, {
13766
- bubbles: true,
13767
- cancelable: true,
13768
- composed: true,
13769
- detail: eventDetail,
13770
- }),
13771
- );
13772
- }
13773
-
13774
- #handlePickerInput(event) {
13775
- event.stopPropagation();
13776
- this.#updateColorFromPicker(event.detail, "input");
13777
- }
13778
-
13779
- #handlePickerChange(event) {
13780
- event.stopPropagation();
13781
- this.#updateColorFromPicker(event.detail, "change");
13782
- }
13783
-
13784
- attributeChangedCallback(name, oldValue, newValue) {
13785
- if (oldValue === newValue) return;
13786
- if (!this.isConnected) return;
13787
-
13788
- switch (name) {
13789
- case "control":
13790
- this.#render();
13791
- break;
13792
- case "value":
13793
- case "selected":
13794
- case "disabled":
13795
- this.#syncFromAttributes();
13796
- break;
13797
- }
13798
- }
13799
-
13800
- get value() {
13801
- return this.#normalizeColor(this.getAttribute("value"));
13802
- }
13803
- set value(value) {
13804
- if (value === null || value === undefined || value === "") {
13805
- this.removeAttribute("value");
13806
- return;
13807
- }
13808
- this.setAttribute("value", this.#normalizeColor(value));
13809
- }
13810
-
13811
- get selected() {
13812
- return this.hasAttribute("selected");
13813
- }
13814
- set selected(value) {
13815
- this.toggleAttribute("selected", Boolean(value));
13816
- }
13817
-
13818
- get disabled() {
13819
- return this.hasAttribute("disabled");
13820
- }
13821
- set disabled(value) {
13822
- this.toggleAttribute("disabled", Boolean(value));
13823
- }
13824
- }
13825
- customElements.define("fig-color-tip", FigColorTip);
13826
-
13827
- /* Choice */
13828
- /**
13829
- * A generic choice container for use within FigChooser.
13830
- * @attr {string} value - Identifier for this choice
13831
- * @attr {boolean} selected - Whether this choice is currently selected
13832
- * @attr {boolean} disabled - Whether this choice is disabled
13833
- */
13834
- class FigChoice extends HTMLElement {
13835
- static get observedAttributes() {
13836
- return ["selected", "disabled"];
13837
- }
13838
-
13839
- connectedCallback() {
13840
- this.setAttribute("role", "option");
13841
- if (!this.hasAttribute("tabindex")) {
13842
- this.setAttribute("tabindex", "0");
13843
- }
13844
- this.setAttribute(
13845
- "aria-selected",
13846
- this.hasAttribute("selected") ? "true" : "false",
13847
- );
13848
- if (this.hasAttribute("disabled")) {
13849
- this.setAttribute("aria-disabled", "true");
13850
- }
13851
- }
13852
-
13853
- attributeChangedCallback(name) {
13854
- if (name === "selected") {
13855
- this.setAttribute(
13856
- "aria-selected",
13857
- this.hasAttribute("selected") ? "true" : "false",
13858
- );
13859
- }
13860
- if (name === "disabled") {
13861
- const isDisabled =
13862
- this.hasAttribute("disabled") &&
13863
- this.getAttribute("disabled") !== "false";
13864
- if (isDisabled) {
13865
- this.setAttribute("aria-disabled", "true");
13866
- this.setAttribute("tabindex", "-1");
13867
- } else {
13868
- this.removeAttribute("aria-disabled");
13869
- this.setAttribute("tabindex", "0");
13870
- }
13871
- }
13872
- }
13873
- }
13874
- customElements.define("fig-choice", FigChoice);
13875
-
13876
- /* Chooser */
13877
- /**
13878
- * A selection controller for a list of choice elements.
13879
- * @attr {string} choice-element - CSS selector for child choices (default: "fig-choice")
13880
- * @attr {string} layout - Layout mode: "vertical" (default), "horizontal", "grid"
13881
- * @attr {string} value - Value of the currently selected choice
13882
- * @attr {boolean} disabled - Whether the chooser is disabled
13883
- */
13884
- class FigChooser extends HTMLElement {
13885
- #selectedChoice = null;
13886
- #boundHandleClick = this.#handleClick.bind(this);
13887
- #boundHandleKeyDown = this.#handleKeyDown.bind(this);
13888
- #boundSyncOverflow = this.#syncOverflow.bind(this);
13889
- #mutationObserver = null;
13890
- #resizeObserver = null;
13891
- #navStart = null;
13892
- #navEnd = null;
13893
- #dragState = null;
13894
-
13895
- constructor() {
13896
- super();
13897
- }
13898
-
13899
- static get observedAttributes() {
13900
- return [
13901
- "value",
13902
- "disabled",
13903
- "choice-element",
13904
- "drag",
13905
- "overflow",
13906
- "loop",
13907
- "padding",
13908
- ];
13909
- }
13910
-
13911
- get #overflowMode() {
13912
- return this.getAttribute("overflow") === "scrollbar"
13913
- ? "scrollbar"
13914
- : "buttons";
13915
- }
13916
-
13917
- get #dragEnabled() {
13918
- const attr = this.getAttribute("drag");
13919
- return attr === null || attr !== "false";
13920
- }
13921
-
13922
- get #choiceSelector() {
13923
- return this.getAttribute("choice-element") || "fig-choice";
13924
- }
13925
-
13926
- get choices() {
13927
- return Array.from(this.querySelectorAll(this.#choiceSelector));
13928
- }
13929
-
13930
- get selectedChoice() {
13931
- return this.#selectedChoice;
13932
- }
13933
-
13934
- set selectedChoice(element) {
13935
- if (element && !this.contains(element)) return;
13936
- const choices = this.choices;
13937
- for (const choice of choices) {
13938
- const shouldSelect = choice === element;
13939
- const isSelected = choice.hasAttribute("selected");
13940
- if (shouldSelect && !isSelected) {
13941
- choice.setAttribute("selected", "");
13942
- } else if (!shouldSelect && isSelected) {
13943
- choice.removeAttribute("selected");
13944
- }
13945
- }
13946
- this.#selectedChoice = element;
13947
- const val = element?.getAttribute("value") ?? "";
13948
- if (this.getAttribute("value") !== val) {
13949
- if (val) {
13950
- this.setAttribute("value", val);
13951
- }
13952
- }
13953
- this.#scrollToChoice(element);
13954
- }
13955
-
13956
- get value() {
13957
- return this.#selectedChoice?.getAttribute("value") ?? "";
13958
- }
13959
-
13960
- set value(val) {
13961
- if (val === null || val === undefined || val === "") return;
13962
- this.setAttribute("value", String(val));
13963
- }
13964
-
13965
- connectedCallback() {
13966
- this.setAttribute("role", "listbox");
13967
- this.addEventListener("click", this.#boundHandleClick);
13968
- this.addEventListener("keydown", this.#boundHandleKeyDown);
13969
- this.addEventListener("scroll", this.#boundSyncOverflow);
13970
- this.#applyOverflowMode();
13971
- this.#setupDrag();
13972
- this.#startObserver();
13973
- this.#startResizeObserver();
13974
-
13975
- requestAnimationFrame(() => {
13976
- this.#syncSelection();
13977
- this.#syncOverflow();
13978
- });
13979
- }
13980
-
13981
- disconnectedCallback() {
13982
- this.removeEventListener("click", this.#boundHandleClick);
13983
- this.removeEventListener("keydown", this.#boundHandleKeyDown);
13984
- this.removeEventListener("scroll", this.#boundSyncOverflow);
13985
- this.#teardownDrag();
13986
- this.#mutationObserver?.disconnect();
13987
- this.#mutationObserver = null;
13988
- this.#resizeObserver?.disconnect();
13989
- this.#resizeObserver = null;
13990
- this.#removeNavButtons();
13991
- }
13992
-
13993
- attributeChangedCallback(name, oldValue, newValue) {
13994
- if (name === "value" && newValue !== oldValue && newValue) {
13995
- this.#selectByValue(newValue);
13996
- }
13997
- if (name === "disabled") {
13998
- const isDisabled = newValue !== null && newValue !== "false";
13999
- const choices = this.choices;
14000
- for (const choice of choices) {
14001
- if (isDisabled) {
14002
- choice.setAttribute("aria-disabled", "true");
14003
- choice.setAttribute("tabindex", "-1");
14004
- } else {
14005
- choice.removeAttribute("aria-disabled");
14006
- choice.setAttribute("tabindex", "0");
14007
- }
14008
- }
14009
- }
14010
- if (name === "choice-element") {
14011
- requestAnimationFrame(() => this.#syncSelection());
14012
- }
14013
- if (name === "drag") {
14014
- if (this.#dragEnabled) {
14015
- this.#setupDrag();
14016
- } else {
14017
- this.#teardownDrag();
14018
- }
14019
- }
14020
- if (name === "overflow") {
14021
- this.#applyOverflowMode();
14022
- }
14023
- }
14024
-
14025
- #syncSelection() {
14026
- const choices = this.choices;
14027
- if (!choices.length) {
14028
- this.#selectedChoice = null;
14029
- return;
14030
- }
14031
-
14032
- const valueAttr = this.getAttribute("value");
14033
- if (valueAttr && this.#selectByValue(valueAttr)) return;
14034
-
14035
- const alreadySelected = choices.find((c) => c.hasAttribute("selected"));
14036
- if (alreadySelected) {
14037
- this.selectedChoice = alreadySelected;
14038
- return;
14039
- }
14040
-
14041
- this.selectedChoice = choices[0];
14042
- }
14043
-
14044
- #selectByValue(value) {
14045
- const choices = this.choices;
14046
- for (const choice of choices) {
14047
- if (choice.getAttribute("value") === value) {
14048
- this.selectedChoice = choice;
14049
- return true;
14050
- }
14051
- }
14052
- return false;
14053
- }
14054
-
14055
- #findChoiceFromTarget(target) {
14056
- const selector = this.#choiceSelector;
14057
- let el = target;
14058
- while (el && el !== this) {
14059
- if (el.matches(selector)) return el;
14060
- el = el.parentElement;
14061
- }
14062
- return null;
14063
- }
14064
-
14065
- #handleClick(event) {
14066
- if (
14067
- this.hasAttribute("disabled") &&
14068
- this.getAttribute("disabled") !== "false"
14069
- )
14070
- return;
14071
- const choice = this.#findChoiceFromTarget(event.target);
14072
- if (!choice) return;
14073
- if (
14074
- choice.hasAttribute("disabled") &&
14075
- choice.getAttribute("disabled") !== "false"
14076
- )
14077
- return;
14078
- this.selectedChoice = choice;
14079
- this.#emitEvents();
14080
- }
14081
-
14082
- #handleKeyDown(event) {
14083
- if (
14084
- this.hasAttribute("disabled") &&
14085
- this.getAttribute("disabled") !== "false"
14086
- )
14087
- return;
14088
- const choices = this.choices.filter(
14089
- (c) =>
14090
- !c.hasAttribute("disabled") || c.getAttribute("disabled") === "false",
14091
- );
14092
- if (!choices.length) return;
14093
- const currentIndex = choices.indexOf(this.#selectedChoice);
14094
- let nextIndex = currentIndex;
14095
-
14096
- const loop = this.hasAttribute("loop");
14097
-
14098
- switch (event.key) {
14099
- case "ArrowDown":
14100
- case "ArrowRight":
14101
- event.preventDefault();
14102
- if (currentIndex < choices.length - 1) {
14103
- nextIndex = currentIndex + 1;
14104
- } else if (loop) {
14105
- nextIndex = 0;
14106
- }
14107
- break;
14108
- case "ArrowUp":
14109
- case "ArrowLeft":
14110
- event.preventDefault();
14111
- if (currentIndex > 0) {
14112
- nextIndex = currentIndex - 1;
14113
- } else if (loop) {
14114
- nextIndex = choices.length - 1;
14115
- }
14116
- break;
14117
- case "Home":
14118
- event.preventDefault();
14119
- nextIndex = 0;
14120
- break;
14121
- case "End":
14122
- event.preventDefault();
14123
- nextIndex = choices.length - 1;
14124
- break;
14125
- case "Enter":
14126
- case " ":
14127
- event.preventDefault();
14128
- if (document.activeElement?.matches(this.#choiceSelector)) {
14129
- const focused = this.#findChoiceFromTarget(document.activeElement);
14130
- if (focused && focused !== this.#selectedChoice) {
14131
- this.selectedChoice = focused;
14132
- this.#emitEvents();
14133
- }
14134
- }
14135
- return;
14136
- default:
14137
- return;
14138
- }
14139
-
14140
- if (nextIndex !== currentIndex && choices[nextIndex]) {
14141
- this.selectedChoice = choices[nextIndex];
14142
- choices[nextIndex].focus();
14143
- this.#emitEvents();
14144
- }
14145
- }
14146
-
14147
- #emitEvents() {
14148
- const val = this.value;
14149
- this.dispatchEvent(
14150
- new CustomEvent("input", { detail: val, bubbles: true }),
14151
- );
14152
- this.dispatchEvent(
14153
- new CustomEvent("change", { detail: val, bubbles: true }),
14154
- );
14155
- }
14156
-
14157
- #syncOverflow() {
14158
- if (this.#overflowMode === "scrollbar") return;
14159
- const isHorizontal = this.getAttribute("layout") === "horizontal";
14160
- const threshold = 2;
14161
-
14162
- if (isHorizontal) {
14163
- const atStart = this.scrollLeft <= threshold;
14164
- const atEnd =
14165
- this.scrollLeft + this.clientWidth >= this.scrollWidth - threshold;
14166
- this.classList.toggle("overflow-start", !atStart);
14167
- this.classList.toggle("overflow-end", !atEnd);
14168
- } else {
14169
- const atStart = this.scrollTop <= threshold;
14170
- const atEnd =
14171
- this.scrollTop + this.clientHeight >= this.scrollHeight - threshold;
14172
- this.classList.toggle("overflow-start", !atStart);
14173
- this.classList.toggle("overflow-end", !atEnd);
14174
- }
14175
- }
14176
-
14177
- #startResizeObserver() {
14178
- this.#resizeObserver?.disconnect();
14179
- this.#resizeObserver = new ResizeObserver(() => {
14180
- this.#syncOverflow();
14181
- });
14182
- this.#resizeObserver.observe(this);
14183
- }
14184
-
14185
- #setupDrag() {
14186
- if (this.#dragState?.bound) return;
14187
- if (!this.#dragEnabled) return;
14188
-
14189
- const onPointerDown = (e) => {
14190
- if (e.button !== 0) return;
14191
- const isHorizontal = this.getAttribute("layout") === "horizontal";
14192
- const hasOverflow = isHorizontal
14193
- ? this.scrollWidth > this.clientWidth
14194
- : this.scrollHeight > this.clientHeight;
14195
- if (!hasOverflow) return;
14196
-
14197
- this.#dragState.active = true;
14198
- this.#dragState.didDrag = false;
14199
- this.#dragState.startX = e.clientX;
14200
- this.#dragState.startY = e.clientY;
14201
- this.#dragState.scrollLeft = this.scrollLeft;
14202
- this.#dragState.scrollTop = this.scrollTop;
14203
- this.style.cursor = "grab";
14204
- this.style.userSelect = "none";
14205
- };
14206
-
14207
- const onPointerMove = (e) => {
14208
- if (!this.#dragState.active) return;
14209
- const isHorizontal = this.getAttribute("layout") === "horizontal";
14210
- const dx = e.clientX - this.#dragState.startX;
14211
- const dy = e.clientY - this.#dragState.startY;
14212
-
14213
- if (!this.#dragState.didDrag && Math.abs(isHorizontal ? dx : dy) > 3) {
14214
- this.#dragState.didDrag = true;
14215
- this.style.cursor = "grabbing";
14216
- this.setPointerCapture(e.pointerId);
14217
- }
14218
-
14219
- if (!this.#dragState.didDrag) return;
14220
-
14221
- if (isHorizontal) {
14222
- this.scrollLeft = this.#dragState.scrollLeft - dx;
14223
- } else {
14224
- this.scrollTop = this.#dragState.scrollTop - dy;
14225
- }
14226
- };
14227
-
14228
- const onPointerUp = (e) => {
14229
- if (!this.#dragState.active) return;
14230
- const wasDrag = this.#dragState.didDrag;
14231
- this.#dragState.active = false;
14232
- this.#dragState.didDrag = false;
14233
- this.style.cursor = "";
14234
- this.style.userSelect = "";
14235
- if (e.pointerId !== undefined) {
14236
- try {
14237
- this.releasePointerCapture(e.pointerId);
14238
- } catch {}
14239
- }
14240
- if (wasDrag) {
14241
- e.preventDefault();
14242
- e.stopPropagation();
14243
- }
14244
- };
14245
-
14246
- const onClick = (e) => {
14247
- if (this.#dragState?.suppressClick) {
14248
- e.stopPropagation();
14249
- e.preventDefault();
14250
- this.#dragState.suppressClick = false;
14251
- }
14252
- };
14253
-
14254
- const onPointerUpCapture = (e) => {
14255
- if (this.#dragState?.didDrag) {
14256
- this.#dragState.suppressClick = true;
14257
- setTimeout(() => {
14258
- if (this.#dragState) this.#dragState.suppressClick = false;
14259
- }, 0);
14260
- }
14261
- };
14262
-
14263
- this.#dragState = {
14264
- active: false,
14265
- didDrag: false,
14266
- suppressClick: false,
14267
- startX: 0,
14268
- startY: 0,
14269
- scrollLeft: 0,
14270
- scrollTop: 0,
14271
- bound: true,
14272
- onPointerDown,
14273
- onPointerMove,
14274
- onPointerUp,
14275
- onClick,
14276
- onPointerUpCapture,
14277
- };
14278
-
14279
- this.addEventListener("pointerdown", onPointerDown);
14280
- window.addEventListener("pointermove", onPointerMove);
14281
- window.addEventListener("pointerup", onPointerUp);
14282
- this.addEventListener("pointerup", onPointerUpCapture, true);
14283
- this.addEventListener("click", onClick, true);
14284
- }
14285
-
14286
- #teardownDrag() {
14287
- if (!this.#dragState?.bound) return;
14288
- this.removeEventListener("pointerdown", this.#dragState.onPointerDown);
14289
- window.removeEventListener("pointermove", this.#dragState.onPointerMove);
14290
- window.removeEventListener("pointerup", this.#dragState.onPointerUp);
14291
- this.removeEventListener(
14292
- "pointerup",
14293
- this.#dragState.onPointerUpCapture,
14294
- true,
14295
- );
14296
- this.removeEventListener("click", this.#dragState.onClick, true);
14297
- this.style.cursor = "";
14298
- this.style.userSelect = "";
14299
- this.#dragState = null;
14300
- }
14301
-
14302
- #applyOverflowMode() {
14303
- if (this.#overflowMode === "scrollbar") {
14304
- this.#removeNavButtons();
14305
- } else {
14306
- this.#createNavButtons();
14307
- }
14308
- }
14309
-
14310
- #removeNavButtons() {
14311
- this.#navStart?.remove();
14312
- this.#navEnd?.remove();
14313
- this.#navStart = null;
14314
- this.#navEnd = null;
14315
- this.classList.remove("overflow-start", "overflow-end");
14316
- }
14317
-
14318
- #createNavButtons() {
14319
- if (this.#navStart) return;
14320
-
14321
- this.#navStart = document.createElement("button");
14322
- this.#navStart.className = "fig-chooser-nav-start";
14323
- this.#navStart.setAttribute("tabindex", "-1");
14324
- this.#navStart.setAttribute("aria-label", "Scroll back");
14325
-
14326
- this.#navEnd = document.createElement("button");
14327
- this.#navEnd.className = "fig-chooser-nav-end";
14328
- this.#navEnd.setAttribute("tabindex", "-1");
14329
- this.#navEnd.setAttribute("aria-label", "Scroll forward");
14330
-
14331
- this.#navStart.addEventListener("pointerdown", (e) => {
14332
- e.stopPropagation();
14333
- this.#scrollByPage(-1);
14334
- });
14335
-
14336
- this.#navEnd.addEventListener("pointerdown", (e) => {
14337
- e.stopPropagation();
14338
- this.#scrollByPage(1);
14339
- });
14340
-
14341
- this.prepend(this.#navStart);
14342
- this.append(this.#navEnd);
14343
- }
14344
-
14345
- #scrollByPage(direction) {
14346
- const isHorizontal = this.getAttribute("layout") === "horizontal";
14347
- const pageSize = isHorizontal ? this.clientWidth : this.clientHeight;
14348
- const scrollAmount = pageSize * 0.8 * direction;
14349
-
14350
- this.scrollBy({
14351
- [isHorizontal ? "left" : "top"]: scrollAmount,
14352
- behavior: "smooth",
14353
- });
12985
+ // Fall through to default.
12986
+ }
12987
+
12988
+ return "#D9D9D9";
14354
12989
  }
14355
12990
 
14356
- #scrollToChoice(el) {
14357
- if (!el) return;
14358
- requestAnimationFrame(() => {
14359
- const overflowY = this.scrollHeight > this.clientHeight;
14360
- const overflowX = this.scrollWidth > this.clientWidth;
14361
- if (!overflowX && !overflowY) return;
12991
+ #syncFromAttributes() {
12992
+ const rawAttr = this.getAttribute("value");
12993
+ const color = this.#normalizeColor(rawAttr);
12994
+ const alpha = this.#extractAlpha(rawAttr);
12995
+ if (rawAttr !== color && alpha >= 1) {
12996
+ this.setAttribute("value", color);
12997
+ return;
12998
+ }
14362
12999
 
14363
- const options = { behavior: "smooth" };
13000
+ if (this.#fillPicker) {
13001
+ const pickerVal =
13002
+ alpha < 1
13003
+ ? { type: "solid", color, opacity: Math.round(alpha * 100) }
13004
+ : { type: "solid", color };
13005
+ this.#fillPicker.setAttribute("value", JSON.stringify(pickerVal));
13006
+ if (this.#alphaEnabled) {
13007
+ this.#fillPicker.removeAttribute("alpha");
13008
+ } else {
13009
+ this.#fillPicker.setAttribute("alpha", "false");
13010
+ }
13011
+ if (this.hasAttribute("disabled")) {
13012
+ this.#fillPicker.setAttribute("disabled", "");
13013
+ } else {
13014
+ this.#fillPicker.removeAttribute("disabled");
13015
+ }
13016
+ }
14364
13017
 
14365
- if (overflowY) {
14366
- const target =
14367
- el.offsetTop - this.clientHeight / 2 + el.offsetHeight / 2;
14368
- options.top = target;
13018
+ if (this.#chit) {
13019
+ this.#chit.setAttribute("background", color);
13020
+ if (this.hasAttribute("disabled")) {
13021
+ this.#chit.setAttribute("disabled", "");
13022
+ } else {
13023
+ this.#chit.removeAttribute("disabled");
14369
13024
  }
13025
+ }
13026
+ }
14370
13027
 
14371
- if (overflowX) {
14372
- const target =
14373
- el.offsetLeft - this.clientWidth / 2 + el.offsetWidth / 2;
14374
- options.left = target;
13028
+ #updateColorFromPicker(detail, type) {
13029
+ const nextColor = this.#normalizeColor(detail?.color);
13030
+ const prevColor = this.#normalizeColor(this.getAttribute("value"));
13031
+ if (nextColor !== prevColor) {
13032
+ this.setAttribute("value", nextColor);
13033
+ } else {
13034
+ this.#syncFromAttributes();
13035
+ }
13036
+
13037
+ const eventDetail = { color: this.value };
13038
+ if (this.#alphaEnabled) {
13039
+ if (detail?.opacity !== undefined) {
13040
+ eventDetail.opacity = detail.opacity;
13041
+ } else if (detail?.alpha !== undefined) {
13042
+ eventDetail.opacity = Math.round(detail.alpha * 100);
14375
13043
  }
13044
+ }
14376
13045
 
14377
- this.scrollTo(options);
14378
- });
13046
+ this.dispatchEvent(
13047
+ new CustomEvent(type, {
13048
+ bubbles: true,
13049
+ cancelable: true,
13050
+ composed: true,
13051
+ detail: eventDetail,
13052
+ }),
13053
+ );
14379
13054
  }
14380
13055
 
14381
- #startObserver() {
14382
- this.#mutationObserver?.disconnect();
14383
- this.#mutationObserver = new MutationObserver(() => {
14384
- const choices = this.choices;
14385
- if (this.#selectedChoice && !choices.includes(this.#selectedChoice)) {
14386
- this.#selectedChoice = null;
14387
- this.#syncSelection();
14388
- } else if (!this.#selectedChoice && choices.length) {
14389
- this.#syncSelection();
14390
- }
14391
- });
14392
- this.#mutationObserver.observe(this, { childList: true, subtree: true });
13056
+ #handlePickerInput(event) {
13057
+ event.stopPropagation();
13058
+ this.#updateColorFromPicker(event.detail, "input");
14393
13059
  }
14394
- }
14395
- customElements.define("fig-chooser", FigChooser);
14396
13060
 
14397
- /* Canvas Control */
14398
- class FigCanvasControl extends HTMLElement {
14399
- static observedAttributes = [
14400
- "type",
14401
- "value",
14402
- "color",
14403
- "name",
14404
- "tooltips",
14405
- "disabled",
14406
- "drag-surface",
14407
- "snapping",
14408
- ];
13061
+ #handlePickerChange(event) {
13062
+ event.stopPropagation();
13063
+ this.#updateColorFromPicker(event.detail, "change");
13064
+ }
14409
13065
 
14410
- #x = 50;
14411
- #y = 50;
14412
- #x2 = 75;
14413
- #y2 = 75;
14414
- #radius = 0;
14415
- #radiusIsPercent = false;
14416
- #angle = 0;
14417
- #pointHandle = null;
14418
- #secondHandle = null;
14419
- #angleHandle = null;
14420
- #radiusSvg = null;
14421
- #angleSvg = null;
14422
- #pointTooltip = null;
14423
- #secondTooltip = null;
14424
- #radiusTooltip = null;
14425
- #angleTooltip = null;
14426
- #isDragging = false;
14427
- #isSecondDragging = false;
14428
- #isRadiusDragging = false;
14429
- #isAngleDragging = false;
14430
- #prevBodyCursor = "";
13066
+ attributeChangedCallback(name, oldValue, newValue) {
13067
+ if (oldValue === newValue) return;
13068
+ if (!this.isConnected) return;
13069
+
13070
+ switch (name) {
13071
+ case "control":
13072
+ this.#render();
13073
+ break;
13074
+ case "value":
13075
+ case "selected":
13076
+ case "disabled":
13077
+ this.#syncFromAttributes();
13078
+ break;
13079
+ }
13080
+ }
13081
+
13082
+ get value() {
13083
+ return this.#normalizeColor(this.getAttribute("value"));
13084
+ }
13085
+ set value(value) {
13086
+ if (value === null || value === undefined || value === "") {
13087
+ this.removeAttribute("value");
13088
+ return;
13089
+ }
13090
+ this.setAttribute("value", this.#normalizeColor(value));
13091
+ }
14431
13092
 
14432
- get #type() {
14433
- return this.getAttribute("type") || "point";
13093
+ get selected() {
13094
+ return this.hasAttribute("selected");
13095
+ }
13096
+ set selected(value) {
13097
+ this.toggleAttribute("selected", Boolean(value));
14434
13098
  }
14435
13099
 
14436
- get #hasRadius() {
14437
- return this.#type === "point-radius" || this.#type === "point-radius-angle";
13100
+ get disabled() {
13101
+ return this.hasAttribute("disabled");
13102
+ }
13103
+ set disabled(value) {
13104
+ this.toggleAttribute("disabled", Boolean(value));
14438
13105
  }
13106
+ }
13107
+ customElements.define("fig-color-tip", FigColorTip);
14439
13108
 
14440
- get #hasAngle() {
14441
- return this.#type === "point-radius-angle";
13109
+ /* Choice */
13110
+ /**
13111
+ * A generic choice container for use within FigChooser.
13112
+ * @attr {string} value - Identifier for this choice
13113
+ * @attr {boolean} selected - Whether this choice is currently selected
13114
+ * @attr {boolean} disabled - Whether this choice is disabled
13115
+ */
13116
+ class FigChoice extends HTMLElement {
13117
+ static get observedAttributes() {
13118
+ return ["selected", "disabled"];
14442
13119
  }
14443
13120
 
14444
- get #hasSecondPoint() {
14445
- return this.#type === "point-point";
13121
+ connectedCallback() {
13122
+ this.setAttribute("role", "option");
13123
+ if (!this.hasAttribute("tabindex")) {
13124
+ this.setAttribute("tabindex", "0");
13125
+ }
13126
+ this.setAttribute(
13127
+ "aria-selected",
13128
+ this.hasAttribute("selected") ? "true" : "false",
13129
+ );
13130
+ if (this.hasAttribute("disabled")) {
13131
+ this.setAttribute("aria-disabled", "true");
13132
+ }
14446
13133
  }
14447
13134
 
14448
- get #hasLine() {
14449
- return this.#type === "point-radius-angle" || this.#type === "point-point";
13135
+ attributeChangedCallback(name) {
13136
+ if (name === "selected") {
13137
+ this.setAttribute(
13138
+ "aria-selected",
13139
+ this.hasAttribute("selected") ? "true" : "false",
13140
+ );
13141
+ }
13142
+ if (name === "disabled") {
13143
+ const isDisabled =
13144
+ this.hasAttribute("disabled") &&
13145
+ this.getAttribute("disabled") !== "false";
13146
+ if (isDisabled) {
13147
+ this.setAttribute("aria-disabled", "true");
13148
+ this.setAttribute("tabindex", "-1");
13149
+ } else {
13150
+ this.removeAttribute("aria-disabled");
13151
+ this.setAttribute("tabindex", "0");
13152
+ }
13153
+ }
14450
13154
  }
13155
+ }
13156
+ customElements.define("fig-choice", FigChoice);
14451
13157
 
14452
- get #tooltipsEnabled() {
14453
- const v = this.getAttribute("tooltips");
14454
- return v === null || v !== "false";
13158
+ /* Chooser */
13159
+ /**
13160
+ * A selection controller for a list of choice elements.
13161
+ * @attr {string} choice-element - CSS selector for child choices (default: "fig-choice")
13162
+ * @attr {string} layout - Layout mode: "vertical" (default), "horizontal", "grid"
13163
+ * @attr {string} value - Value of the currently selected choice
13164
+ * @attr {boolean} disabled - Whether the chooser is disabled
13165
+ */
13166
+ class FigChooser extends HTMLElement {
13167
+ #selectedChoice = null;
13168
+ #boundHandleClick = this.#handleClick.bind(this);
13169
+ #boundHandleKeyDown = this.#handleKeyDown.bind(this);
13170
+ #boundSyncOverflow = this.#syncOverflow.bind(this);
13171
+ #mutationObserver = null;
13172
+ #resizeObserver = null;
13173
+ #navStart = null;
13174
+ #navEnd = null;
13175
+ #dragState = null;
13176
+
13177
+ constructor() {
13178
+ super();
14455
13179
  }
14456
13180
 
14457
- get #snappingMode() {
14458
- const raw = this.getAttribute("snapping");
14459
- if (raw === null) return "false";
14460
- const n = raw.trim().toLowerCase();
14461
- if (n === "modifier") return "modifier";
14462
- if (n === "" || n === "true") return "true";
14463
- return "false";
13181
+ static get observedAttributes() {
13182
+ return [
13183
+ "value",
13184
+ "disabled",
13185
+ "choice-element",
13186
+ "drag",
13187
+ "overflow",
13188
+ "loop",
13189
+ "padding",
13190
+ ];
14464
13191
  }
14465
13192
 
14466
- #shouldSnap(shiftKey) {
14467
- const mode = this.#snappingMode;
14468
- if (mode === "true") return true;
14469
- if (mode === "modifier") return !!shiftKey;
14470
- return false;
13193
+ get #overflowMode() {
13194
+ return this.getAttribute("overflow") === "scrollbar"
13195
+ ? "scrollbar"
13196
+ : "buttons";
14471
13197
  }
14472
13198
 
14473
- get #pointTipText() {
14474
- const name = this.getAttribute("name");
14475
- if (name) {
14476
- const parts = name.split(",");
14477
- return parts[0].trim();
14478
- }
14479
- return `${Math.round(this.#x)}%, ${Math.round(this.#y)}%`;
13199
+ get #dragEnabled() {
13200
+ const attr = this.getAttribute("drag");
13201
+ return attr === null || attr !== "false";
14480
13202
  }
14481
13203
 
14482
- get #secondTipText() {
14483
- const name = this.getAttribute("name");
14484
- if (name) {
14485
- const parts = name.split(",");
14486
- if (parts.length > 1) return parts[1].trim();
14487
- }
14488
- return `${Math.round(this.#x2)}%, ${Math.round(this.#y2)}%`;
13204
+ get #choiceSelector() {
13205
+ return this.getAttribute("choice-element") || "fig-choice";
14489
13206
  }
14490
13207
 
14491
- get #dragSurface() {
14492
- return this.getAttribute("drag-surface") || "parent";
13208
+ get choices() {
13209
+ return Array.from(this.querySelectorAll(this.#choiceSelector));
14493
13210
  }
14494
13211
 
14495
- get #container() {
14496
- const surface = this.#dragSurface;
14497
- if (surface === "parent") return this.parentElement;
14498
- return this.closest(surface);
13212
+ get selectedChoice() {
13213
+ return this.#selectedChoice;
14499
13214
  }
14500
13215
 
14501
- get #handleDragSurface() {
14502
- const surface = this.#dragSurface;
14503
- if (surface === "parent") {
14504
- const container = this.parentElement;
14505
- if (container) {
14506
- container.setAttribute("data-fig-canvas-control-surface", "");
14507
- return "[data-fig-canvas-control-surface]";
13216
+ set selectedChoice(element) {
13217
+ if (element && !this.contains(element)) return;
13218
+ const choices = this.choices;
13219
+ for (const choice of choices) {
13220
+ const shouldSelect = choice === element;
13221
+ const isSelected = choice.hasAttribute("selected");
13222
+ if (shouldSelect && !isSelected) {
13223
+ choice.setAttribute("selected", "");
13224
+ } else if (!shouldSelect && isSelected) {
13225
+ choice.removeAttribute("selected");
13226
+ }
13227
+ }
13228
+ this.#selectedChoice = element;
13229
+ const val = element?.getAttribute("value") ?? "";
13230
+ if (this.getAttribute("value") !== val) {
13231
+ if (val) {
13232
+ this.setAttribute("value", val);
14508
13233
  }
14509
13234
  }
14510
- return surface;
13235
+ this.#scrollToChoice(element);
14511
13236
  }
14512
13237
 
14513
- #resolveRadius(containerWidth) {
14514
- if (this.#radiusIsPercent) return (this.#radius / 100) * containerWidth;
14515
- return this.#radius;
13238
+ get value() {
13239
+ return this.#selectedChoice?.getAttribute("value") ?? "";
14516
13240
  }
14517
13241
 
14518
- #formatRadius() {
14519
- if (this.#radiusIsPercent) return `Radius ${Math.round(this.#radius)}%`;
14520
- return `Radius ${Math.round(this.#radius)}`;
13242
+ set value(val) {
13243
+ if (val === null || val === undefined || val === "") return;
13244
+ this.setAttribute("value", String(val));
14521
13245
  }
14522
13246
 
14523
13247
  connectedCallback() {
14524
- this.#parseValue();
14525
- this.#render();
13248
+ this.setAttribute("role", "listbox");
13249
+ this.addEventListener("click", this.#boundHandleClick);
13250
+ this.addEventListener("keydown", this.#boundHandleKeyDown);
13251
+ this.addEventListener("scroll", this.#boundSyncOverflow);
13252
+ this.#applyOverflowMode();
13253
+ this.#setupDrag();
13254
+ this.#startObserver();
13255
+ this.#startResizeObserver();
13256
+
13257
+ requestAnimationFrame(() => {
13258
+ this.#syncSelection();
13259
+ this.#syncOverflow();
13260
+ });
14526
13261
  }
14527
13262
 
14528
13263
  disconnectedCallback() {
14529
- this.#teardownRadiusDrag();
13264
+ this.removeEventListener("click", this.#boundHandleClick);
13265
+ this.removeEventListener("keydown", this.#boundHandleKeyDown);
13266
+ this.removeEventListener("scroll", this.#boundSyncOverflow);
13267
+ this.#teardownDrag();
13268
+ this.#mutationObserver?.disconnect();
13269
+ this.#mutationObserver = null;
13270
+ this.#resizeObserver?.disconnect();
13271
+ this.#resizeObserver = null;
13272
+ this.#removeNavButtons();
14530
13273
  }
14531
13274
 
14532
- attributeChangedCallback(name, oldVal, newVal) {
14533
- if (oldVal === newVal) return;
14534
- if (
14535
- name === "value" &&
14536
- !this.#isDragging &&
14537
- !this.#isSecondDragging &&
14538
- !this.#isRadiusDragging &&
14539
- !this.#isAngleDragging
14540
- ) {
14541
- this.#parseValue();
14542
- if (this.#pointHandle) this.#syncPositions();
14543
- else this.#render();
14544
- }
14545
- if (name === "type") {
14546
- this.#parseValue();
14547
- this.#render();
14548
- }
14549
- if (name === "color" && this.#pointHandle) {
14550
- if (newVal) this.#pointHandle.setAttribute("color", newVal);
14551
- else this.#pointHandle.removeAttribute("color");
13275
+ attributeChangedCallback(name, oldValue, newValue) {
13276
+ if (name === "value" && newValue !== oldValue && newValue) {
13277
+ this.#selectByValue(newValue);
14552
13278
  }
14553
13279
  if (name === "disabled") {
14554
- this.#render();
13280
+ const isDisabled = newValue !== null && newValue !== "false";
13281
+ const choices = this.choices;
13282
+ for (const choice of choices) {
13283
+ if (isDisabled) {
13284
+ choice.setAttribute("aria-disabled", "true");
13285
+ choice.setAttribute("tabindex", "-1");
13286
+ } else {
13287
+ choice.removeAttribute("aria-disabled");
13288
+ choice.setAttribute("tabindex", "0");
13289
+ }
13290
+ }
14555
13291
  }
14556
- if (name === "tooltips") {
14557
- this.#render();
13292
+ if (name === "choice-element") {
13293
+ requestAnimationFrame(() => this.#syncSelection());
14558
13294
  }
14559
- if (name === "snapping" && this.#pointHandle) {
14560
- this.#pointHandle.setAttribute("drag-snapping", newVal || "false");
14561
- if (this.#secondHandle)
14562
- this.#secondHandle.setAttribute("drag-snapping", newVal || "false");
13295
+ if (name === "drag") {
13296
+ if (this.#dragEnabled) {
13297
+ this.#setupDrag();
13298
+ } else {
13299
+ this.#teardownDrag();
13300
+ }
14563
13301
  }
14564
- if (name === "name") {
14565
- if (this.#pointTooltip)
14566
- this.#pointTooltip.setAttribute("text", this.#pointTipText);
14567
- if (this.#secondTooltip)
14568
- this.#secondTooltip.setAttribute("text", this.#secondTipText);
13302
+ if (name === "overflow") {
13303
+ this.#applyOverflowMode();
14569
13304
  }
14570
13305
  }
14571
13306
 
14572
- #parseValue() {
14573
- const raw = this.getAttribute("value");
14574
- if (!raw) return;
14575
- try {
14576
- const v = JSON.parse(raw);
14577
- if (typeof v.x === "number") this.#x = v.x;
14578
- if (typeof v.y === "number") this.#y = v.y;
14579
- if (v.radius !== undefined) {
14580
- const rs = String(v.radius);
14581
- if (rs.endsWith("%")) {
14582
- this.#radiusIsPercent = true;
14583
- this.#radius = parseFloat(rs);
14584
- } else {
14585
- this.#radiusIsPercent = false;
14586
- this.#radius = parseFloat(rs);
14587
- }
14588
- if (!Number.isFinite(this.#radius)) this.#radius = 0;
14589
- }
14590
- if (typeof v.angle === "number") this.#angle = v.angle;
14591
- if (typeof v.x2 === "number") this.#x2 = v.x2;
14592
- if (typeof v.y2 === "number") this.#y2 = v.y2;
14593
- } catch {
14594
- /* ignore */
13307
+ #syncSelection() {
13308
+ const choices = this.choices;
13309
+ if (!choices.length) {
13310
+ this.#selectedChoice = null;
13311
+ return;
14595
13312
  }
14596
- }
14597
13313
 
14598
- get value() {
14599
- const v = { x: this.#x, y: this.#y };
14600
- if (this.#type === "color") {
14601
- const color =
14602
- this.getAttribute("color") || this.#pointHandle?.getAttribute("color");
14603
- if (color) v.color = color;
14604
- }
14605
- if (this.#hasRadius) {
14606
- v.radius = this.#radiusIsPercent ? `${this.#radius}%` : this.#radius;
13314
+ const valueAttr = this.getAttribute("value");
13315
+ if (valueAttr && this.#selectByValue(valueAttr)) return;
13316
+
13317
+ const alreadySelected = choices.find((c) => c.hasAttribute("selected"));
13318
+ if (alreadySelected) {
13319
+ this.selectedChoice = alreadySelected;
13320
+ return;
14607
13321
  }
14608
- if (this.#hasAngle) v.angle = this.#angle;
14609
- if (this.#hasSecondPoint) {
14610
- v.x2 = this.#x2;
14611
- v.y2 = this.#y2;
13322
+
13323
+ this.selectedChoice = choices[0];
13324
+ }
13325
+
13326
+ #selectByValue(value) {
13327
+ const choices = this.choices;
13328
+ for (const choice of choices) {
13329
+ if (choice.getAttribute("value") === value) {
13330
+ this.selectedChoice = choice;
13331
+ return true;
13332
+ }
14612
13333
  }
14613
- return v;
13334
+ return false;
14614
13335
  }
14615
13336
 
14616
- set value(val) {
14617
- if (typeof val === "object") {
14618
- this.setAttribute("value", JSON.stringify(val));
14619
- } else if (typeof val === "string") {
14620
- this.setAttribute("value", val);
13337
+ #findChoiceFromTarget(target) {
13338
+ const selector = this.#choiceSelector;
13339
+ let el = target;
13340
+ while (el && el !== this) {
13341
+ if (el.matches(selector)) return el;
13342
+ el = el.parentElement;
14621
13343
  }
13344
+ return null;
14622
13345
  }
14623
13346
 
14624
- #render() {
14625
- this.innerHTML = "";
14626
- this.#pointHandle = null;
14627
- this.#secondHandle = null;
14628
- this.#angleHandle = null;
14629
- this.#radiusSvg = null;
14630
- this.#angleSvg = null;
14631
- this.#pointTooltip = null;
14632
- this.#secondTooltip = null;
14633
- this.#radiusTooltip = null;
14634
- this.#angleTooltip = null;
13347
+ #handleClick(event) {
13348
+ if (
13349
+ this.hasAttribute("disabled") &&
13350
+ this.getAttribute("disabled") !== "false"
13351
+ )
13352
+ return;
13353
+ const choice = this.#findChoiceFromTarget(event.target);
13354
+ if (!choice) return;
13355
+ if (
13356
+ choice.hasAttribute("disabled") &&
13357
+ choice.getAttribute("disabled") !== "false"
13358
+ )
13359
+ return;
13360
+ this.selectedChoice = choice;
13361
+ this.#emitEvents();
13362
+ }
14635
13363
 
14636
- const disabled = this.hasAttribute("disabled");
14637
- const type = this.#type;
14638
- const tooltips = this.#tooltipsEnabled;
14639
-
14640
- const handleSurface = this.#handleDragSurface;
14641
-
14642
- const handle = document.createElement("fig-handle");
14643
- handle.setAttribute("drag", "true");
14644
- handle.setAttribute("drag-surface", handleSurface);
14645
- handle.setAttribute("drag-axes", "x,y");
14646
- handle.setAttribute("drag-snapping", this.#snappingMode);
14647
- handle.setAttribute("value", `${this.#x}% ${this.#y}%`);
14648
- if (disabled) handle.setAttribute("disabled", "");
14649
- if (type === "color") {
14650
- handle.setAttribute("type", "color");
14651
- const color = this.getAttribute("color");
14652
- if (color) handle.setAttribute("color", color);
14653
- }
14654
- if (this.#hasSecondPoint) {
14655
- handle.setAttribute("hit-area", "12 circle");
14656
- handle.setAttribute("hit-area-mode", "delegate");
14657
- }
14658
- this.#pointHandle = handle;
14659
-
14660
- if (this.#hasRadius) {
14661
- this.#createRadiusSvg();
14662
- }
14663
-
14664
- if (this.#hasLine) {
14665
- this.#createAngleSvg();
14666
- }
14667
-
14668
- if (tooltips) {
14669
- const tip = document.createElement("fig-tooltip");
14670
- tip.setAttribute("action", "manual");
14671
- tip.setAttribute("theme", "brand");
14672
- tip.setAttribute("pointer", "false");
14673
- tip.setAttribute("text", this.#pointTipText);
14674
- tip.appendChild(handle);
14675
- this.appendChild(tip);
14676
- this.#pointTooltip = tip;
14677
- } else {
14678
- this.appendChild(handle);
14679
- }
13364
+ #handleKeyDown(event) {
13365
+ if (
13366
+ this.hasAttribute("disabled") &&
13367
+ this.getAttribute("disabled") !== "false"
13368
+ )
13369
+ return;
13370
+ const choices = this.choices.filter(
13371
+ (c) =>
13372
+ !c.hasAttribute("disabled") || c.getAttribute("disabled") === "false",
13373
+ );
13374
+ if (!choices.length) return;
13375
+ const currentIndex = choices.indexOf(this.#selectedChoice);
13376
+ let nextIndex = currentIndex;
14680
13377
 
14681
- if (this.#hasAngle) {
14682
- this.#createAngleHandle(disabled, tooltips, handleSurface);
14683
- }
13378
+ const loop = this.hasAttribute("loop");
14684
13379
 
14685
- if (this.#hasSecondPoint) {
14686
- this.#createSecondHandle(disabled, tooltips, handleSurface);
13380
+ switch (event.key) {
13381
+ case "ArrowDown":
13382
+ case "ArrowRight":
13383
+ event.preventDefault();
13384
+ if (currentIndex < choices.length - 1) {
13385
+ nextIndex = currentIndex + 1;
13386
+ } else if (loop) {
13387
+ nextIndex = 0;
13388
+ }
13389
+ break;
13390
+ case "ArrowUp":
13391
+ case "ArrowLeft":
13392
+ event.preventDefault();
13393
+ if (currentIndex > 0) {
13394
+ nextIndex = currentIndex - 1;
13395
+ } else if (loop) {
13396
+ nextIndex = choices.length - 1;
13397
+ }
13398
+ break;
13399
+ case "Home":
13400
+ event.preventDefault();
13401
+ nextIndex = 0;
13402
+ break;
13403
+ case "End":
13404
+ event.preventDefault();
13405
+ nextIndex = choices.length - 1;
13406
+ break;
13407
+ case "Enter":
13408
+ case " ":
13409
+ event.preventDefault();
13410
+ if (document.activeElement?.matches(this.#choiceSelector)) {
13411
+ const focused = this.#findChoiceFromTarget(document.activeElement);
13412
+ if (focused && focused !== this.#selectedChoice) {
13413
+ this.selectedChoice = focused;
13414
+ this.#emitEvents();
13415
+ }
13416
+ }
13417
+ return;
13418
+ default:
13419
+ return;
14687
13420
  }
14688
13421
 
14689
- this.#setupEventListeners();
14690
- requestAnimationFrame(() => this.#syncPositions());
14691
- }
14692
-
14693
- #createRadiusSvg() {
14694
- const ns = "http://www.w3.org/2000/svg";
14695
- const svg = document.createElementNS(ns, "svg");
14696
- svg.classList.add("fig-canvas-control-radius");
14697
- svg.setAttribute("overflow", "visible");
14698
- const hitCircle = document.createElementNS(ns, "circle");
14699
- hitCircle.classList.add("fig-canvas-control-radius-hit");
14700
- svg.appendChild(hitCircle);
14701
- const circle = document.createElementNS(ns, "circle");
14702
- svg.appendChild(circle);
14703
- this.#radiusSvg = svg;
14704
-
14705
- if (this.#tooltipsEnabled) {
14706
- const tip = document.createElement("fig-tooltip");
14707
- tip.setAttribute("action", "manual");
14708
- tip.setAttribute("theme", "brand");
14709
- tip.setAttribute("pointer", "false");
14710
- tip.setAttribute("text", this.#formatRadius());
14711
- tip.appendChild(svg);
14712
- this.appendChild(tip);
14713
- this.#radiusTooltip = tip;
14714
- } else {
14715
- this.appendChild(svg);
14716
- }
14717
-
14718
- this.#setupRadiusDrag(hitCircle);
14719
- }
14720
-
14721
- #createAngleSvg() {
14722
- const ns = "http://www.w3.org/2000/svg";
14723
- const svg = document.createElementNS(ns, "svg");
14724
- svg.classList.add("fig-canvas-control-angle-svg");
14725
- svg.setAttribute("overflow", "visible");
14726
- svg.style.position = "absolute";
14727
- svg.style.pointerEvents = "none";
14728
- const line = document.createElementNS(ns, "line");
14729
- line.classList.add("fig-canvas-control-angle-line");
14730
- svg.appendChild(line);
14731
- this.#angleSvg = svg;
14732
- this.appendChild(svg);
14733
- }
14734
-
14735
- #createAngleHandle(disabled, tooltips, handleSurface) {
14736
- const handle = document.createElement("fig-handle");
14737
- handle.setAttribute("drag", "true");
14738
- handle.setAttribute("drag-surface", handleSurface);
14739
- handle.setAttribute("drag-axes", "x,y");
14740
- handle.setAttribute("size", "small");
14741
- handle.setAttribute("hit-area", "12 circle");
14742
- handle.setAttribute("hit-area-mode", "delegate");
14743
- if (disabled) handle.setAttribute("disabled", "");
14744
- this.#angleHandle = handle;
14745
-
14746
- if (tooltips) {
14747
- const tip = document.createElement("fig-tooltip");
14748
- tip.setAttribute("action", "manual");
14749
- tip.setAttribute("theme", "brand");
14750
- tip.setAttribute("pointer", "false");
14751
- tip.setAttribute("text", `${Math.round(this.#angle)}°`);
14752
- tip.appendChild(handle);
14753
- this.appendChild(tip);
14754
- this.#angleTooltip = tip;
14755
- } else {
14756
- this.appendChild(handle);
14757
- }
14758
- }
14759
-
14760
- #createSecondHandle(disabled, tooltips, handleSurface) {
14761
- const handle = document.createElement("fig-handle");
14762
- handle.setAttribute("drag", "true");
14763
- handle.setAttribute("drag-surface", handleSurface);
14764
- handle.setAttribute("drag-axes", "x,y");
14765
- handle.setAttribute("drag-snapping", this.#snappingMode);
14766
- handle.setAttribute("hit-area", "12 circle");
14767
- handle.setAttribute("hit-area-mode", "delegate");
14768
- handle.setAttribute("value", `${this.#x2}% ${this.#y2}%`);
14769
- if (disabled) handle.setAttribute("disabled", "");
14770
- this.#secondHandle = handle;
14771
-
14772
- if (tooltips) {
14773
- const tip = document.createElement("fig-tooltip");
14774
- tip.setAttribute("action", "manual");
14775
- tip.setAttribute("theme", "brand");
14776
- tip.setAttribute("pointer", "false");
14777
- tip.setAttribute("text", this.#secondTipText);
14778
- tip.appendChild(handle);
14779
- this.appendChild(tip);
14780
- this.#secondTooltip = tip;
14781
- } else {
14782
- this.appendChild(handle);
13422
+ if (nextIndex !== currentIndex && choices[nextIndex]) {
13423
+ this.selectedChoice = choices[nextIndex];
13424
+ choices[nextIndex].focus();
13425
+ this.#emitEvents();
14783
13426
  }
14784
13427
  }
14785
13428
 
14786
- #resizeCursorSvg(deg) {
14787
- const r = Math.round(deg);
14788
- return `url("data:image/svg+xml,%3Csvg width='32' height='32' viewBox='0 0 32 32' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform='rotate(${r} 16 16)'%3E%3Cg filter='url(%23f)'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11.1212 16.9998L11.5607 17.4394C12.1465 18.0252 12.1464 18.975 11.5606 19.5607C10.9748 20.1465 10.0251 20.1465 9.4393 19.5606L6.4393 16.5604C5.85354 15.9746 5.85357 15.0249 6.43938 14.4391L9.43938 11.4393C10.0252 10.8535 10.9749 10.8536 11.5607 11.4394C12.1465 12.0252 12.1464 12.9749 11.5606 13.5607L11.1215 13.9998L20.8786 13.9999L20.4394 13.5607C19.8536 12.9749 19.8535 12.0252 20.4393 11.4394C21.0251 10.8536 21.9749 10.8536 22.5606 11.4394L25.5606 14.4393C25.842 14.7206 26 15.1021 26 15.4999C26 15.8978 25.842 16.2793 25.5607 16.5606L22.5607 19.5607C21.9749 20.1465 21.0251 20.1465 20.4393 19.5607C19.8536 18.9749 19.8535 18.0252 20.4393 17.4394L20.8788 16.9999L11.1212 16.9998Z' fill='white'/%3E%3C/g%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10.8536 12.1465C11.0488 12.3417 11.0488 12.6583 10.8535 12.8536L8.70715 14.9998L23.2929 14.9999L21.1465 12.8536C20.9512 12.6583 20.9512 12.3417 21.1464 12.1465C21.3417 11.9512 21.6583 11.9512 21.8535 12.1465L24.8535 15.1464C24.9473 15.2402 25 15.3673 25 15.4999C25 15.6326 24.9473 15.7597 24.8536 15.8535L21.8536 18.8536C21.6583 19.0488 21.3417 19.0488 21.1465 18.8536C20.9512 18.6583 20.9512 18.3417 21.1464 18.1465L23.2929 15.9999L8.70705 15.9998L10.8536 18.1465C11.0488 18.3417 11.0488 18.6583 10.8535 18.8536C10.6583 19.0488 10.3417 19.0488 10.1464 18.8535L7.14643 15.8533C6.95118 15.658 6.95119 15.3415 7.14646 15.1462L10.1465 12.1464C10.3417 11.9512 10.6583 11.9512 10.8536 12.1465Z' fill='black'/%3E%3C/g%3E%3Cdefs%3E%3Cfilter id='f' x='3' y='9' width='26' height='15' filterUnits='userSpaceOnUse' color-interpolation-filters='sRGB'%3E%3CfeFlood flood-opacity='0' result='a'/%3E%3CfeColorMatrix in='SourceAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' result='b'/%3E%3CfeOffset dy='1'/%3E%3CfeGaussianBlur stdDeviation='1.5'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.35 0'/%3E%3CfeBlend in2='a' result='c'/%3E%3CfeBlend in='SourceGraphic' in2='c'/%3E%3C/filter%3E%3C/defs%3E%3C/svg%3E") 16 16, nwse-resize`;
13429
+ #emitEvents() {
13430
+ const val = this.value;
13431
+ this.dispatchEvent(
13432
+ new CustomEvent("input", { detail: val, bubbles: true }),
13433
+ );
13434
+ this.dispatchEvent(
13435
+ new CustomEvent("change", { detail: val, bubbles: true }),
13436
+ );
14789
13437
  }
14790
13438
 
14791
- #rotateCursorSvg(deg) {
14792
- const r = Math.round(deg - 45);
14793
- return `url("data:image/svg+xml,%3Csvg width='32' height='32' viewBox='0 0 32 32' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform='rotate(${r} 16 16)'%3E%3Cg filter='url(%23f)'%3E%3Cpath d='M12.5607 22.4393L12.0216 21.9002C17.1558 21.2216 21.2216 17.1558 21.9002 12.0216L22.4393 12.5607C23.0251 13.1464 23.9749 13.1464 24.5607 12.5607C25.1464 11.9749 25.1464 11.0251 24.5607 10.4393L21.5607 7.43934C20.9749 6.85355 20.0251 6.85355 19.4393 7.43934L16.4393 10.4393C15.8536 11.0251 15.8536 11.9749 16.4393 12.5607C17.0251 13.1464 17.9749 13.1464 18.5607 12.5607L18.8056 12.3157C18.1013 15.5527 15.5527 18.1013 12.3157 18.8056L12.5607 18.5607C13.1464 17.9749 13.1464 17.0251 12.5607 16.4393C11.9749 15.8536 11.0251 15.8536 10.4393 16.4393L7.43934 19.4393C6.85356 20.0251 6.85356 20.9749 7.43934 21.5607L10.4393 24.5607C11.0251 25.1464 11.9749 25.1464 12.5607 24.5607C13.1464 23.9749 13.1464 23.0251 12.5607 22.4393Z' fill='white'/%3E%3C/g%3E%3Cpath d='M23.8536 11.8536C23.6583 12.0488 23.3417 12.0488 23.1464 11.8536L21 9.70711V10.5C21 16.299 16.299 21 10.5 21H9.70711L11.8536 23.1464C12.0488 23.3417 12.0488 23.6583 11.8536 23.8536C11.6583 24.0488 11.3417 24.0488 11.1464 23.8536L8.14645 20.8536C7.95119 20.6583 7.95119 20.3417 8.14645 20.1464L11.1464 17.1464C11.3417 16.9512 11.6583 16.9512 11.8536 17.1464C12.0488 17.3417 12.0488 17.6583 11.8536 17.8536L9.70711 20H10.5C15.7467 20 20 15.7467 20 10.5V9.70711L17.8536 11.8536C17.6583 12.0488 17.3417 12.0488 17.1464 11.8536C16.9512 11.6583 16.9512 11.3417 17.1464 11.1464L20.1464 8.14645C20.3417 7.95119 20.6583 7.95119 20.8536 8.14645L23.8536 11.1464C24.0488 11.3417 24.0488 11.6583 23.8536 11.8536Z' fill='black'/%3E%3C/g%3E%3Cdefs%3E%3Cfilter id='f' x='4' y='5' width='24' height='24' filterUnits='userSpaceOnUse' color-interpolation-filters='sRGB'%3E%3CfeFlood flood-opacity='0' result='a'/%3E%3CfeColorMatrix in='SourceAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' result='b'/%3E%3CfeOffset dy='1'/%3E%3CfeGaussianBlur stdDeviation='1.5'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.35 0'/%3E%3CfeBlend in2='a' result='c'/%3E%3CfeBlend in='SourceGraphic' in2='c'/%3E%3C/filter%3E%3C/defs%3E%3C/svg%3E") 16 16, pointer`;
14794
- }
13439
+ #syncOverflow() {
13440
+ if (this.#overflowMode === "scrollbar") return;
13441
+ const isHorizontal = this.getAttribute("layout") === "horizontal";
13442
+ const threshold = 2;
14795
13443
 
14796
- #syncAngleCursor() {
14797
- if (!this.#angleHandle || !this.#hasAngle) return;
14798
- const hitArea = this.#angleHandle.querySelector(".fig-handle-hit-area");
14799
- if (!hitArea) return;
14800
- hitArea.style.cursor = this.#rotateCursorSvg(this.#angle);
13444
+ if (isHorizontal) {
13445
+ const atStart = this.scrollLeft <= threshold;
13446
+ const atEnd =
13447
+ this.scrollLeft + this.clientWidth >= this.scrollWidth - threshold;
13448
+ this.classList.toggle("overflow-start", !atStart);
13449
+ this.classList.toggle("overflow-end", !atEnd);
13450
+ } else {
13451
+ const atStart = this.scrollTop <= threshold;
13452
+ const atEnd =
13453
+ this.scrollTop + this.clientHeight >= this.scrollHeight - threshold;
13454
+ this.classList.toggle("overflow-start", !atStart);
13455
+ this.classList.toggle("overflow-end", !atEnd);
13456
+ }
14801
13457
  }
14802
13458
 
14803
- #pointPointLineDeg() {
14804
- return (Math.atan2(this.#y2 - this.#y, this.#x2 - this.#x) * 180) / Math.PI;
13459
+ #startResizeObserver() {
13460
+ this.#resizeObserver?.disconnect();
13461
+ this.#resizeObserver = new ResizeObserver(() => {
13462
+ this.#syncOverflow();
13463
+ });
13464
+ this.#resizeObserver.observe(this);
14805
13465
  }
14806
13466
 
14807
- #syncPointPointCursors() {
14808
- if (!this.#hasSecondPoint) return;
14809
- const deg = this.#pointPointLineDeg();
14810
- const setHitCursor = (handle, rotateDeg) => {
14811
- if (!handle) return;
14812
- const hitArea = handle.querySelector(".fig-handle-hit-area");
14813
- if (hitArea) hitArea.style.cursor = this.#rotateCursorSvg(rotateDeg);
13467
+ #setupDrag() {
13468
+ if (this.#dragState?.bound) return;
13469
+ if (!this.#dragEnabled) return;
13470
+
13471
+ const onPointerDown = (e) => {
13472
+ if (e.button !== 0) return;
13473
+ const isHorizontal = this.getAttribute("layout") === "horizontal";
13474
+ const hasOverflow = isHorizontal
13475
+ ? this.scrollWidth > this.clientWidth
13476
+ : this.scrollHeight > this.clientHeight;
13477
+ if (!hasOverflow) return;
13478
+
13479
+ this.#dragState.active = true;
13480
+ this.#dragState.didDrag = false;
13481
+ this.#dragState.startX = e.clientX;
13482
+ this.#dragState.startY = e.clientY;
13483
+ this.#dragState.scrollLeft = this.scrollLeft;
13484
+ this.#dragState.scrollTop = this.scrollTop;
13485
+ this.style.cursor = "grab";
13486
+ this.style.userSelect = "none";
14814
13487
  };
14815
- setHitCursor(this.#pointHandle, deg + 180);
14816
- setHitCursor(this.#secondHandle, deg);
14817
- }
14818
13488
 
14819
- #positionHandle(handle, xPct, yPct, rect) {
14820
- const hw = handle.offsetWidth / 2;
14821
- const hh = handle.offsetHeight / 2;
14822
- handle.style.left = `${(xPct / 100) * rect.width - hw}px`;
14823
- handle.style.top = `${(yPct / 100) * rect.height - hh}px`;
14824
- }
13489
+ const onPointerMove = (e) => {
13490
+ if (!this.#dragState.active) return;
13491
+ const isHorizontal = this.getAttribute("layout") === "horizontal";
13492
+ const dx = e.clientX - this.#dragState.startX;
13493
+ const dy = e.clientY - this.#dragState.startY;
13494
+
13495
+ if (!this.#dragState.didDrag && Math.abs(isHorizontal ? dx : dy) > 3) {
13496
+ this.#dragState.didDrag = true;
13497
+ this.style.cursor = "grabbing";
13498
+ this.setPointerCapture(e.pointerId);
13499
+ }
14825
13500
 
14826
- #syncPositions() {
14827
- const container = this.#container;
14828
- if (!container || !this.#pointHandle) return;
14829
- const rect = container.getBoundingClientRect();
13501
+ if (!this.#dragState.didDrag) return;
14830
13502
 
14831
- this.#positionHandle(this.#pointHandle, this.#x, this.#y, rect);
14832
-
14833
- if (this.#radiusSvg) {
14834
- const cx = (this.#x / 100) * rect.width;
14835
- const cy = (this.#y / 100) * rect.height;
14836
- const r = this.#resolveRadius(rect.width);
14837
- const svg = this.#radiusSvg;
14838
- const d = Math.max(r * 2, 1);
14839
- svg.style.position = "absolute";
14840
- svg.style.width = `${d}px`;
14841
- svg.style.height = `${d}px`;
14842
- svg.style.left = `${cx - r}px`;
14843
- svg.style.top = `${cy - r}px`;
14844
- svg.setAttribute("viewBox", `0 0 ${d} ${d}`);
14845
- const circles = svg.querySelectorAll("circle");
14846
- for (const c of circles) {
14847
- c.setAttribute("cx", String(r));
14848
- c.setAttribute("cy", String(r));
14849
- c.setAttribute("r", String(Math.max(r - 1, 0)));
14850
- }
14851
- }
14852
-
14853
- if (this.#angleSvg && this.#hasLine) {
14854
- const cx = (this.#x / 100) * rect.width;
14855
- const cy = (this.#y / 100) * rect.height;
14856
- let lx2, ly2;
14857
- if (this.#hasSecondPoint) {
14858
- lx2 = (this.#x2 / 100) * rect.width;
14859
- ly2 = (this.#y2 / 100) * rect.height;
13503
+ if (isHorizontal) {
13504
+ this.scrollLeft = this.#dragState.scrollLeft - dx;
14860
13505
  } else {
14861
- const r = this.#resolveRadius(rect.width);
14862
- const angleRad = (this.#angle * Math.PI) / 180;
14863
- lx2 = cx + r * Math.cos(angleRad);
14864
- ly2 = cy + r * Math.sin(angleRad);
13506
+ this.scrollTop = this.#dragState.scrollTop - dy;
14865
13507
  }
13508
+ };
14866
13509
 
14867
- const svg = this.#angleSvg;
14868
- svg.style.width = `${rect.width}px`;
14869
- svg.style.height = `${rect.height}px`;
14870
- svg.style.left = "0";
14871
- svg.style.top = "0";
14872
- svg.setAttribute("viewBox", `0 0 ${rect.width} ${rect.height}`);
14873
- const line = svg.querySelector("line");
14874
- if (line) {
14875
- line.setAttribute("x1", String(cx));
14876
- line.setAttribute("y1", String(cy));
14877
- line.setAttribute("x2", String(lx2));
14878
- line.setAttribute("y2", String(ly2));
13510
+ const onPointerUp = (e) => {
13511
+ if (!this.#dragState.active) return;
13512
+ const wasDrag = this.#dragState.didDrag;
13513
+ this.#dragState.active = false;
13514
+ this.#dragState.didDrag = false;
13515
+ this.style.cursor = "";
13516
+ this.style.userSelect = "";
13517
+ if (e.pointerId !== undefined) {
13518
+ try {
13519
+ this.releasePointerCapture(e.pointerId);
13520
+ } catch {}
14879
13521
  }
14880
- }
13522
+ if (wasDrag) {
13523
+ e.preventDefault();
13524
+ e.stopPropagation();
13525
+ }
13526
+ };
14881
13527
 
14882
- if (this.#angleHandle && this.#hasAngle) {
14883
- const cx = (this.#x / 100) * rect.width;
14884
- const cy = (this.#y / 100) * rect.height;
14885
- const r = this.#resolveRadius(rect.width);
14886
- const angleRad = (this.#angle * Math.PI) / 180;
14887
- const ax = cx + r * Math.cos(angleRad);
14888
- const ay = cy + r * Math.sin(angleRad);
14889
- const pxPct = rect.width > 0 ? (ax / rect.width) * 100 : 0;
14890
- const pyPct = rect.height > 0 ? (ay / rect.height) * 100 : 0;
14891
- this.#positionHandle(this.#angleHandle, pxPct, pyPct, rect);
14892
- }
13528
+ const onClick = (e) => {
13529
+ if (this.#dragState?.suppressClick) {
13530
+ e.stopPropagation();
13531
+ e.preventDefault();
13532
+ this.#dragState.suppressClick = false;
13533
+ }
13534
+ };
14893
13535
 
14894
- if (this.#secondHandle && this.#hasSecondPoint) {
14895
- this.#positionHandle(this.#secondHandle, this.#x2, this.#y2, rect);
14896
- }
13536
+ const onPointerUpCapture = (e) => {
13537
+ if (this.#dragState?.didDrag) {
13538
+ this.#dragState.suppressClick = true;
13539
+ setTimeout(() => {
13540
+ if (this.#dragState) this.#dragState.suppressClick = false;
13541
+ }, 0);
13542
+ }
13543
+ };
13544
+
13545
+ this.#dragState = {
13546
+ active: false,
13547
+ didDrag: false,
13548
+ suppressClick: false,
13549
+ startX: 0,
13550
+ startY: 0,
13551
+ scrollLeft: 0,
13552
+ scrollTop: 0,
13553
+ bound: true,
13554
+ onPointerDown,
13555
+ onPointerMove,
13556
+ onPointerUp,
13557
+ onClick,
13558
+ onPointerUpCapture,
13559
+ };
14897
13560
 
14898
- this.#syncAngleCursor();
14899
- this.#syncPointPointCursors();
13561
+ this.addEventListener("pointerdown", onPointerDown);
13562
+ window.addEventListener("pointermove", onPointerMove);
13563
+ window.addEventListener("pointerup", onPointerUp);
13564
+ this.addEventListener("pointerup", onPointerUpCapture, true);
13565
+ this.addEventListener("click", onClick, true);
14900
13566
  }
14901
13567
 
14902
- #emitInput() {
14903
- this.dispatchEvent(
14904
- new CustomEvent("input", { bubbles: true, detail: this.value }),
13568
+ #teardownDrag() {
13569
+ if (!this.#dragState?.bound) return;
13570
+ this.removeEventListener("pointerdown", this.#dragState.onPointerDown);
13571
+ window.removeEventListener("pointermove", this.#dragState.onPointerMove);
13572
+ window.removeEventListener("pointerup", this.#dragState.onPointerUp);
13573
+ this.removeEventListener(
13574
+ "pointerup",
13575
+ this.#dragState.onPointerUpCapture,
13576
+ true,
14905
13577
  );
13578
+ this.removeEventListener("click", this.#dragState.onClick, true);
13579
+ this.style.cursor = "";
13580
+ this.style.userSelect = "";
13581
+ this.#dragState = null;
14906
13582
  }
14907
13583
 
14908
- #emitChange() {
14909
- this.dispatchEvent(
14910
- new CustomEvent("change", { bubbles: true, detail: this.value }),
14911
- );
13584
+ #applyOverflowMode() {
13585
+ if (this.#overflowMode === "scrollbar") {
13586
+ this.#removeNavButtons();
13587
+ } else {
13588
+ this.#createNavButtons();
13589
+ }
14912
13590
  }
14913
13591
 
14914
- #syncValueAttribute() {
14915
- this.setAttribute("value", JSON.stringify(this.value));
13592
+ #removeNavButtons() {
13593
+ this.#navStart?.remove();
13594
+ this.#navEnd?.remove();
13595
+ this.#navStart = null;
13596
+ this.#navEnd = null;
13597
+ this.classList.remove("overflow-start", "overflow-end");
14916
13598
  }
14917
13599
 
14918
- #setupEventListeners() {
14919
- if (!this.#pointHandle) return;
13600
+ #createNavButtons() {
13601
+ if (this.#navStart) return;
13602
+
13603
+ this.#navStart = document.createElement("button");
13604
+ this.#navStart.className = "fig-chooser-nav-start";
13605
+ this.#navStart.setAttribute("tabindex", "-1");
13606
+ this.#navStart.setAttribute("aria-label", "Scroll back");
13607
+
13608
+ this.#navEnd = document.createElement("button");
13609
+ this.#navEnd.className = "fig-chooser-nav-end";
13610
+ this.#navEnd.setAttribute("tabindex", "-1");
13611
+ this.#navEnd.setAttribute("aria-label", "Scroll forward");
14920
13612
 
14921
- this.#pointHandle.addEventListener("input", (e) => {
13613
+ this.#navStart.addEventListener("pointerdown", (e) => {
14922
13614
  e.stopPropagation();
14923
- if (e.detail?.color) {
14924
- this.setAttribute("color", e.detail.color);
14925
- this.#emitInput();
14926
- return;
14927
- }
14928
- if (!this.#isDragging && this.#hasSecondPoint) {
14929
- this.#prevBodyCursor = document.body.style.cursor;
14930
- }
14931
- this.#isDragging = true;
14932
- const px = e.detail?.px ?? this.#x / 100;
14933
- const py = e.detail?.py ?? this.#y / 100;
14934
- this.#x = Math.round(Math.max(0, Math.min(100, px * 100)));
14935
- this.#y = Math.round(Math.max(0, Math.min(100, py * 100)));
14936
- if (this.#pointTooltip && this.#type !== "color") {
14937
- this.#pointTooltip.setAttribute("text", this.#pointTipText);
14938
- this.#pointTooltip.setAttribute("show", "true");
14939
- this.#pointTooltip.showPopup?.();
14940
- }
14941
- this.#syncPositions();
14942
- if (this.#hasSecondPoint) {
14943
- document.body.style.cursor = this.#resizeCursorSvg(
14944
- this.#pointPointLineDeg(),
14945
- );
14946
- }
14947
- this.#emitInput();
13615
+ this.#scrollByPage(-1);
14948
13616
  });
14949
13617
 
14950
- this.#pointHandle.addEventListener("change", (e) => {
13618
+ this.#navEnd.addEventListener("pointerdown", (e) => {
14951
13619
  e.stopPropagation();
14952
- if (e.detail?.color) {
14953
- this.setAttribute("color", e.detail.color);
14954
- this.#emitChange();
14955
- return;
14956
- }
14957
- const px = e.detail?.px ?? this.#x / 100;
14958
- const py = e.detail?.py ?? this.#y / 100;
14959
- this.#x = Math.round(Math.max(0, Math.min(100, px * 100)));
14960
- this.#y = Math.round(Math.max(0, Math.min(100, py * 100)));
14961
- if (this.#pointTooltip && this.#type !== "color")
14962
- this.#pointTooltip.removeAttribute("show");
14963
- if (this.#hasSecondPoint) {
14964
- document.body.style.cursor = this.#prevBodyCursor ?? "";
14965
- }
14966
- this.#syncPositions();
14967
- this.#syncValueAttribute();
14968
- this.#emitChange();
14969
- requestAnimationFrame(() => {
14970
- this.#isDragging = false;
14971
- });
13620
+ this.#scrollByPage(1);
14972
13621
  });
14973
13622
 
14974
- if (this.#angleHandle) {
14975
- this.#angleHandle.addEventListener("input", (e) => {
14976
- e.stopPropagation();
14977
- this.#isAngleDragging = true;
14978
- this.classList.add("fig-canvas-control-ring-active");
14979
- const container = this.#container;
14980
- if (!container) return;
14981
- const rect = container.getBoundingClientRect();
14982
- const cx = (this.#x / 100) * rect.width;
14983
- const cy = (this.#y / 100) * rect.height;
14984
- const hx = e.detail?.x ?? 0;
14985
- const hy = e.detail?.y ?? 0;
14986
- const hw = this.#angleHandle.offsetWidth / 2;
14987
- const hh = this.#angleHandle.offsetHeight / 2;
14988
- const dx = hx + hw - cx;
14989
- const dy = hy + hh - cy;
14990
- let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
14991
- if (this.#shouldSnap(e.detail?.shiftKey)) {
14992
- angle = Math.round(angle / 15) * 15;
14993
- }
14994
- this.#angle = angle;
14995
-
14996
- let dist = Math.sqrt(dx * dx + dy * dy);
14997
- if (this.#shouldSnap(e.detail?.shiftKey)) {
14998
- const step = this.#radiusIsPercent ? 5 : 10;
14999
- if (this.#radiusIsPercent) {
15000
- let pct = (dist / rect.width) * 100;
15001
- pct = Math.round(pct / step) * step;
15002
- dist = (pct / 100) * rect.width;
15003
- } else {
15004
- dist = Math.round(dist / step) * step;
15005
- }
15006
- }
15007
- if (this.#radiusIsPercent) {
15008
- this.#radius = Math.max(0, (dist / rect.width) * 100);
15009
- } else {
15010
- this.#radius = Math.max(0, dist);
15011
- }
15012
-
15013
- if (this.#angleTooltip) {
15014
- this.#angleTooltip.setAttribute(
15015
- "text",
15016
- `Angle ${Math.round(this.#angle)}°`,
15017
- );
15018
- this.#angleTooltip.setAttribute("show", "true");
15019
- this.#angleTooltip.showPopup?.();
15020
- }
15021
- this.#syncPositions();
15022
- this.#emitInput();
15023
- });
15024
-
15025
- this.#angleHandle.addEventListener("change", (e) => {
15026
- e.stopPropagation();
15027
- this.classList.remove("fig-canvas-control-ring-active");
15028
- if (this.#angleTooltip) this.#angleTooltip.removeAttribute("show");
15029
- this.#syncPositions();
15030
- this.#syncValueAttribute();
15031
- this.#emitChange();
15032
- requestAnimationFrame(() => {
15033
- this.#isAngleDragging = false;
15034
- });
15035
- });
15036
-
15037
- this.#angleHandle.addEventListener("hitareadown", (e) => {
15038
- e.stopPropagation();
15039
- const origEvent = e.detail?.originalEvent;
15040
- if (!origEvent) return;
15041
- origEvent.preventDefault();
15042
- this.#isAngleDragging = true;
15043
- this.classList.add("fig-canvas-control-ring-active");
15044
- const container = this.#container;
15045
- if (!container) return;
15046
-
15047
- if (this.#angleTooltip) {
15048
- this.#angleTooltip.setAttribute("show", "true");
15049
- this.#angleTooltip.showPopup?.();
15050
- }
15051
-
15052
- const prevBodyCursor = document.body.style.cursor;
15053
- let lastCursorDeg = Math.round(this.#angle);
15054
- document.body.style.cursor = this.#rotateCursorSvg(lastCursorDeg);
15055
-
15056
- const onMove = (ev) => {
15057
- const rect = container.getBoundingClientRect();
15058
- const cx = (this.#x / 100) * rect.width;
15059
- const cy = (this.#y / 100) * rect.height;
15060
- const dx = ev.clientX - rect.left - cx;
15061
- const dy = ev.clientY - rect.top - cy;
15062
- let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
15063
- if (this.#shouldSnap(ev.shiftKey)) {
15064
- angle = Math.round(angle / 15) * 15;
15065
- }
15066
- this.#angle = angle;
15067
- if (this.#angleTooltip)
15068
- this.#angleTooltip.setAttribute(
15069
- "text",
15070
- `Angle ${Math.round(angle)}°`,
15071
- );
15072
- this.#syncPositions();
15073
- const curDeg = Math.round(angle);
15074
- if (curDeg !== lastCursorDeg) {
15075
- lastCursorDeg = curDeg;
15076
- document.body.style.cursor = this.#rotateCursorSvg(curDeg);
15077
- }
15078
- this.#emitInput();
15079
- };
15080
-
15081
- const onUp = () => {
15082
- this.#isAngleDragging = false;
15083
- this.classList.remove("fig-canvas-control-ring-active");
15084
- document.body.style.cursor = prevBodyCursor;
15085
- if (this.#angleTooltip) this.#angleTooltip.removeAttribute("show");
15086
- this.#syncValueAttribute();
15087
- this.#emitChange();
15088
- window.removeEventListener("pointermove", onMove);
15089
- window.removeEventListener("pointerup", onUp);
15090
- };
15091
-
15092
- window.addEventListener("pointermove", onMove);
15093
- window.addEventListener("pointerup", onUp);
15094
- });
15095
- }
15096
-
15097
- if (this.#secondHandle) {
15098
- this.#secondHandle.addEventListener("input", (e) => {
15099
- e.stopPropagation();
15100
- if (!this.#isSecondDragging) {
15101
- this.#prevBodyCursor = document.body.style.cursor;
15102
- }
15103
- this.#isSecondDragging = true;
15104
- const px = e.detail?.px ?? this.#x2 / 100;
15105
- const py = e.detail?.py ?? this.#y2 / 100;
15106
- this.#x2 = Math.round(Math.max(0, Math.min(100, px * 100)));
15107
- this.#y2 = Math.round(Math.max(0, Math.min(100, py * 100)));
15108
- if (this.#secondTooltip) {
15109
- this.#secondTooltip.setAttribute("text", this.#secondTipText);
15110
- this.#secondTooltip.setAttribute("show", "true");
15111
- this.#secondTooltip.showPopup?.();
15112
- }
15113
- this.#syncPositions();
15114
- document.body.style.cursor = this.#resizeCursorSvg(
15115
- this.#pointPointLineDeg(),
15116
- );
15117
- this.#emitInput();
15118
- });
13623
+ this.prepend(this.#navStart);
13624
+ this.append(this.#navEnd);
13625
+ }
15119
13626
 
15120
- this.#secondHandle.addEventListener("change", (e) => {
15121
- e.stopPropagation();
15122
- document.body.style.cursor = this.#prevBodyCursor ?? "";
15123
- if (this.#secondTooltip) this.#secondTooltip.removeAttribute("show");
15124
- this.#syncPositions();
15125
- this.#syncValueAttribute();
15126
- this.#emitChange();
15127
- requestAnimationFrame(() => {
15128
- this.#isSecondDragging = false;
15129
- });
15130
- });
13627
+ #scrollByPage(direction) {
13628
+ const isHorizontal = this.getAttribute("layout") === "horizontal";
13629
+ const pageSize = isHorizontal ? this.clientWidth : this.clientHeight;
13630
+ const scrollAmount = pageSize * 0.8 * direction;
15131
13631
 
15132
- this.#setupPointPointHitArea(this.#pointHandle, true);
15133
- this.#setupPointPointHitArea(this.#secondHandle, false);
15134
- }
13632
+ this.scrollBy({
13633
+ [isHorizontal ? "left" : "top"]: scrollAmount,
13634
+ behavior: "smooth",
13635
+ });
15135
13636
  }
15136
13637
 
15137
- #setupPointPointHitArea(handle, isFirst) {
15138
- if (!handle) return;
15139
- handle.addEventListener("hitareadown", (e) => {
15140
- e.stopPropagation();
15141
- const origEvent = e.detail?.originalEvent;
15142
- if (!origEvent) return;
15143
- origEvent.preventDefault();
15144
- this.#isDragging = true;
15145
- const container = this.#container;
15146
- if (!container) return;
15147
- const rect = container.getBoundingClientRect();
13638
+ #scrollToChoice(el) {
13639
+ if (!el) return;
13640
+ requestAnimationFrame(() => {
13641
+ const overflowY = this.scrollHeight > this.clientHeight;
13642
+ const overflowX = this.scrollWidth > this.clientWidth;
13643
+ if (!overflowX && !overflowY) return;
15148
13644
 
15149
- const pivotX = isFirst ? this.#x2 : this.#x;
15150
- const pivotY = isFirst ? this.#y2 : this.#y;
15151
- const movingX = isFirst ? this.#x : this.#x2;
15152
- const movingY = isFirst ? this.#y : this.#y2;
15153
- const pcx = (pivotX / 100) * rect.width;
15154
- const pcy = (pivotY / 100) * rect.height;
15155
- const mcx = (movingX / 100) * rect.width;
15156
- const mcy = (movingY / 100) * rect.height;
15157
- const fixedLen = Math.sqrt((mcx - pcx) ** 2 + (mcy - pcy) ** 2);
15158
-
15159
- const tooltip = isFirst ? this.#pointTooltip : this.#secondTooltip;
15160
- if (tooltip) {
15161
- tooltip.setAttribute("show", "true");
15162
- tooltip.showPopup?.();
15163
- }
15164
-
15165
- const prevBodyCursor = document.body.style.cursor;
15166
- const initDeg = this.#pointPointLineDeg();
15167
- let lastCursorDeg = Math.round(isFirst ? initDeg + 180 : initDeg);
15168
- document.body.style.cursor = this.#rotateCursorSvg(lastCursorDeg);
15169
-
15170
- const onMove = (ev) => {
15171
- const r = container.getBoundingClientRect();
15172
- const px = (pivotX / 100) * r.width;
15173
- const py = (pivotY / 100) * r.height;
15174
- const dx = ev.clientX - r.left - px;
15175
- const dy = ev.clientY - r.top - py;
15176
- let angle = Math.atan2(dy, dx);
15177
- if (this.#shouldSnap(ev.shiftKey)) {
15178
- const snapDeg = Math.round((angle * 180) / Math.PI / 15) * 15;
15179
- angle = (snapDeg * Math.PI) / 180;
15180
- }
15181
- const nx = px + fixedLen * Math.cos(angle);
15182
- const ny = py + fixedLen * Math.sin(angle);
15183
- const newPctX = Math.max(0, Math.min(100, (nx / r.width) * 100));
15184
- const newPctY = Math.max(0, Math.min(100, (ny / r.height) * 100));
15185
- if (isFirst) {
15186
- this.#x = newPctX;
15187
- this.#y = newPctY;
15188
- } else {
15189
- this.#x2 = newPctX;
15190
- this.#y2 = newPctY;
15191
- }
15192
- this.#syncPositions();
15193
- const curDeg = Math.round(
15194
- isFirst ? this.#pointPointLineDeg() + 180 : this.#pointPointLineDeg(),
15195
- );
15196
- if (curDeg !== lastCursorDeg) {
15197
- lastCursorDeg = curDeg;
15198
- document.body.style.cursor = this.#rotateCursorSvg(curDeg);
15199
- }
15200
- this.#emitInput();
15201
- };
13645
+ const options = { behavior: "smooth" };
15202
13646
 
15203
- const onUp = () => {
15204
- this.#isDragging = false;
15205
- document.body.style.cursor = prevBodyCursor;
15206
- if (tooltip) tooltip.removeAttribute("show");
15207
- this.#syncValueAttribute();
15208
- this.#emitChange();
15209
- window.removeEventListener("pointermove", onMove);
15210
- window.removeEventListener("pointerup", onUp);
15211
- };
13647
+ if (overflowY) {
13648
+ const target =
13649
+ el.offsetTop - this.clientHeight / 2 + el.offsetHeight / 2;
13650
+ options.top = target;
13651
+ }
15212
13652
 
15213
- window.addEventListener("pointermove", onMove);
15214
- window.addEventListener("pointerup", onUp);
15215
- });
15216
- }
13653
+ if (overflowX) {
13654
+ const target =
13655
+ el.offsetLeft - this.clientWidth / 2 + el.offsetWidth / 2;
13656
+ options.left = target;
13657
+ }
15217
13658
 
15218
- #setupRadiusDrag(circle) {
15219
- if (!circle) return;
15220
- circle.addEventListener("pointermove", (e) => {
15221
- if (this.#isRadiusDragging) return;
15222
- const container = this.#container;
15223
- if (!container) return;
15224
- const rect = container.getBoundingClientRect();
15225
- const cx = (this.#x / 100) * rect.width;
15226
- const cy = (this.#y / 100) * rect.height;
15227
- const deg =
15228
- (Math.atan2(e.clientY - rect.top - cy, e.clientX - rect.left - cx) *
15229
- 180) /
15230
- Math.PI;
15231
- circle.style.cursor = this.#resizeCursorSvg(deg);
13659
+ this.scrollTo(options);
15232
13660
  });
15233
- const onDown = (e) => {
15234
- if (this.hasAttribute("disabled")) return;
15235
- e.preventDefault();
15236
- e.stopPropagation();
15237
- this.#isRadiusDragging = true;
15238
- this.classList.add("fig-canvas-control-ring-active");
15239
- const container = this.#container;
15240
- if (!container) return;
15241
-
15242
- if (this.#radiusTooltip) {
15243
- this.#radiusTooltip.setAttribute("show", "true");
15244
- this.#radiusTooltip.showPopup?.();
15245
- }
15246
-
15247
- const prevBodyCursor = document.body.style.cursor;
15248
- circle.style.pointerEvents = "none";
15249
- const rect0 = container.getBoundingClientRect();
15250
- const cx0 = (this.#x / 100) * rect0.width;
15251
- const cy0 = (this.#y / 100) * rect0.height;
15252
- const initDeg =
15253
- (Math.atan2(e.clientY - rect0.top - cy0, e.clientX - rect0.left - cx0) *
15254
- 180) /
15255
- Math.PI;
15256
- let lastCursorDeg = Math.round(initDeg);
15257
- document.body.style.cursor = this.#resizeCursorSvg(lastCursorDeg);
15258
-
15259
- const onMove = (ev) => {
15260
- const rect = container.getBoundingClientRect();
15261
- const cx = (this.#x / 100) * rect.width;
15262
- const cy = (this.#y / 100) * rect.height;
15263
- const dx = ev.clientX - rect.left - cx;
15264
- const dy = ev.clientY - rect.top - cy;
15265
- const curDeg = Math.round((Math.atan2(dy, dx) * 180) / Math.PI);
15266
- if (curDeg !== lastCursorDeg) {
15267
- lastCursorDeg = curDeg;
15268
- document.body.style.cursor = this.#resizeCursorSvg(curDeg);
15269
- }
15270
- let dist = Math.sqrt(dx * dx + dy * dy);
15271
- if (this.#shouldSnap(ev.shiftKey)) {
15272
- const step = this.#radiusIsPercent ? 5 : 10;
15273
- if (this.#radiusIsPercent) {
15274
- let pct = (dist / rect.width) * 100;
15275
- pct = Math.round(pct / step) * step;
15276
- dist = (pct / 100) * rect.width;
15277
- } else {
15278
- dist = Math.round(dist / step) * step;
15279
- }
15280
- }
15281
- if (this.#radiusIsPercent) {
15282
- this.#radius = Math.max(0, (dist / rect.width) * 100);
15283
- } else {
15284
- this.#radius = Math.max(0, dist);
15285
- }
15286
- if (this.#radiusTooltip)
15287
- this.#radiusTooltip.setAttribute("text", this.#formatRadius());
15288
- this.#syncPositions();
15289
- this.#emitInput();
15290
- };
15291
-
15292
- const onUp = () => {
15293
- this.#isRadiusDragging = false;
15294
- this.classList.remove("fig-canvas-control-ring-active");
15295
- circle.style.pointerEvents = "";
15296
- document.body.style.cursor = prevBodyCursor;
15297
- if (this.#radiusTooltip) this.#radiusTooltip.removeAttribute("show");
15298
- this.#syncValueAttribute();
15299
- this.#emitChange();
15300
- window.removeEventListener("pointermove", onMove);
15301
- window.removeEventListener("pointerup", onUp);
15302
- };
15303
-
15304
- window.addEventListener("pointermove", onMove);
15305
- window.addEventListener("pointerup", onUp);
15306
- };
15307
- circle.addEventListener("pointerdown", onDown);
15308
- this._radiusDragCleanup = () =>
15309
- circle.removeEventListener("pointerdown", onDown);
15310
13661
  }
15311
13662
 
15312
- #teardownRadiusDrag() {
15313
- if (this._radiusDragCleanup) {
15314
- this._radiusDragCleanup();
15315
- this._radiusDragCleanup = null;
15316
- }
13663
+ #startObserver() {
13664
+ this.#mutationObserver?.disconnect();
13665
+ this.#mutationObserver = new MutationObserver(() => {
13666
+ const choices = this.choices;
13667
+ if (this.#selectedChoice && !choices.includes(this.#selectedChoice)) {
13668
+ this.#selectedChoice = null;
13669
+ this.#syncSelection();
13670
+ } else if (!this.#selectedChoice && choices.length) {
13671
+ this.#syncSelection();
13672
+ }
13673
+ });
13674
+ this.#mutationObserver.observe(this, { childList: true, subtree: true });
15317
13675
  }
15318
13676
  }
15319
- customElements.define("fig-canvas-control", FigCanvasControl);
13677
+ customElements.define("fig-chooser", FigChooser);
15320
13678
 
15321
13679
  /* Handle */
15322
13680
  class FigHandle extends HTMLElement {