@rogieking/figui3 8.0.3 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig-lab.js CHANGED
@@ -18,6 +18,13 @@ function figLabBooleanAttribute(element, name) {
18
18
  return element.hasAttribute(name) && element.getAttribute(name) !== "false";
19
19
  }
20
20
 
21
+ function figLabSyncDisabledControls(host, controls) {
22
+ const disabled = figLabBooleanAttribute(host, "disabled");
23
+ for (const control of controls) {
24
+ control?.toggleAttribute("disabled", disabled);
25
+ }
26
+ }
27
+
21
28
  function figLabColorEventAliases(color, alpha, opacity) {
22
29
  const numericAlpha = Number(alpha);
23
30
  const numericOpacity = Number(opacity);
@@ -566,6 +573,7 @@ class PropskitSwitch extends HTMLElement {
566
573
 
567
574
  #forwardSwitchEvent(type, event) {
568
575
  event.stopImmediatePropagation();
576
+ if (figLabBooleanAttribute(this, "disabled")) return;
569
577
  const checked = this.#switch?.value === "on";
570
578
  this.toggleAttribute("checked", checked);
571
579
  const detail = {
@@ -583,6 +591,7 @@ class PropskitSwitch extends HTMLElement {
583
591
  }
584
592
 
585
593
  #handleClick(event) {
594
+ if (figLabBooleanAttribute(this, "disabled")) return;
586
595
  if (
587
596
  event.target instanceof Element &&
588
597
  event.target.closest("fig-segmented-control, fig-menu")
@@ -845,6 +854,7 @@ class PropskitColor extends HTMLElement {
845
854
 
846
855
  #forwardInputEvent(type, event) {
847
856
  event.stopImmediatePropagation();
857
+ if (figLabBooleanAttribute(this, "disabled")) return;
848
858
  const value = this.#valueFromColorEvent(event);
849
859
  this.setAttribute("value", value);
850
860
  if (this.#input && this.#input.getAttribute("value") !== value) {
@@ -865,6 +875,7 @@ class PropskitColor extends HTMLElement {
865
875
  }
866
876
 
867
877
  #handleClick(event) {
878
+ if (figLabBooleanAttribute(this, "disabled")) return;
868
879
  if (
869
880
  event.target instanceof Element &&
870
881
  event.target.closest("fig-input-color, fig-menu")
@@ -1131,6 +1142,7 @@ class PropskitGradient extends HTMLElement {
1131
1142
 
1132
1143
  #forwardInputEvent(type, event) {
1133
1144
  event.stopImmediatePropagation();
1145
+ if (figLabBooleanAttribute(this, "disabled")) return;
1134
1146
  const value = this.#valueFromGradientEvent(event);
1135
1147
  this.setAttribute("value", value);
1136
1148
  const detail =
@@ -1148,6 +1160,7 @@ class PropskitGradient extends HTMLElement {
1148
1160
  }
1149
1161
 
1150
1162
  #handleClick(event) {
1163
+ if (figLabBooleanAttribute(this, "disabled")) return;
1151
1164
  if (
1152
1165
  event.target instanceof Element &&
1153
1166
  event.target.closest("fig-input-gradient, fig-menu")
@@ -1481,6 +1494,7 @@ class PropskitSelect extends HTMLElement {
1481
1494
  #forwardSelectEvent(type, event) {
1482
1495
  if (event.target !== this.#select) return;
1483
1496
  event.stopImmediatePropagation();
1497
+ if (figLabBooleanAttribute(this, "disabled")) return;
1484
1498
  const value = this.#select?.value ?? "";
1485
1499
  if (type !== "optionhover") this.setAttribute("value", String(value));
1486
1500
  const detail =
@@ -1520,6 +1534,7 @@ class PropskitSelect extends HTMLElement {
1520
1534
  }
1521
1535
 
1522
1536
  #handleClick(event) {
1537
+ if (figLabBooleanAttribute(this, "disabled")) return;
1523
1538
  if (event.target instanceof Element && event.target.closest("fig-menu")) {
1524
1539
  return;
1525
1540
  }
@@ -1753,6 +1768,7 @@ class PropskitText extends HTMLElement {
1753
1768
 
1754
1769
  #forwardInputEvent(type, event) {
1755
1770
  event.stopImmediatePropagation();
1771
+ if (figLabBooleanAttribute(this, "disabled")) return;
1756
1772
  const value = this.#input?.value ?? "";
1757
1773
  this.setAttribute("value", String(value));
1758
1774
  const detail =
@@ -1770,6 +1786,7 @@ class PropskitText extends HTMLElement {
1770
1786
  }
1771
1787
 
1772
1788
  #handleClick(event) {
1789
+ if (figLabBooleanAttribute(this, "disabled")) return;
1773
1790
  if (
1774
1791
  event.target instanceof Element &&
1775
1792
  event.target.closest("fig-input-text, fig-menu")
@@ -1978,6 +1995,7 @@ class PropskitNumber extends HTMLElement {
1978
1995
 
1979
1996
  #forwardInputEvent(type, event) {
1980
1997
  event.stopImmediatePropagation();
1998
+ if (figLabBooleanAttribute(this, "disabled")) return;
1981
1999
  const detail =
1982
2000
  event instanceof CustomEvent && event.detail !== undefined
1983
2001
  ? event.detail
@@ -1996,6 +2014,7 @@ class PropskitNumber extends HTMLElement {
1996
2014
  }
1997
2015
 
1998
2016
  #handleClick(event) {
2017
+ if (figLabBooleanAttribute(this, "disabled")) return;
1999
2018
  if (
2000
2019
  event.target instanceof Element &&
2001
2020
  event.target.closest("fig-input-number, fig-menu")
@@ -2040,233 +2059,1793 @@ class PropskitNumber extends HTMLElement {
2040
2059
  }
2041
2060
  figLabDefineElement("propskit-number", PropskitNumber);
2042
2061
 
2043
- /* Collapsible property group — always collapsible (no collapsible attr). */
2044
- class PropskitGroup extends HTMLElement {
2045
- static observedAttributes = ["name", "open", "show-reset"];
2046
-
2047
- static #CONTROL_SELECTOR = [
2048
- "propskit-color",
2049
- "propskit-gradient",
2050
- "propskit-number",
2051
- "propskit-select",
2052
- "propskit-slider",
2053
- "propskit-switch",
2054
- "propskit-text",
2055
- "propskit-oscillator",
2056
- ].join(",");
2062
+ /**
2063
+ * Compact X/Y editor.
2064
+ *
2065
+ * @attr {number} x - Horizontal value.
2066
+ * @attr {number} y - Vertical value.
2067
+ * @attr {string} default - JSON reset value with x and y.
2068
+ * @attr {string} label - Field label. Empty values use "Position".
2069
+ * @attr {string} units - Set to "percent" to show percentage units.
2070
+ * @attr {boolean|string} disabled - Disables both number inputs.
2071
+ * @attr {string} size - Set to "large" for the expanded row.
2072
+ * @fires input - Composed event with { x, y }.
2073
+ * @fires change - Composed event with { x, y }.
2074
+ */
2075
+ class PropskitPosition extends HTMLElement {
2076
+ static observedAttributes = [
2077
+ "x",
2078
+ "y",
2079
+ "default",
2080
+ "label",
2081
+ "units",
2082
+ "disabled",
2083
+ "size",
2084
+ ];
2057
2085
 
2058
- #header = null;
2059
- #chevron = null;
2060
- #resetTooltip = null;
2061
- #childObserver = null;
2062
- #dirtyFrame = 0;
2063
- #boundOnControlEvent = () => this.#queueDirtySync();
2086
+ #field = null;
2087
+ #xInput = null;
2088
+ #yInput = null;
2089
+ #initialValue = null;
2090
+ #initialized = false;
2091
+ #reflecting = false;
2092
+ #boundHandleInput = this.#handleInputEvent.bind(this, "input");
2093
+ #boundHandleChange = this.#handleInputEvent.bind(this, "change");
2094
+ #boundHandleClick = this.#handleClick.bind(this);
2064
2095
 
2065
2096
  connectedCallback() {
2066
- this.#render();
2067
- this.#bindDirtyListeners();
2068
- requestAnimationFrame(() => {
2069
- this.#syncDirtyState();
2070
- });
2097
+ if (!this.#initialized) {
2098
+ this.#initialValue = this.#readValue();
2099
+ this.#reflectValue(this.#initialValue);
2100
+ this.#initialized = true;
2101
+ }
2102
+ if (!this.#field) this.#render();
2103
+ this.#syncLabel();
2104
+ this.#syncInputs();
2105
+ this.#bindEvents();
2106
+ this.removeEventListener("click", this.#boundHandleClick);
2107
+ this.addEventListener("click", this.#boundHandleClick);
2108
+ figLabConnectPropskitResetMenu(this);
2071
2109
  }
2072
2110
 
2073
2111
  disconnectedCallback() {
2074
- this.#unbindDirtyListeners();
2075
- if (this.#dirtyFrame) {
2076
- cancelAnimationFrame(this.#dirtyFrame);
2077
- this.#dirtyFrame = 0;
2078
- }
2079
- this.#header?.removeEventListener("click", this.#handleToggle);
2080
- this.#header?.removeEventListener("keydown", this.#handleHeaderKeyDown);
2081
- const resetBtn = this.#resetTooltip?.querySelector("fig-button");
2082
- resetBtn?.removeEventListener("click", this.#handleReset);
2112
+ this.#unbindEvents();
2113
+ this.removeEventListener("click", this.#boundHandleClick);
2114
+ figLabDisconnectPropskitResetMenu(this);
2083
2115
  }
2084
2116
 
2085
2117
  attributeChangedCallback(name, oldValue, newValue) {
2086
- if (oldValue === newValue) return;
2087
- if (name === "open") {
2088
- this.#header?.setAttribute("aria-expanded", String(this.open));
2089
- return;
2090
- }
2091
- if (name === "show-reset") {
2092
- this.#syncResetButton();
2093
- this.#syncDirtyState();
2094
- return;
2118
+ if (oldValue === newValue || !this.#initialized) return;
2119
+ if ((name === "x" || name === "y") && !this.#reflecting) {
2120
+ this.#syncInputs();
2121
+ } else if (name === "label") {
2122
+ this.#syncLabel();
2123
+ } else if (name === "units") {
2124
+ this.#syncUnits();
2125
+ } else if (name === "disabled") {
2126
+ this.#syncDisabled();
2095
2127
  }
2096
- this.#render();
2097
2128
  }
2098
2129
 
2099
- get open() {
2100
- const attr = this.getAttribute("open");
2101
- return attr !== null && attr !== "false";
2130
+ #finiteNumber(value, fallback = 50) {
2131
+ const number = Number(value);
2132
+ return Number.isFinite(number) ? number : fallback;
2102
2133
  }
2103
2134
 
2104
- set open(value) {
2105
- const was = this.open;
2106
- if (value) {
2107
- this.setAttribute("open", "true");
2108
- } else {
2109
- this.setAttribute("open", "false");
2110
- }
2111
- this.#header?.setAttribute("aria-expanded", String(!!value));
2112
- if (was !== !!value) {
2113
- this.dispatchEvent(
2114
- new CustomEvent("openchange", {
2115
- detail: { open: !!value },
2116
- bubbles: true,
2117
- }),
2118
- );
2135
+ #readValue() {
2136
+ const x = this.getAttribute("x");
2137
+ const y = this.getAttribute("y");
2138
+ return {
2139
+ x: x === null || !x.trim() ? 50 : this.#finiteNumber(x, 50),
2140
+ y: y === null || !y.trim() ? 50 : this.#finiteNumber(y, 50),
2141
+ };
2142
+ }
2143
+
2144
+ #parseDefault(value) {
2145
+ if (typeof value !== "string" || !value.trim()) return null;
2146
+ try {
2147
+ const parsed = JSON.parse(value);
2148
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2149
+ return null;
2150
+ }
2151
+ return {
2152
+ x: this.#finiteNumber(parsed.x, this.#initialValue?.x ?? 50),
2153
+ y: this.#finiteNumber(parsed.y, this.#initialValue?.y ?? 50),
2154
+ };
2155
+ } catch {
2156
+ return null;
2119
2157
  }
2120
2158
  }
2121
2159
 
2122
- /** When true (default), show the reset control while the group is dirty. */
2123
- get showReset() {
2124
- const attr = this.getAttribute("show-reset");
2125
- if (attr === null) return true;
2126
- return attr !== "false";
2160
+ #reflectValue(value) {
2161
+ const current = this.#readValue();
2162
+ const normalized = {
2163
+ x: this.#finiteNumber(value?.x, current.x),
2164
+ y: this.#finiteNumber(value?.y, current.y),
2165
+ };
2166
+ this.#reflecting = true;
2167
+ this.setAttribute("x", String(normalized.x));
2168
+ this.setAttribute("y", String(normalized.y));
2169
+ this.#reflecting = false;
2170
+ return normalized;
2127
2171
  }
2128
2172
 
2129
- set showReset(value) {
2130
- if (value) this.setAttribute("show-reset", "true");
2131
- else this.setAttribute("show-reset", "false");
2173
+ #createNumber(axis, value) {
2174
+ const input = document.createElement("fig-input-number");
2175
+ input.setAttribute("data-propskit-position-axis", axis);
2176
+ input.setAttribute("value", String(value));
2177
+ input.setAttribute("min", "0");
2178
+ input.setAttribute("max", "100");
2179
+ input.setAttribute("step", "1");
2180
+ input.setAttribute("precision", "2");
2181
+ if (this.units === "percent") input.setAttribute("units", "%");
2182
+ input.setAttribute("aria-label", `${axis.toUpperCase()} position`);
2183
+ const prepend = document.createElement("span");
2184
+ prepend.setAttribute("slot", "prepend");
2185
+ prepend.textContent = axis.toUpperCase();
2186
+ input.append(prepend);
2187
+ if (figLabBooleanAttribute(this, "disabled")) {
2188
+ input.setAttribute("disabled", "");
2189
+ }
2190
+ return input;
2132
2191
  }
2133
2192
 
2134
- get dirty() {
2135
- return this.hasAttribute("data-dirty") && this.getAttribute("data-dirty") !== "false";
2193
+ #render() {
2194
+ const value = this.#readValue();
2195
+ const field = document.createElement("fig-field");
2196
+ field.className = "propskit-position-row";
2197
+ field.setAttribute("direction", "horizontal");
2198
+ const label = document.createElement("label");
2199
+ const xInput = this.#createNumber("x", value.x);
2200
+ const yInput = this.#createNumber("y", value.y);
2201
+ field.append(label, xInput, yInput);
2202
+ this.#field = field;
2203
+ this.#xInput = xInput;
2204
+ this.#yInput = yInput;
2205
+ this.replaceChildren(field);
2206
+ figLabConnectPropskitResetMenu(this);
2136
2207
  }
2137
2208
 
2138
- /** Restore all propskit controls in this group to their captured defaults. */
2139
- resetProperties() {
2140
- this.#resetControls();
2209
+ #syncLabel() {
2210
+ const label = this.#field?.querySelector(":scope > label");
2211
+ if (!label) return;
2212
+ const rawLabel = this.getAttribute("label");
2213
+ label.textContent = rawLabel?.trim() || "Position";
2141
2214
  }
2142
2215
 
2143
- #bindDirtyListeners() {
2144
- this.removeEventListener("input", this.#boundOnControlEvent, true);
2145
- this.removeEventListener("change", this.#boundOnControlEvent, true);
2146
- this.addEventListener("input", this.#boundOnControlEvent, true);
2147
- this.addEventListener("change", this.#boundOnControlEvent, true);
2216
+ #syncInputs() {
2217
+ const value = this.#readValue();
2218
+ this.#xInput?.setAttribute("value", String(value.x));
2219
+ this.#yInput?.setAttribute("value", String(value.y));
2220
+ this.#syncUnits();
2221
+ this.#syncDisabled();
2222
+ }
2148
2223
 
2149
- if (!this.#childObserver) {
2150
- this.#childObserver = new MutationObserver(() => {
2151
- this.#queueDirtySync();
2152
- });
2224
+ #syncUnits() {
2225
+ const units = this.units === "percent" ? "%" : null;
2226
+ for (const input of [this.#xInput, this.#yInput]) {
2227
+ if (!input) continue;
2228
+ if (units) input.setAttribute("units", units);
2229
+ else input.removeAttribute("units");
2153
2230
  }
2154
- this.#childObserver.disconnect();
2155
- this.#childObserver.observe(this, {
2156
- childList: true,
2157
- subtree: true,
2158
- attributes: true,
2159
- attributeFilter: ["value", "checked", "default"],
2160
- });
2161
2231
  }
2162
2232
 
2163
- #unbindDirtyListeners() {
2164
- this.removeEventListener("input", this.#boundOnControlEvent, true);
2165
- this.removeEventListener("change", this.#boundOnControlEvent, true);
2166
- this.#childObserver?.disconnect();
2233
+ #syncDisabled() {
2234
+ const disabled = figLabBooleanAttribute(this, "disabled");
2235
+ this.#xInput?.toggleAttribute("disabled", disabled);
2236
+ this.#yInput?.toggleAttribute("disabled", disabled);
2167
2237
  }
2168
2238
 
2169
- #queueDirtySync() {
2170
- if (this.#dirtyFrame) return;
2171
- this.#dirtyFrame = requestAnimationFrame(() => {
2172
- this.#dirtyFrame = 0;
2173
- this.#syncDirtyState();
2174
- });
2239
+ #bindEvents() {
2240
+ this.#unbindEvents();
2241
+ this.#xInput?.addEventListener("input", this.#boundHandleInput);
2242
+ this.#xInput?.addEventListener("change", this.#boundHandleChange);
2243
+ this.#yInput?.addEventListener("input", this.#boundHandleInput);
2244
+ this.#yInput?.addEventListener("change", this.#boundHandleChange);
2175
2245
  }
2176
2246
 
2177
- #isResetTarget(target) {
2178
- return (
2179
- target instanceof Element &&
2180
- Boolean(
2181
- target.closest(
2182
- ".propskit-group-reset, .propskit-group-reset-tooltip",
2183
- ),
2184
- )
2247
+ #unbindEvents() {
2248
+ this.#xInput?.removeEventListener("input", this.#boundHandleInput);
2249
+ this.#xInput?.removeEventListener("change", this.#boundHandleChange);
2250
+ this.#yInput?.removeEventListener("input", this.#boundHandleInput);
2251
+ this.#yInput?.removeEventListener("change", this.#boundHandleChange);
2252
+ }
2253
+
2254
+ #handleInputEvent(type, event) {
2255
+ event.stopImmediatePropagation();
2256
+ if (figLabBooleanAttribute(this, "disabled")) return;
2257
+ const axis = event.currentTarget?.getAttribute("data-propskit-position-axis");
2258
+ if (axis !== "x" && axis !== "y") return;
2259
+ const value = this.value;
2260
+ value[axis] = this.#finiteNumber(event.currentTarget.value, value[axis]);
2261
+ const detail = this.#reflectValue(value);
2262
+ this.dispatchEvent(
2263
+ new CustomEvent(type, {
2264
+ detail: { ...detail, units: this.units },
2265
+ bubbles: true,
2266
+ cancelable: true,
2267
+ composed: true,
2268
+ }),
2185
2269
  );
2186
2270
  }
2187
2271
 
2188
- #handleToggle = (e) => {
2189
- if (this.#isResetTarget(e.target)) return;
2190
- e.stopPropagation();
2191
- this.open = !this.open;
2192
- };
2272
+ #handleClick(event) {
2273
+ if (figLabBooleanAttribute(this, "disabled")) return;
2274
+ if (
2275
+ event.target instanceof Element &&
2276
+ event.target.closest("fig-input-number, fig-menu")
2277
+ ) {
2278
+ return;
2279
+ }
2280
+ this.focus();
2281
+ }
2193
2282
 
2194
- #handleHeaderKeyDown = (e) => {
2195
- if (this.#isResetTarget(e.target)) return;
2196
- if (e.key !== "Enter" && e.key !== " ") return;
2197
- e.preventDefault();
2198
- e.stopPropagation();
2199
- this.open = !this.open;
2200
- };
2283
+ get x() {
2284
+ return this.#readValue().x;
2285
+ }
2201
2286
 
2202
- #handleReset = (e) => {
2203
- e.preventDefault();
2204
- e.stopPropagation();
2205
- this.#resetControls();
2206
- };
2287
+ set x(value) {
2288
+ this.setAttribute("x", String(this.#finiteNumber(value, 50)));
2289
+ }
2207
2290
 
2208
- #controls() {
2209
- return [
2210
- ...this.querySelectorAll(PropskitGroup.#CONTROL_SELECTOR),
2211
- ].filter(
2212
- (el) =>
2213
- el.closest("propskit-group") === this &&
2214
- !el.parentElement?.closest(PropskitGroup.#CONTROL_SELECTOR),
2215
- );
2291
+ get y() {
2292
+ return this.#readValue().y;
2216
2293
  }
2217
2294
 
2218
- #controlIsDirty(el) {
2219
- return el.isDefault === false;
2295
+ set y(value) {
2296
+ this.setAttribute("y", String(this.#finiteNumber(value, 50)));
2220
2297
  }
2221
2298
 
2222
- #computeDirty() {
2223
- for (const el of this.#controls()) {
2224
- if (this.#controlIsDirty(el)) return true;
2225
- }
2226
- return false;
2299
+ get units() {
2300
+ return this.getAttribute("units")?.trim().toLowerCase() === "percent"
2301
+ ? "percent"
2302
+ : "none";
2227
2303
  }
2228
2304
 
2229
- #syncDirtyState() {
2230
- const dirty = this.#computeDirty();
2231
- if (dirty) this.setAttribute("data-dirty", "");
2232
- else this.removeAttribute("data-dirty");
2233
- this.#syncResetButton();
2305
+ set units(value) {
2306
+ this.setAttribute(
2307
+ "units",
2308
+ String(value).trim().toLowerCase() === "none" ? "none" : "percent",
2309
+ );
2234
2310
  }
2235
2311
 
2236
- #restoreControl(el) {
2237
- if (typeof el.resetToDefault === "function") {
2238
- el.resetToDefault();
2239
- }
2312
+ get value() {
2313
+ return { ...this.#readValue() };
2240
2314
  }
2241
2315
 
2242
- #resetControls() {
2243
- for (const el of this.#controls()) this.#restoreControl(el);
2244
- this.#syncDirtyState();
2245
- this.dispatchEvent(
2246
- new CustomEvent("reset", {
2247
- bubbles: true,
2248
- composed: true,
2249
- }),
2316
+ set value(value) {
2317
+ const normalized = this.#reflectValue(value);
2318
+ this.#syncInputs(normalized);
2319
+ }
2320
+
2321
+ get defaultValue() {
2322
+ return (
2323
+ this.#parseDefault(this.getAttribute("default")) || {
2324
+ ...(this.#initialValue || { x: 50, y: 50 }),
2325
+ }
2250
2326
  );
2251
2327
  }
2252
2328
 
2253
- #syncResetButton() {
2254
- if (!this.showReset) {
2255
- this.#resetTooltip?.setAttribute("hidden", "");
2256
- return;
2257
- }
2258
- this.#ensureResetButton();
2259
- this.#resetTooltip?.removeAttribute("hidden");
2329
+ get isDefault() {
2330
+ return figLabPropskitJsonValuesEqual(this.value, this.defaultValue);
2260
2331
  }
2261
2332
 
2262
- #ensureResetButton() {
2263
- if (!this.#header || !this.showReset) return;
2264
- let tip = this.#header.querySelector(":scope > .propskit-group-reset-tooltip");
2265
- if (!tip) {
2266
- tip = document.createElement("fig-tooltip");
2267
- tip.className = "propskit-group-reset-tooltip";
2268
- tip.setAttribute("text", "Reset properties");
2269
- const btn = document.createElement("fig-button");
2333
+ resetToDefault() {
2334
+ const value = this.defaultValue;
2335
+ this.value = value;
2336
+ figLabEmitPropskitReset(this, { ...value, units: this.units });
2337
+ }
2338
+
2339
+ focus(options) {
2340
+ this.#xInput?.focus(options);
2341
+ }
2342
+ }
2343
+ figLabDefineElement("propskit-position", PropskitPosition);
2344
+
2345
+ /**
2346
+ * Collapsible color-point group composed from color and position controls.
2347
+ *
2348
+ * @attr {string} label - Passed to the internal fig-group name.
2349
+ * @attr {boolean|string} collapsible - Internal group collapsibility; defaults true.
2350
+ * @attr {boolean|string} open - Internal group expanded state; defaults true.
2351
+ * @attr {string} size - Passed to both internal PropsKit controls.
2352
+ * @attr {boolean|string} disabled - Disables both internal controls.
2353
+ * @attr {string} value - JSON object with x, y, and color.
2354
+ * @fires input - Composed event with { x, y, color }.
2355
+ * @fires change - Composed event with { x, y, color }.
2356
+ * @fires openchange - Composed event mirroring the internal fig-group.
2357
+ */
2358
+ class PropskitColorPoint extends HTMLElement {
2359
+ static observedAttributes = [
2360
+ "label",
2361
+ "collapsible",
2362
+ "open",
2363
+ "size",
2364
+ "disabled",
2365
+ "value",
2366
+ ];
2367
+
2368
+ #group = null;
2369
+ #colorControl = null;
2370
+ #positionControl = null;
2371
+ #initialized = false;
2372
+ #reflectingValue = false;
2373
+ #reflectingOpen = false;
2374
+ #boundHandleInput = this.#handleControlEvent.bind(this, "input");
2375
+ #boundHandleChange = this.#handleControlEvent.bind(this, "change");
2376
+ #boundHandleOpenChange = this.#handleOpenChange.bind(this);
2377
+
2378
+ connectedCallback() {
2379
+ if (!this.#initialized) {
2380
+ this.#reflectValue(this.#readValue());
2381
+ this.#initialized = true;
2382
+ }
2383
+ if (!this.#group) this.#render();
2384
+ this.#syncGroupAttributes();
2385
+ this.#syncSize();
2386
+ this.#syncDisabled();
2387
+ this.#syncControls(this.#readValue());
2388
+ this.removeEventListener("input", this.#boundHandleInput);
2389
+ this.addEventListener("input", this.#boundHandleInput);
2390
+ this.removeEventListener("change", this.#boundHandleChange);
2391
+ this.addEventListener("change", this.#boundHandleChange);
2392
+ this.removeEventListener("openchange", this.#boundHandleOpenChange);
2393
+ this.addEventListener("openchange", this.#boundHandleOpenChange);
2394
+ }
2395
+
2396
+ disconnectedCallback() {
2397
+ this.removeEventListener("input", this.#boundHandleInput);
2398
+ this.removeEventListener("change", this.#boundHandleChange);
2399
+ this.removeEventListener("openchange", this.#boundHandleOpenChange);
2400
+ }
2401
+
2402
+ attributeChangedCallback(name, oldValue, newValue) {
2403
+ if (oldValue === newValue || !this.#initialized) return;
2404
+ if (name === "value") {
2405
+ if (!this.#reflectingValue) {
2406
+ const value = this.#reflectValue(this.#readValue());
2407
+ this.#syncControls(value);
2408
+ }
2409
+ return;
2410
+ }
2411
+ if (name === "size") {
2412
+ this.#syncSize();
2413
+ return;
2414
+ }
2415
+ if (name === "disabled") {
2416
+ this.#syncDisabled();
2417
+ return;
2418
+ }
2419
+ this.#syncGroupAttributes();
2420
+ }
2421
+
2422
+ #finiteNumber(value, fallback) {
2423
+ const number = Number(value);
2424
+ return Number.isFinite(number) ? number : fallback;
2425
+ }
2426
+
2427
+ #normalizeValue(value) {
2428
+ let source = value;
2429
+ if (typeof value === "string") {
2430
+ try {
2431
+ source = JSON.parse(value);
2432
+ } catch {
2433
+ source = null;
2434
+ }
2435
+ }
2436
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
2437
+ source = {};
2438
+ }
2439
+ return {
2440
+ x: this.#finiteNumber(source.x, 50),
2441
+ y: this.#finiteNumber(source.y, 50),
2442
+ color:
2443
+ typeof source.color === "string" && source.color.trim()
2444
+ ? source.color.trim()
2445
+ : "#D9D9D9",
2446
+ };
2447
+ }
2448
+
2449
+ #readValue() {
2450
+ return this.#normalizeValue(this.getAttribute("value"));
2451
+ }
2452
+
2453
+ #reflectValue(value) {
2454
+ const normalized = this.#normalizeValue(value);
2455
+ const serialized = JSON.stringify(normalized);
2456
+ if (this.getAttribute("value") === serialized) return normalized;
2457
+ this.#reflectingValue = true;
2458
+ this.setAttribute("value", serialized);
2459
+ this.#reflectingValue = false;
2460
+ return normalized;
2461
+ }
2462
+
2463
+ #render() {
2464
+ const value = this.#readValue();
2465
+ const group = document.createElement("fig-group");
2466
+ const color = document.createElement("propskit-color");
2467
+ const position = document.createElement("propskit-position");
2468
+ group.setAttribute("compact", "");
2469
+ color.setAttribute("label", "Color");
2470
+ color.setAttribute("value", value.color);
2471
+ color.setAttribute("data-propskit-color-point-control", "color");
2472
+ position.setAttribute("label", "Position");
2473
+ position.setAttribute("x", String(value.x));
2474
+ position.setAttribute("y", String(value.y));
2475
+ position.setAttribute("units", "percent");
2476
+ position.setAttribute("data-propskit-color-point-control", "position");
2477
+ this.#group = group;
2478
+ this.#colorControl = color;
2479
+ this.#positionControl = position;
2480
+ this.#syncGroupAttributes();
2481
+ this.#syncSize();
2482
+ this.#syncDisabled();
2483
+ group.append(color, position);
2484
+ this.replaceChildren(group);
2485
+ }
2486
+
2487
+ #syncGroupAttributes() {
2488
+ if (!this.#group) return;
2489
+ this.#group.setAttribute("compact", "");
2490
+ const label = this.getAttribute("label")?.trim();
2491
+ if (label) this.#group.setAttribute("name", label);
2492
+ else this.#group.removeAttribute("name");
2493
+ this.#group.toggleAttribute("collapsible", this.collapsible);
2494
+ if (this.collapsible) {
2495
+ this.#group.setAttribute("open", String(this.open));
2496
+ } else {
2497
+ this.#group.removeAttribute("open");
2498
+ }
2499
+ }
2500
+
2501
+ #syncSize() {
2502
+ const size = this.getAttribute("size");
2503
+ for (const control of [this.#colorControl, this.#positionControl]) {
2504
+ if (!control) continue;
2505
+ if (size === null) control.removeAttribute("size");
2506
+ else control.setAttribute("size", size);
2507
+ }
2508
+ }
2509
+
2510
+ #syncDisabled() {
2511
+ figLabSyncDisabledControls(this, [
2512
+ this.#colorControl,
2513
+ this.#positionControl,
2514
+ ]);
2515
+ }
2516
+
2517
+ #syncControls(value) {
2518
+ const normalized = this.#normalizeValue(value);
2519
+ if (this.#colorControl) this.#colorControl.value = normalized.color;
2520
+ if (this.#positionControl) {
2521
+ this.#positionControl.value = {
2522
+ x: normalized.x,
2523
+ y: normalized.y,
2524
+ };
2525
+ }
2526
+ }
2527
+
2528
+ #handleControlEvent(type, event) {
2529
+ if (event.target === this) return;
2530
+ const control = event.target?.closest?.(
2531
+ "[data-propskit-color-point-control]",
2532
+ );
2533
+ if (!control || !this.contains(control)) return;
2534
+ event.stopImmediatePropagation();
2535
+ if (figLabBooleanAttribute(this, "disabled")) return;
2536
+ const value = this.value;
2537
+ const kind = control.getAttribute("data-propskit-color-point-control");
2538
+ if (kind === "color") {
2539
+ value.color = control.value || control.getAttribute("value");
2540
+ } else if (kind === "position") {
2541
+ value.x = this.#finiteNumber(control.value?.x, value.x);
2542
+ value.y = this.#finiteNumber(control.value?.y, value.y);
2543
+ } else {
2544
+ return;
2545
+ }
2546
+ const detail = this.#reflectValue(value);
2547
+ this.dispatchEvent(
2548
+ new CustomEvent(type, {
2549
+ detail: { ...detail, units: "percent" },
2550
+ bubbles: true,
2551
+ cancelable: true,
2552
+ composed: true,
2553
+ }),
2554
+ );
2555
+ }
2556
+
2557
+ #handleOpenChange(event) {
2558
+ if (event.target !== this.#group || this.#reflectingOpen) return;
2559
+ event.stopImmediatePropagation();
2560
+ const open = Boolean(event.detail?.open);
2561
+ this.#reflectingOpen = true;
2562
+ this.setAttribute("open", String(open));
2563
+ this.#reflectingOpen = false;
2564
+ this.dispatchEvent(
2565
+ new CustomEvent("openchange", {
2566
+ detail: { open },
2567
+ bubbles: true,
2568
+ composed: true,
2569
+ }),
2570
+ );
2571
+ }
2572
+
2573
+ get collapsible() {
2574
+ const value = this.getAttribute("collapsible");
2575
+ return value === null || value !== "false";
2576
+ }
2577
+
2578
+ set collapsible(value) {
2579
+ this.setAttribute("collapsible", String(Boolean(value)));
2580
+ }
2581
+
2582
+ get open() {
2583
+ const value = this.getAttribute("open");
2584
+ return value === null || value !== "false";
2585
+ }
2586
+
2587
+ set open(value) {
2588
+ this.setAttribute("open", String(Boolean(value)));
2589
+ }
2590
+
2591
+ get value() {
2592
+ return { ...this.#readValue() };
2593
+ }
2594
+
2595
+ set value(value) {
2596
+ const normalized = this.#reflectValue(value);
2597
+ this.#syncControls(normalized);
2598
+ }
2599
+
2600
+ focus(options) {
2601
+ this.#colorControl?.focus(options);
2602
+ }
2603
+ }
2604
+ figLabDefineElement("propskit-color-point", PropskitColorPoint);
2605
+
2606
+ /**
2607
+ * Collapsible point-radius group composed from position and number controls.
2608
+ *
2609
+ * @attr {string} label - Passed to the internal fig-group name.
2610
+ * @attr {boolean|string} collapsible - Internal group collapsibility; defaults true.
2611
+ * @attr {boolean|string} open - Internal group expanded state; defaults true.
2612
+ * @attr {string} size - Passed to both internal PropsKit controls.
2613
+ * @attr {string} units - Passed to the internal position and radius controls.
2614
+ * @attr {boolean|string} disabled - Disables both internal controls.
2615
+ * @attr {string} value - JSON object with x, y, and radius.
2616
+ * @fires input - Composed event with { x, y, radius }.
2617
+ * @fires change - Composed event with { x, y, radius }.
2618
+ * @fires openchange - Composed event mirroring the internal fig-group.
2619
+ */
2620
+ class PropskitPointRadius extends HTMLElement {
2621
+ static observedAttributes = [
2622
+ "label",
2623
+ "collapsible",
2624
+ "open",
2625
+ "size",
2626
+ "units",
2627
+ "disabled",
2628
+ "value",
2629
+ ];
2630
+
2631
+ #group = null;
2632
+ #positionControl = null;
2633
+ #radiusControl = null;
2634
+ #initialized = false;
2635
+ #reflectingValue = false;
2636
+ #reflectingOpen = false;
2637
+ #boundHandleInput = this.#handleControlEvent.bind(this, "input");
2638
+ #boundHandleChange = this.#handleControlEvent.bind(this, "change");
2639
+ #boundHandleOpenChange = this.#handleOpenChange.bind(this);
2640
+
2641
+ connectedCallback() {
2642
+ if (!this.#initialized) {
2643
+ this.#reflectValue(this.#readValue());
2644
+ this.#initialized = true;
2645
+ }
2646
+ if (!this.#group) this.#render();
2647
+ this.#syncGroupAttributes();
2648
+ this.#syncSize();
2649
+ this.#syncUnits();
2650
+ this.#syncDisabled();
2651
+ this.#syncControls(this.#readValue());
2652
+ this.removeEventListener("input", this.#boundHandleInput);
2653
+ this.addEventListener("input", this.#boundHandleInput);
2654
+ this.removeEventListener("change", this.#boundHandleChange);
2655
+ this.addEventListener("change", this.#boundHandleChange);
2656
+ this.removeEventListener("openchange", this.#boundHandleOpenChange);
2657
+ this.addEventListener("openchange", this.#boundHandleOpenChange);
2658
+ }
2659
+
2660
+ disconnectedCallback() {
2661
+ this.removeEventListener("input", this.#boundHandleInput);
2662
+ this.removeEventListener("change", this.#boundHandleChange);
2663
+ this.removeEventListener("openchange", this.#boundHandleOpenChange);
2664
+ }
2665
+
2666
+ attributeChangedCallback(name, oldValue, newValue) {
2667
+ if (oldValue === newValue || !this.#initialized) return;
2668
+ if (name === "value") {
2669
+ if (!this.#reflectingValue) {
2670
+ const value = this.#reflectValue(this.#readValue());
2671
+ this.#syncControls(value);
2672
+ }
2673
+ return;
2674
+ }
2675
+ if (name === "size") {
2676
+ this.#syncSize();
2677
+ return;
2678
+ }
2679
+ if (name === "units") {
2680
+ this.#syncUnits();
2681
+ return;
2682
+ }
2683
+ if (name === "disabled") {
2684
+ this.#syncDisabled();
2685
+ return;
2686
+ }
2687
+ this.#syncGroupAttributes();
2688
+ }
2689
+
2690
+ #finiteNumber(value, fallback) {
2691
+ const number = Number(value);
2692
+ return Number.isFinite(number) ? number : fallback;
2693
+ }
2694
+
2695
+ #normalizeRadius(value) {
2696
+ if (typeof value === "string" && value.trim().endsWith("%")) {
2697
+ const number = Number.parseFloat(value);
2698
+ if (Number.isFinite(number)) return `${number}%`;
2699
+ }
2700
+ return this.#finiteNumber(value, 0);
2701
+ }
2702
+
2703
+ #normalizeValue(value) {
2704
+ let source = value;
2705
+ if (typeof value === "string") {
2706
+ try {
2707
+ source = JSON.parse(value);
2708
+ } catch {
2709
+ source = null;
2710
+ }
2711
+ }
2712
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
2713
+ source = {};
2714
+ }
2715
+ return {
2716
+ x: this.#finiteNumber(source.x, 50),
2717
+ y: this.#finiteNumber(source.y, 50),
2718
+ radius: this.#normalizeRadius(source.radius),
2719
+ };
2720
+ }
2721
+
2722
+ #readValue() {
2723
+ return this.#normalizeValue(this.getAttribute("value"));
2724
+ }
2725
+
2726
+ #reflectValue(value) {
2727
+ const normalized = this.#normalizeValue(value);
2728
+ const serialized = JSON.stringify(normalized);
2729
+ if (this.getAttribute("value") === serialized) return normalized;
2730
+ this.#reflectingValue = true;
2731
+ this.setAttribute("value", serialized);
2732
+ this.#reflectingValue = false;
2733
+ return normalized;
2734
+ }
2735
+
2736
+ #radiusParts(radius) {
2737
+ const percent =
2738
+ typeof radius === "string" && radius.trim().endsWith("%");
2739
+ return {
2740
+ value: percent ? Number.parseFloat(radius) : radius,
2741
+ units: percent ? "%" : "px",
2742
+ };
2743
+ }
2744
+
2745
+ #render() {
2746
+ const value = this.#readValue();
2747
+ const radiusValue = this.#radiusParts(value.radius);
2748
+ const group = document.createElement("fig-group");
2749
+ const position = document.createElement("propskit-position");
2750
+ const radius = document.createElement("propskit-number");
2751
+ group.setAttribute("compact", "");
2752
+ position.setAttribute("label", "Position");
2753
+ position.setAttribute("x", String(value.x));
2754
+ position.setAttribute("y", String(value.y));
2755
+ if (this.hasAttribute("units")) position.setAttribute("units", this.units);
2756
+ position.setAttribute("data-propskit-point-radius-control", "position");
2757
+ radius.setAttribute("label", "Radius");
2758
+ radius.setAttribute("value", String(radiusValue.value));
2759
+ if (this.units === "percent") radius.setAttribute("units", "%");
2760
+ radius.setAttribute("units-disallow", "");
2761
+ radius.setAttribute("min", "0");
2762
+ radius.setAttribute("precision", "2");
2763
+ radius.setAttribute("data-propskit-point-radius-control", "radius");
2764
+ this.#group = group;
2765
+ this.#positionControl = position;
2766
+ this.#radiusControl = radius;
2767
+ this.#syncGroupAttributes();
2768
+ this.#syncSize();
2769
+ this.#syncDisabled();
2770
+ group.append(position, radius);
2771
+ this.replaceChildren(group);
2772
+ }
2773
+
2774
+ #syncGroupAttributes() {
2775
+ if (!this.#group) return;
2776
+ this.#group.setAttribute("compact", "");
2777
+ const label = this.getAttribute("label")?.trim();
2778
+ if (label) this.#group.setAttribute("name", label);
2779
+ else this.#group.removeAttribute("name");
2780
+ this.#group.toggleAttribute("collapsible", this.collapsible);
2781
+ if (this.collapsible) {
2782
+ this.#group.setAttribute("open", String(this.open));
2783
+ } else {
2784
+ this.#group.removeAttribute("open");
2785
+ }
2786
+ }
2787
+
2788
+ #syncSize() {
2789
+ const size = this.getAttribute("size");
2790
+ for (const control of [this.#positionControl, this.#radiusControl]) {
2791
+ if (!control) continue;
2792
+ if (size === null) control.removeAttribute("size");
2793
+ else control.setAttribute("size", size);
2794
+ }
2795
+ }
2796
+
2797
+ #syncUnits() {
2798
+ if (this.hasAttribute("units")) {
2799
+ this.#positionControl?.setAttribute("units", this.units);
2800
+ } else {
2801
+ this.#positionControl?.removeAttribute("units");
2802
+ }
2803
+ if (this.units === "percent") {
2804
+ this.#radiusControl?.setAttribute("units", "%");
2805
+ } else {
2806
+ this.#radiusControl?.removeAttribute("units");
2807
+ }
2808
+ }
2809
+
2810
+ #syncDisabled() {
2811
+ figLabSyncDisabledControls(this, [
2812
+ this.#positionControl,
2813
+ this.#radiusControl,
2814
+ ]);
2815
+ }
2816
+
2817
+ #syncControls(value) {
2818
+ const normalized = this.#normalizeValue(value);
2819
+ if (this.#positionControl) {
2820
+ this.#positionControl.value = {
2821
+ x: normalized.x,
2822
+ y: normalized.y,
2823
+ };
2824
+ }
2825
+ if (this.#radiusControl) {
2826
+ const radius = this.#radiusParts(normalized.radius);
2827
+ this.#radiusControl.value = radius.value;
2828
+ }
2829
+ }
2830
+
2831
+ #radiusFromControl(control, fallback) {
2832
+ const fallbackValue = this.#radiusParts(fallback).value;
2833
+ const value = this.#finiteNumber(control.value, fallbackValue);
2834
+ const units =
2835
+ control.querySelector("fig-input-number")?.getAttribute("units") ||
2836
+ control.getAttribute("units");
2837
+ return units === "%" ? `${value}%` : value;
2838
+ }
2839
+
2840
+ #handleControlEvent(type, event) {
2841
+ if (event.target === this) return;
2842
+ const control = event.target?.closest?.(
2843
+ "[data-propskit-point-radius-control]",
2844
+ );
2845
+ if (!control || !this.contains(control)) return;
2846
+ event.stopImmediatePropagation();
2847
+ if (figLabBooleanAttribute(this, "disabled")) return;
2848
+ const value = this.value;
2849
+ const kind = control.getAttribute("data-propskit-point-radius-control");
2850
+ if (kind === "position") {
2851
+ value.x = this.#finiteNumber(control.value?.x, value.x);
2852
+ value.y = this.#finiteNumber(control.value?.y, value.y);
2853
+ } else if (kind === "radius") {
2854
+ value.radius = this.#radiusFromControl(control, value.radius);
2855
+ } else {
2856
+ return;
2857
+ }
2858
+ const detail = this.#reflectValue(value);
2859
+ this.dispatchEvent(
2860
+ new CustomEvent(type, {
2861
+ detail: {
2862
+ ...detail,
2863
+ radius: this.#radiusParts(detail.radius).value,
2864
+ units: this.units,
2865
+ },
2866
+ bubbles: true,
2867
+ cancelable: true,
2868
+ composed: true,
2869
+ }),
2870
+ );
2871
+ }
2872
+
2873
+ #handleOpenChange(event) {
2874
+ if (event.target !== this.#group || this.#reflectingOpen) return;
2875
+ event.stopImmediatePropagation();
2876
+ const open = Boolean(event.detail?.open);
2877
+ this.#reflectingOpen = true;
2878
+ this.setAttribute("open", String(open));
2879
+ this.#reflectingOpen = false;
2880
+ this.dispatchEvent(
2881
+ new CustomEvent("openchange", {
2882
+ detail: { open },
2883
+ bubbles: true,
2884
+ composed: true,
2885
+ }),
2886
+ );
2887
+ }
2888
+
2889
+ get collapsible() {
2890
+ const value = this.getAttribute("collapsible");
2891
+ return value === null || value !== "false";
2892
+ }
2893
+
2894
+ set collapsible(value) {
2895
+ this.setAttribute("collapsible", String(Boolean(value)));
2896
+ }
2897
+
2898
+ get open() {
2899
+ const value = this.getAttribute("open");
2900
+ return value === null || value !== "false";
2901
+ }
2902
+
2903
+ set open(value) {
2904
+ this.setAttribute("open", String(Boolean(value)));
2905
+ }
2906
+
2907
+ get units() {
2908
+ return this.getAttribute("units")?.trim().toLowerCase() === "percent"
2909
+ ? "percent"
2910
+ : "none";
2911
+ }
2912
+
2913
+ set units(value) {
2914
+ this.setAttribute(
2915
+ "units",
2916
+ String(value).trim().toLowerCase() === "none" ? "none" : "percent",
2917
+ );
2918
+ }
2919
+
2920
+ get value() {
2921
+ return { ...this.#readValue() };
2922
+ }
2923
+
2924
+ set value(value) {
2925
+ const normalized = this.#reflectValue(value);
2926
+ this.#syncControls(normalized);
2927
+ }
2928
+
2929
+ focus(options) {
2930
+ this.#positionControl?.focus(options);
2931
+ }
2932
+ }
2933
+ figLabDefineElement("propskit-point-radius", PropskitPointRadius);
2934
+
2935
+ /**
2936
+ * Collapsible point-radius-angle group composed from PropsKit controls.
2937
+ *
2938
+ * @attr {string} label - Passed to the internal fig-group name.
2939
+ * @attr {boolean|string} collapsible - Internal group collapsibility; defaults true.
2940
+ * @attr {boolean|string} open - Internal group expanded state; defaults true.
2941
+ * @attr {string} size - Passed to every internal PropsKit control.
2942
+ * @attr {string} units - Passed to the internal position and radius controls.
2943
+ * @attr {boolean|string} disabled - Disables every internal control.
2944
+ * @attr {string} value - JSON object with x, y, radius, and angle.
2945
+ * @fires input - Composed event with { x, y, radius, angle }.
2946
+ * @fires change - Composed event with { x, y, radius, angle }.
2947
+ * @fires openchange - Composed event mirroring the internal fig-group.
2948
+ */
2949
+ class PropskitPointRadiusAngle extends HTMLElement {
2950
+ static observedAttributes = [
2951
+ "label",
2952
+ "collapsible",
2953
+ "open",
2954
+ "size",
2955
+ "units",
2956
+ "disabled",
2957
+ "value",
2958
+ ];
2959
+
2960
+ #group = null;
2961
+ #positionControl = null;
2962
+ #radiusControl = null;
2963
+ #angleControl = null;
2964
+ #initialized = false;
2965
+ #reflectingValue = false;
2966
+ #reflectingOpen = false;
2967
+ #boundHandleInput = this.#handleControlEvent.bind(this, "input");
2968
+ #boundHandleChange = this.#handleControlEvent.bind(this, "change");
2969
+ #boundHandleOpenChange = this.#handleOpenChange.bind(this);
2970
+
2971
+ connectedCallback() {
2972
+ if (!this.#initialized) {
2973
+ this.#reflectValue(this.#readValue());
2974
+ this.#initialized = true;
2975
+ }
2976
+ if (!this.#group) this.#render();
2977
+ this.#syncGroupAttributes();
2978
+ this.#syncSize();
2979
+ this.#syncUnits();
2980
+ this.#syncDisabled();
2981
+ this.#syncControls(this.#readValue());
2982
+ this.removeEventListener("input", this.#boundHandleInput);
2983
+ this.addEventListener("input", this.#boundHandleInput);
2984
+ this.removeEventListener("change", this.#boundHandleChange);
2985
+ this.addEventListener("change", this.#boundHandleChange);
2986
+ this.removeEventListener("openchange", this.#boundHandleOpenChange);
2987
+ this.addEventListener("openchange", this.#boundHandleOpenChange);
2988
+ }
2989
+
2990
+ disconnectedCallback() {
2991
+ this.removeEventListener("input", this.#boundHandleInput);
2992
+ this.removeEventListener("change", this.#boundHandleChange);
2993
+ this.removeEventListener("openchange", this.#boundHandleOpenChange);
2994
+ }
2995
+
2996
+ attributeChangedCallback(name, oldValue, newValue) {
2997
+ if (oldValue === newValue || !this.#initialized) return;
2998
+ if (name === "value") {
2999
+ if (!this.#reflectingValue) {
3000
+ const value = this.#reflectValue(this.#readValue());
3001
+ this.#syncControls(value);
3002
+ }
3003
+ return;
3004
+ }
3005
+ if (name === "size") {
3006
+ this.#syncSize();
3007
+ return;
3008
+ }
3009
+ if (name === "units") {
3010
+ this.#syncUnits();
3011
+ return;
3012
+ }
3013
+ if (name === "disabled") {
3014
+ this.#syncDisabled();
3015
+ return;
3016
+ }
3017
+ this.#syncGroupAttributes();
3018
+ }
3019
+
3020
+ #finiteNumber(value, fallback) {
3021
+ const number = Number(value);
3022
+ return Number.isFinite(number) ? number : fallback;
3023
+ }
3024
+
3025
+ #normalizeRadius(value) {
3026
+ if (typeof value === "string" && value.trim().endsWith("%")) {
3027
+ const number = Number.parseFloat(value);
3028
+ if (Number.isFinite(number)) return `${number}%`;
3029
+ }
3030
+ return this.#finiteNumber(value, 0);
3031
+ }
3032
+
3033
+ #normalizeValue(value) {
3034
+ let source = value;
3035
+ if (typeof value === "string") {
3036
+ try {
3037
+ source = JSON.parse(value);
3038
+ } catch {
3039
+ source = null;
3040
+ }
3041
+ }
3042
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
3043
+ source = {};
3044
+ }
3045
+ return {
3046
+ x: this.#finiteNumber(source.x, 50),
3047
+ y: this.#finiteNumber(source.y, 50),
3048
+ radius: this.#normalizeRadius(source.radius),
3049
+ angle: this.#finiteNumber(source.angle, 0),
3050
+ };
3051
+ }
3052
+
3053
+ #readValue() {
3054
+ return this.#normalizeValue(this.getAttribute("value"));
3055
+ }
3056
+
3057
+ #reflectValue(value) {
3058
+ const normalized = this.#normalizeValue(value);
3059
+ const serialized = JSON.stringify(normalized);
3060
+ if (this.getAttribute("value") === serialized) return normalized;
3061
+ this.#reflectingValue = true;
3062
+ this.setAttribute("value", serialized);
3063
+ this.#reflectingValue = false;
3064
+ return normalized;
3065
+ }
3066
+
3067
+ #radiusParts(radius) {
3068
+ const percent =
3069
+ typeof radius === "string" && radius.trim().endsWith("%");
3070
+ return {
3071
+ value: percent ? Number.parseFloat(radius) : radius,
3072
+ units: percent ? "%" : "px",
3073
+ };
3074
+ }
3075
+
3076
+ #render() {
3077
+ const value = this.#readValue();
3078
+ const radiusValue = this.#radiusParts(value.radius);
3079
+ const group = document.createElement("fig-group");
3080
+ const position = document.createElement("propskit-position");
3081
+ const radius = document.createElement("propskit-number");
3082
+ const angle = document.createElement("propskit-number");
3083
+ group.setAttribute("compact", "");
3084
+ position.setAttribute("label", "Position");
3085
+ position.setAttribute("x", String(value.x));
3086
+ position.setAttribute("y", String(value.y));
3087
+ if (this.hasAttribute("units")) position.setAttribute("units", this.units);
3088
+ position.setAttribute(
3089
+ "data-propskit-point-radius-angle-control",
3090
+ "position",
3091
+ );
3092
+ radius.setAttribute("label", "Radius");
3093
+ radius.setAttribute("value", String(radiusValue.value));
3094
+ if (this.units === "percent") radius.setAttribute("units", "%");
3095
+ radius.setAttribute("units-disallow", "");
3096
+ radius.setAttribute("min", "0");
3097
+ radius.setAttribute("precision", "2");
3098
+ radius.setAttribute("data-propskit-point-radius-angle-control", "radius");
3099
+ angle.setAttribute("label", "Angle");
3100
+ angle.setAttribute("value", String(value.angle));
3101
+ angle.setAttribute("units", "°");
3102
+ angle.setAttribute("units-disallow", "");
3103
+ angle.setAttribute("precision", "1");
3104
+ angle.setAttribute("data-propskit-point-radius-angle-control", "angle");
3105
+ this.#group = group;
3106
+ this.#positionControl = position;
3107
+ this.#radiusControl = radius;
3108
+ this.#angleControl = angle;
3109
+ this.#syncGroupAttributes();
3110
+ this.#syncSize();
3111
+ this.#syncDisabled();
3112
+ group.append(position, radius, angle);
3113
+ this.replaceChildren(group);
3114
+ }
3115
+
3116
+ #syncGroupAttributes() {
3117
+ if (!this.#group) return;
3118
+ this.#group.setAttribute("compact", "");
3119
+ const label = this.getAttribute("label")?.trim();
3120
+ if (label) this.#group.setAttribute("name", label);
3121
+ else this.#group.removeAttribute("name");
3122
+ this.#group.toggleAttribute("collapsible", this.collapsible);
3123
+ if (this.collapsible) {
3124
+ this.#group.setAttribute("open", String(this.open));
3125
+ } else {
3126
+ this.#group.removeAttribute("open");
3127
+ }
3128
+ }
3129
+
3130
+ #syncSize() {
3131
+ const size = this.getAttribute("size");
3132
+ for (const control of [
3133
+ this.#positionControl,
3134
+ this.#radiusControl,
3135
+ this.#angleControl,
3136
+ ]) {
3137
+ if (!control) continue;
3138
+ if (size === null) control.removeAttribute("size");
3139
+ else control.setAttribute("size", size);
3140
+ }
3141
+ }
3142
+
3143
+ #syncUnits() {
3144
+ if (this.hasAttribute("units")) {
3145
+ this.#positionControl?.setAttribute("units", this.units);
3146
+ } else {
3147
+ this.#positionControl?.removeAttribute("units");
3148
+ }
3149
+ if (this.units === "percent") {
3150
+ this.#radiusControl?.setAttribute("units", "%");
3151
+ } else {
3152
+ this.#radiusControl?.removeAttribute("units");
3153
+ }
3154
+ }
3155
+
3156
+ #syncDisabled() {
3157
+ figLabSyncDisabledControls(this, [
3158
+ this.#positionControl,
3159
+ this.#radiusControl,
3160
+ this.#angleControl,
3161
+ ]);
3162
+ }
3163
+
3164
+ #syncControls(value) {
3165
+ const normalized = this.#normalizeValue(value);
3166
+ if (this.#positionControl) {
3167
+ this.#positionControl.value = {
3168
+ x: normalized.x,
3169
+ y: normalized.y,
3170
+ };
3171
+ }
3172
+ if (this.#radiusControl) {
3173
+ const radius = this.#radiusParts(normalized.radius);
3174
+ this.#radiusControl.value = radius.value;
3175
+ }
3176
+ if (this.#angleControl) {
3177
+ this.#angleControl.value = normalized.angle;
3178
+ }
3179
+ }
3180
+
3181
+ #radiusFromControl(control, fallback) {
3182
+ const fallbackValue = this.#radiusParts(fallback).value;
3183
+ const value = this.#finiteNumber(control.value, fallbackValue);
3184
+ const units =
3185
+ control.querySelector("fig-input-number")?.getAttribute("units") ||
3186
+ control.getAttribute("units");
3187
+ return units === "%" ? `${value}%` : value;
3188
+ }
3189
+
3190
+ #handleControlEvent(type, event) {
3191
+ if (event.target === this) return;
3192
+ const control = event.target?.closest?.(
3193
+ "[data-propskit-point-radius-angle-control]",
3194
+ );
3195
+ if (!control || !this.contains(control)) return;
3196
+ event.stopImmediatePropagation();
3197
+ if (figLabBooleanAttribute(this, "disabled")) return;
3198
+ const value = this.value;
3199
+ const kind = control.getAttribute(
3200
+ "data-propskit-point-radius-angle-control",
3201
+ );
3202
+ if (kind === "position") {
3203
+ value.x = this.#finiteNumber(control.value?.x, value.x);
3204
+ value.y = this.#finiteNumber(control.value?.y, value.y);
3205
+ } else if (kind === "radius") {
3206
+ value.radius = this.#radiusFromControl(control, value.radius);
3207
+ } else if (kind === "angle") {
3208
+ value.angle = this.#finiteNumber(control.value, value.angle);
3209
+ } else {
3210
+ return;
3211
+ }
3212
+ const detail = this.#reflectValue(value);
3213
+ this.dispatchEvent(
3214
+ new CustomEvent(type, {
3215
+ detail: {
3216
+ ...detail,
3217
+ radius: this.#radiusParts(detail.radius).value,
3218
+ units: this.units,
3219
+ },
3220
+ bubbles: true,
3221
+ cancelable: true,
3222
+ composed: true,
3223
+ }),
3224
+ );
3225
+ }
3226
+
3227
+ #handleOpenChange(event) {
3228
+ if (event.target !== this.#group || this.#reflectingOpen) return;
3229
+ event.stopImmediatePropagation();
3230
+ const open = Boolean(event.detail?.open);
3231
+ this.#reflectingOpen = true;
3232
+ this.setAttribute("open", String(open));
3233
+ this.#reflectingOpen = false;
3234
+ this.dispatchEvent(
3235
+ new CustomEvent("openchange", {
3236
+ detail: { open },
3237
+ bubbles: true,
3238
+ composed: true,
3239
+ }),
3240
+ );
3241
+ }
3242
+
3243
+ get collapsible() {
3244
+ const value = this.getAttribute("collapsible");
3245
+ return value === null || value !== "false";
3246
+ }
3247
+
3248
+ set collapsible(value) {
3249
+ this.setAttribute("collapsible", String(Boolean(value)));
3250
+ }
3251
+
3252
+ get open() {
3253
+ const value = this.getAttribute("open");
3254
+ return value === null || value !== "false";
3255
+ }
3256
+
3257
+ set open(value) {
3258
+ this.setAttribute("open", String(Boolean(value)));
3259
+ }
3260
+
3261
+ get units() {
3262
+ return this.getAttribute("units")?.trim().toLowerCase() === "percent"
3263
+ ? "percent"
3264
+ : "none";
3265
+ }
3266
+
3267
+ set units(value) {
3268
+ this.setAttribute(
3269
+ "units",
3270
+ String(value).trim().toLowerCase() === "none" ? "none" : "percent",
3271
+ );
3272
+ }
3273
+
3274
+ get value() {
3275
+ return { ...this.#readValue() };
3276
+ }
3277
+
3278
+ set value(value) {
3279
+ const normalized = this.#reflectValue(value);
3280
+ this.#syncControls(normalized);
3281
+ }
3282
+
3283
+ focus(options) {
3284
+ this.#positionControl?.focus(options);
3285
+ }
3286
+ }
3287
+ figLabDefineElement(
3288
+ "propskit-point-radius-angle",
3289
+ PropskitPointRadiusAngle,
3290
+ );
3291
+
3292
+ /**
3293
+ * Collapsible point-point group composed from two position controls.
3294
+ *
3295
+ * @attr {string} label - Passed to the internal fig-group name.
3296
+ * @attr {boolean|string} collapsible - Internal group collapsibility; defaults true.
3297
+ * @attr {boolean|string} open - Internal group expanded state; defaults true.
3298
+ * @attr {string} size - Passed to both internal position controls.
3299
+ * @attr {string} units - Passed to both internal position controls.
3300
+ * @attr {boolean|string} disabled - Disables both internal position controls.
3301
+ * @attr {string} value - JSON object with x, y, x2, and y2.
3302
+ * @fires input - Composed event with { x, y, x2, y2 }.
3303
+ * @fires change - Composed event with { x, y, x2, y2 }.
3304
+ * @fires openchange - Composed event mirroring the internal fig-group.
3305
+ */
3306
+ class PropskitPointPoint extends HTMLElement {
3307
+ static observedAttributes = [
3308
+ "label",
3309
+ "collapsible",
3310
+ "open",
3311
+ "size",
3312
+ "units",
3313
+ "disabled",
3314
+ "value",
3315
+ ];
3316
+
3317
+ #group = null;
3318
+ #startControl = null;
3319
+ #endControl = null;
3320
+ #initialized = false;
3321
+ #reflectingValue = false;
3322
+ #reflectingOpen = false;
3323
+ #boundHandleInput = this.#handleControlEvent.bind(this, "input");
3324
+ #boundHandleChange = this.#handleControlEvent.bind(this, "change");
3325
+ #boundHandleOpenChange = this.#handleOpenChange.bind(this);
3326
+
3327
+ connectedCallback() {
3328
+ if (!this.#initialized) {
3329
+ this.#reflectValue(this.#readValue());
3330
+ this.#initialized = true;
3331
+ }
3332
+ if (!this.#group) this.#render();
3333
+ this.#syncGroupAttributes();
3334
+ this.#syncSize();
3335
+ this.#syncUnits();
3336
+ this.#syncDisabled();
3337
+ this.#syncControls(this.#readValue());
3338
+ this.removeEventListener("input", this.#boundHandleInput);
3339
+ this.addEventListener("input", this.#boundHandleInput);
3340
+ this.removeEventListener("change", this.#boundHandleChange);
3341
+ this.addEventListener("change", this.#boundHandleChange);
3342
+ this.removeEventListener("openchange", this.#boundHandleOpenChange);
3343
+ this.addEventListener("openchange", this.#boundHandleOpenChange);
3344
+ }
3345
+
3346
+ disconnectedCallback() {
3347
+ this.removeEventListener("input", this.#boundHandleInput);
3348
+ this.removeEventListener("change", this.#boundHandleChange);
3349
+ this.removeEventListener("openchange", this.#boundHandleOpenChange);
3350
+ }
3351
+
3352
+ attributeChangedCallback(name, oldValue, newValue) {
3353
+ if (oldValue === newValue || !this.#initialized) return;
3354
+ if (name === "value") {
3355
+ if (!this.#reflectingValue) {
3356
+ const value = this.#reflectValue(this.#readValue());
3357
+ this.#syncControls(value);
3358
+ }
3359
+ return;
3360
+ }
3361
+ if (name === "size") {
3362
+ this.#syncSize();
3363
+ return;
3364
+ }
3365
+ if (name === "units") {
3366
+ this.#syncUnits();
3367
+ return;
3368
+ }
3369
+ if (name === "disabled") {
3370
+ this.#syncDisabled();
3371
+ return;
3372
+ }
3373
+ this.#syncGroupAttributes();
3374
+ }
3375
+
3376
+ #finiteNumber(value, fallback) {
3377
+ const number = Number(value);
3378
+ return Number.isFinite(number) ? number : fallback;
3379
+ }
3380
+
3381
+ #normalizeValue(value) {
3382
+ let source = value;
3383
+ if (typeof value === "string") {
3384
+ try {
3385
+ source = JSON.parse(value);
3386
+ } catch {
3387
+ source = null;
3388
+ }
3389
+ }
3390
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
3391
+ source = {};
3392
+ }
3393
+ return {
3394
+ x: this.#finiteNumber(source.x, 50),
3395
+ y: this.#finiteNumber(source.y, 50),
3396
+ x2: this.#finiteNumber(source.x2, 75),
3397
+ y2: this.#finiteNumber(source.y2, 75),
3398
+ };
3399
+ }
3400
+
3401
+ #readValue() {
3402
+ return this.#normalizeValue(this.getAttribute("value"));
3403
+ }
3404
+
3405
+ #reflectValue(value) {
3406
+ const normalized = this.#normalizeValue(value);
3407
+ const serialized = JSON.stringify(normalized);
3408
+ if (this.getAttribute("value") === serialized) return normalized;
3409
+ this.#reflectingValue = true;
3410
+ this.setAttribute("value", serialized);
3411
+ this.#reflectingValue = false;
3412
+ return normalized;
3413
+ }
3414
+
3415
+ #createPosition(label, role, x, y) {
3416
+ const control = document.createElement("propskit-position");
3417
+ control.setAttribute("label", label);
3418
+ control.setAttribute("x", String(x));
3419
+ control.setAttribute("y", String(y));
3420
+ if (this.hasAttribute("units")) control.setAttribute("units", this.units);
3421
+ control.setAttribute("data-propskit-point-point-control", role);
3422
+ return control;
3423
+ }
3424
+
3425
+ #render() {
3426
+ const value = this.#readValue();
3427
+ const group = document.createElement("fig-group");
3428
+ const start = this.#createPosition("Start", "start", value.x, value.y);
3429
+ const end = this.#createPosition("End", "end", value.x2, value.y2);
3430
+ group.setAttribute("compact", "");
3431
+ this.#group = group;
3432
+ this.#startControl = start;
3433
+ this.#endControl = end;
3434
+ this.#syncGroupAttributes();
3435
+ this.#syncSize();
3436
+ this.#syncDisabled();
3437
+ group.append(start, end);
3438
+ this.replaceChildren(group);
3439
+ }
3440
+
3441
+ #syncGroupAttributes() {
3442
+ if (!this.#group) return;
3443
+ this.#group.setAttribute("compact", "");
3444
+ const label = this.getAttribute("label")?.trim();
3445
+ if (label) this.#group.setAttribute("name", label);
3446
+ else this.#group.removeAttribute("name");
3447
+ this.#group.toggleAttribute("collapsible", this.collapsible);
3448
+ if (this.collapsible) {
3449
+ this.#group.setAttribute("open", String(this.open));
3450
+ } else {
3451
+ this.#group.removeAttribute("open");
3452
+ }
3453
+ }
3454
+
3455
+ #syncSize() {
3456
+ const size = this.getAttribute("size");
3457
+ for (const control of [this.#startControl, this.#endControl]) {
3458
+ if (!control) continue;
3459
+ if (size === null) control.removeAttribute("size");
3460
+ else control.setAttribute("size", size);
3461
+ }
3462
+ }
3463
+
3464
+ #syncUnits() {
3465
+ for (const control of [this.#startControl, this.#endControl]) {
3466
+ if (!control) continue;
3467
+ if (this.hasAttribute("units")) control.setAttribute("units", this.units);
3468
+ else control.removeAttribute("units");
3469
+ }
3470
+ }
3471
+
3472
+ #syncDisabled() {
3473
+ figLabSyncDisabledControls(this, [
3474
+ this.#startControl,
3475
+ this.#endControl,
3476
+ ]);
3477
+ }
3478
+
3479
+ #syncControls(value) {
3480
+ const normalized = this.#normalizeValue(value);
3481
+ if (this.#startControl) {
3482
+ this.#startControl.value = {
3483
+ x: normalized.x,
3484
+ y: normalized.y,
3485
+ };
3486
+ }
3487
+ if (this.#endControl) {
3488
+ this.#endControl.value = {
3489
+ x: normalized.x2,
3490
+ y: normalized.y2,
3491
+ };
3492
+ }
3493
+ }
3494
+
3495
+ #handleControlEvent(type, event) {
3496
+ if (event.target === this) return;
3497
+ const control = event.target?.closest?.(
3498
+ "[data-propskit-point-point-control]",
3499
+ );
3500
+ if (!control || !this.contains(control)) return;
3501
+ event.stopImmediatePropagation();
3502
+ if (figLabBooleanAttribute(this, "disabled")) return;
3503
+ const value = this.value;
3504
+ const point = control.value;
3505
+ const role = control.getAttribute("data-propskit-point-point-control");
3506
+ if (role === "start") {
3507
+ value.x = this.#finiteNumber(point?.x, value.x);
3508
+ value.y = this.#finiteNumber(point?.y, value.y);
3509
+ } else if (role === "end") {
3510
+ value.x2 = this.#finiteNumber(point?.x, value.x2);
3511
+ value.y2 = this.#finiteNumber(point?.y, value.y2);
3512
+ } else {
3513
+ return;
3514
+ }
3515
+ const detail = this.#reflectValue(value);
3516
+ this.dispatchEvent(
3517
+ new CustomEvent(type, {
3518
+ detail: { ...detail, units: this.units },
3519
+ bubbles: true,
3520
+ cancelable: true,
3521
+ composed: true,
3522
+ }),
3523
+ );
3524
+ }
3525
+
3526
+ #handleOpenChange(event) {
3527
+ if (event.target !== this.#group || this.#reflectingOpen) return;
3528
+ event.stopImmediatePropagation();
3529
+ const open = Boolean(event.detail?.open);
3530
+ this.#reflectingOpen = true;
3531
+ this.setAttribute("open", String(open));
3532
+ this.#reflectingOpen = false;
3533
+ this.dispatchEvent(
3534
+ new CustomEvent("openchange", {
3535
+ detail: { open },
3536
+ bubbles: true,
3537
+ composed: true,
3538
+ }),
3539
+ );
3540
+ }
3541
+
3542
+ get collapsible() {
3543
+ const value = this.getAttribute("collapsible");
3544
+ return value === null || value !== "false";
3545
+ }
3546
+
3547
+ set collapsible(value) {
3548
+ this.setAttribute("collapsible", String(Boolean(value)));
3549
+ }
3550
+
3551
+ get open() {
3552
+ const value = this.getAttribute("open");
3553
+ return value === null || value !== "false";
3554
+ }
3555
+
3556
+ set open(value) {
3557
+ this.setAttribute("open", String(Boolean(value)));
3558
+ }
3559
+
3560
+ get units() {
3561
+ return this.getAttribute("units")?.trim().toLowerCase() === "percent"
3562
+ ? "percent"
3563
+ : "none";
3564
+ }
3565
+
3566
+ set units(value) {
3567
+ this.setAttribute(
3568
+ "units",
3569
+ String(value).trim().toLowerCase() === "none" ? "none" : "percent",
3570
+ );
3571
+ }
3572
+
3573
+ get value() {
3574
+ return { ...this.#readValue() };
3575
+ }
3576
+
3577
+ set value(value) {
3578
+ const normalized = this.#reflectValue(value);
3579
+ this.#syncControls(normalized);
3580
+ }
3581
+
3582
+ focus(options) {
3583
+ this.#startControl?.focus(options);
3584
+ }
3585
+ }
3586
+ figLabDefineElement("propskit-point-point", PropskitPointPoint);
3587
+
3588
+ /* Collapsible property group — always collapsible (no collapsible attr). */
3589
+ class PropskitGroup extends HTMLElement {
3590
+ static observedAttributes = ["name", "open", "show-reset", "disabled"];
3591
+
3592
+ static #CONTROL_SELECTOR = [
3593
+ "propskit-color",
3594
+ "propskit-gradient",
3595
+ "propskit-number",
3596
+ "propskit-position",
3597
+ "propskit-color-point",
3598
+ "propskit-point-radius",
3599
+ "propskit-point-radius-angle",
3600
+ "propskit-point-point",
3601
+ "propskit-select",
3602
+ "propskit-slider",
3603
+ "propskit-switch",
3604
+ "propskit-text",
3605
+ "propskit-oscillator",
3606
+ ].join(",");
3607
+
3608
+ #header = null;
3609
+ #chevron = null;
3610
+ #resetTooltip = null;
3611
+ #childObserver = null;
3612
+ #dirtyFrame = 0;
3613
+ #boundOnControlEvent = () => this.#queueDirtySync();
3614
+
3615
+ connectedCallback() {
3616
+ this.#render();
3617
+ this.#syncDisabled();
3618
+ this.#bindDirtyListeners();
3619
+ requestAnimationFrame(() => {
3620
+ this.#syncDirtyState();
3621
+ });
3622
+ }
3623
+
3624
+ disconnectedCallback() {
3625
+ this.#unbindDirtyListeners();
3626
+ if (this.#dirtyFrame) {
3627
+ cancelAnimationFrame(this.#dirtyFrame);
3628
+ this.#dirtyFrame = 0;
3629
+ }
3630
+ this.#header?.removeEventListener("click", this.#handleToggle);
3631
+ this.#header?.removeEventListener("keydown", this.#handleHeaderKeyDown);
3632
+ const resetBtn = this.#resetTooltip?.querySelector("fig-button");
3633
+ resetBtn?.removeEventListener("click", this.#handleReset);
3634
+ }
3635
+
3636
+ attributeChangedCallback(name, oldValue, newValue) {
3637
+ if (oldValue === newValue) return;
3638
+ if (name === "open") {
3639
+ this.#header?.setAttribute("aria-expanded", String(this.open));
3640
+ return;
3641
+ }
3642
+ if (name === "show-reset") {
3643
+ this.#syncResetButton();
3644
+ this.#syncDirtyState();
3645
+ return;
3646
+ }
3647
+ if (name === "disabled") {
3648
+ this.#syncDisabled();
3649
+ return;
3650
+ }
3651
+ this.#render();
3652
+ }
3653
+
3654
+ get open() {
3655
+ const attr = this.getAttribute("open");
3656
+ return attr !== null && attr !== "false";
3657
+ }
3658
+
3659
+ set open(value) {
3660
+ const was = this.open;
3661
+ if (value) {
3662
+ this.setAttribute("open", "true");
3663
+ } else {
3664
+ this.setAttribute("open", "false");
3665
+ }
3666
+ this.#header?.setAttribute("aria-expanded", String(!!value));
3667
+ if (was !== !!value) {
3668
+ this.dispatchEvent(
3669
+ new CustomEvent("openchange", {
3670
+ detail: { open: !!value },
3671
+ bubbles: true,
3672
+ }),
3673
+ );
3674
+ }
3675
+ }
3676
+
3677
+ /** When true (default), show the reset control while the group is dirty. */
3678
+ get showReset() {
3679
+ const attr = this.getAttribute("show-reset");
3680
+ if (attr === null) return true;
3681
+ return attr !== "false";
3682
+ }
3683
+
3684
+ set showReset(value) {
3685
+ if (value) this.setAttribute("show-reset", "true");
3686
+ else this.setAttribute("show-reset", "false");
3687
+ }
3688
+
3689
+ get dirty() {
3690
+ return this.hasAttribute("data-dirty") && this.getAttribute("data-dirty") !== "false";
3691
+ }
3692
+
3693
+ /** Restore all propskit controls in this group to their captured defaults. */
3694
+ resetProperties() {
3695
+ this.#resetControls();
3696
+ }
3697
+
3698
+ #bindDirtyListeners() {
3699
+ this.removeEventListener("input", this.#boundOnControlEvent, true);
3700
+ this.removeEventListener("change", this.#boundOnControlEvent, true);
3701
+ this.addEventListener("input", this.#boundOnControlEvent, true);
3702
+ this.addEventListener("change", this.#boundOnControlEvent, true);
3703
+
3704
+ if (!this.#childObserver) {
3705
+ this.#childObserver = new MutationObserver(() => {
3706
+ this.#syncDisabled();
3707
+ this.#queueDirtySync();
3708
+ });
3709
+ }
3710
+ this.#childObserver.disconnect();
3711
+ this.#childObserver.observe(this, {
3712
+ childList: true,
3713
+ subtree: true,
3714
+ attributes: true,
3715
+ attributeFilter: ["value", "checked", "default", "x", "y"],
3716
+ });
3717
+ }
3718
+
3719
+ #unbindDirtyListeners() {
3720
+ this.removeEventListener("input", this.#boundOnControlEvent, true);
3721
+ this.removeEventListener("change", this.#boundOnControlEvent, true);
3722
+ this.#childObserver?.disconnect();
3723
+ }
3724
+
3725
+ #queueDirtySync() {
3726
+ if (this.#dirtyFrame) return;
3727
+ this.#dirtyFrame = requestAnimationFrame(() => {
3728
+ this.#dirtyFrame = 0;
3729
+ this.#syncDirtyState();
3730
+ });
3731
+ }
3732
+
3733
+ #isResetTarget(target) {
3734
+ return (
3735
+ target instanceof Element &&
3736
+ Boolean(
3737
+ target.closest(
3738
+ ".propskit-group-reset, .propskit-group-reset-tooltip",
3739
+ ),
3740
+ )
3741
+ );
3742
+ }
3743
+
3744
+ #handleToggle = (e) => {
3745
+ if (figLabBooleanAttribute(this, "disabled")) return;
3746
+ if (this.#isResetTarget(e.target)) return;
3747
+ e.stopPropagation();
3748
+ this.open = !this.open;
3749
+ };
3750
+
3751
+ #handleHeaderKeyDown = (e) => {
3752
+ if (figLabBooleanAttribute(this, "disabled")) return;
3753
+ if (this.#isResetTarget(e.target)) return;
3754
+ if (e.key !== "Enter" && e.key !== " ") return;
3755
+ e.preventDefault();
3756
+ e.stopPropagation();
3757
+ this.open = !this.open;
3758
+ };
3759
+
3760
+ #handleReset = (e) => {
3761
+ e.preventDefault();
3762
+ e.stopPropagation();
3763
+ if (figLabBooleanAttribute(this, "disabled")) return;
3764
+ this.#resetControls();
3765
+ };
3766
+
3767
+ #controls() {
3768
+ return [
3769
+ ...this.querySelectorAll(PropskitGroup.#CONTROL_SELECTOR),
3770
+ ].filter(
3771
+ (el) =>
3772
+ el.closest("propskit-group") === this &&
3773
+ !el.parentElement?.closest(PropskitGroup.#CONTROL_SELECTOR),
3774
+ );
3775
+ }
3776
+
3777
+ #syncDisabled() {
3778
+ const disabled = figLabBooleanAttribute(this, "disabled");
3779
+ for (const control of this.#controls()) {
3780
+ if (disabled) {
3781
+ if (!figLabBooleanAttribute(control, "disabled")) {
3782
+ control.setAttribute("disabled", "");
3783
+ control.setAttribute("data-propskit-group-disabled", "");
3784
+ }
3785
+ } else if (control.hasAttribute("data-propskit-group-disabled")) {
3786
+ control.removeAttribute("disabled");
3787
+ control.removeAttribute("data-propskit-group-disabled");
3788
+ }
3789
+ }
3790
+ this.#header?.setAttribute("aria-disabled", String(disabled));
3791
+ this.#header?.setAttribute("tabindex", disabled ? "-1" : "0");
3792
+ this.#resetTooltip
3793
+ ?.querySelector("fig-button")
3794
+ ?.toggleAttribute("disabled", disabled);
3795
+ }
3796
+
3797
+ #controlIsDirty(el) {
3798
+ return el.isDefault === false;
3799
+ }
3800
+
3801
+ #computeDirty() {
3802
+ for (const el of this.#controls()) {
3803
+ if (this.#controlIsDirty(el)) return true;
3804
+ }
3805
+ return false;
3806
+ }
3807
+
3808
+ #syncDirtyState() {
3809
+ const dirty = this.#computeDirty();
3810
+ if (dirty) this.setAttribute("data-dirty", "");
3811
+ else this.removeAttribute("data-dirty");
3812
+ this.#syncResetButton();
3813
+ }
3814
+
3815
+ #restoreControl(el) {
3816
+ if (typeof el.resetToDefault === "function") {
3817
+ el.resetToDefault();
3818
+ }
3819
+ }
3820
+
3821
+ #resetControls() {
3822
+ for (const el of this.#controls()) this.#restoreControl(el);
3823
+ this.#syncDirtyState();
3824
+ this.dispatchEvent(
3825
+ new CustomEvent("reset", {
3826
+ bubbles: true,
3827
+ composed: true,
3828
+ }),
3829
+ );
3830
+ }
3831
+
3832
+ #syncResetButton() {
3833
+ if (!this.showReset) {
3834
+ this.#resetTooltip?.setAttribute("hidden", "");
3835
+ return;
3836
+ }
3837
+ this.#ensureResetButton();
3838
+ this.#resetTooltip?.removeAttribute("hidden");
3839
+ }
3840
+
3841
+ #ensureResetButton() {
3842
+ if (!this.#header || !this.showReset) return;
3843
+ let tip = this.#header.querySelector(":scope > .propskit-group-reset-tooltip");
3844
+ if (!tip) {
3845
+ tip = document.createElement("fig-tooltip");
3846
+ tip.className = "propskit-group-reset-tooltip";
3847
+ tip.setAttribute("text", "Reset properties");
3848
+ const btn = document.createElement("fig-button");
2270
3849
  btn.className = "propskit-group-reset";
2271
3850
  btn.setAttribute("variant", "ghost");
2272
3851
  btn.setAttribute("icon", "");
@@ -2352,7 +3931,7 @@ class PropskitGroup extends HTMLElement {
2352
3931
  this.#chevron = h3.querySelector(".propskit-group-chevron");
2353
3932
  this.#syncResetButton();
2354
3933
  this.#header.setAttribute("role", "button");
2355
- this.#header.setAttribute("tabindex", "0");
3934
+ this.#syncDisabled();
2356
3935
  this.#header.setAttribute("aria-expanded", String(this.open));
2357
3936
  this.#header.removeEventListener("click", this.#handleToggle);
2358
3937
  this.#header.addEventListener("click", this.#handleToggle);
@@ -3165,6 +4744,7 @@ class PropskitSlider extends HTMLElement {
3165
4744
 
3166
4745
  #forwardSliderEvent(type, event) {
3167
4746
  event.stopImmediatePropagation();
4747
+ if (figLabBooleanAttribute(this, "disabled")) return;
3168
4748
  if (type === "change") {
3169
4749
  this.#resetElasticPull();
3170
4750
  }
@@ -3437,6 +5017,14 @@ class FigCanvasControl extends HTMLElement {
3437
5017
  if (typeof v.angle === "number") this.#angle = v.angle;
3438
5018
  if (typeof v.x2 === "number") this.#x2 = v.x2;
3439
5019
  if (typeof v.y2 === "number") this.#y2 = v.y2;
5020
+ if (
5021
+ this.#type === "color" &&
5022
+ typeof v.color === "string" &&
5023
+ v.color.trim() &&
5024
+ this.getAttribute("color") !== v.color.trim()
5025
+ ) {
5026
+ this.setAttribute("color", v.color.trim());
5027
+ }
3440
5028
  } catch {
3441
5029
  /* ignore */
3442
5030
  }
@@ -4895,7 +6483,7 @@ class PropskitOscillator extends HTMLElement {
4895
6483
  this.#syncViewportSize();
4896
6484
  this.#updateWaveform();
4897
6485
  this.#setupEvents();
4898
- this.#startPlayhead();
6486
+ if (!this.#isDisabled()) this.#startPlayhead();
4899
6487
  figLabConnectPropskitResetMenu(this);
4900
6488
  }
4901
6489
 
@@ -4945,6 +6533,7 @@ class PropskitOscillator extends HTMLElement {
4945
6533
  }
4946
6534
 
4947
6535
  #getWaveTypeMenuHTML(disabled, index) {
6536
+ const disabledAttr = disabled ? " disabled" : "";
4948
6537
  const items = PropskitOscillator.TYPES.map((type) => {
4949
6538
  return `<fig-menu-item value="${type.value}">
4950
6539
  ${PropskitOscillator.waveIcon(type.value, 24)}
@@ -4952,9 +6541,9 @@ class PropskitOscillator extends HTMLElement {
4952
6541
  </fig-menu-item>`;
4953
6542
  }).join("");
4954
6543
  const indexAttr = ` data-wave-index="${PropskitOscillator.#escapeAttribute(String(index))}"`;
4955
- return `<fig-menu class="propskit-oscillator-add-type" position="bottom right"${indexAttr}${disabled}>
6544
+ return `<fig-menu class="propskit-oscillator-add-type" position="bottom right"${indexAttr}${disabledAttr}>
4956
6545
  <fig-tooltip text="Add form">
4957
- <fig-button class="propskit-oscillator-add-type-button" variant="ghost" icon fig-menu-trigger aria-label="Add form"><fig-icon name="plus"></fig-icon></fig-button>
6546
+ <fig-button class="propskit-oscillator-add-type-button" variant="ghost" icon fig-menu-trigger aria-label="Add form"${disabledAttr}><fig-icon name="plus"></fig-icon></fig-button>
4958
6547
  </fig-tooltip>
4959
6548
  ${items}
4960
6549
  </fig-menu>`;
@@ -6239,7 +7828,12 @@ class FigReorder extends HTMLElement {
6239
7828
  "fig-input-angle",
6240
7829
  "fig-input-joystick",
6241
7830
  "fig-canvas-control",
7831
+ "propskit-color-point",
6242
7832
  "propskit-number",
7833
+ "propskit-point-point",
7834
+ "propskit-point-radius",
7835
+ "propskit-point-radius-angle",
7836
+ "propskit-position",
6243
7837
  "propskit-slider",
6244
7838
  "propskit-oscillator",
6245
7839
  ];