@rogieking/figui3 11.5.0 → 11.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
+ import "./fig.js";
3
+
4
+ const EASING_ATTRIBUTES = [
5
+ "value",
6
+ "mode",
7
+ "precision",
8
+ "edit",
9
+ "presets",
10
+ "text",
11
+ ];
12
+
13
+ customElements.whenDefined("fig-popup").then(() => {
14
+ if (customElements.get("fig-popup-easing")) return;
15
+
16
+ const FigPopup = customElements.get("fig-popup");
17
+
18
+ /**
19
+ * A reusable popup editor for easing curves.
20
+ *
21
+ * @element fig-popup-easing
22
+ * @attr {string} value - Cubic bezier or spring easing value
23
+ * @attr {string} mode - Optional "bezier" or "spring" constraint
24
+ * @fires input - During easing edits
25
+ * @fires change - On committed easing edits
26
+ */
27
+ class FigPopupEasing extends FigPopup {
28
+ #curve = null;
29
+ #syncingValueAttribute = false;
30
+
31
+ static get observedAttributes() {
32
+ return [
33
+ ...new Set([
34
+ ...(super.observedAttributes || []),
35
+ ...EASING_ATTRIBUTES,
36
+ ]),
37
+ ];
38
+ }
39
+
40
+ get value() {
41
+ return this.#curve?.value ?? this.getAttribute("value") ?? "";
42
+ }
43
+
44
+ set value(value) {
45
+ this.setAttribute(
46
+ "value",
47
+ value && typeof value === "object"
48
+ ? JSON.stringify(value)
49
+ : String(value ?? ""),
50
+ );
51
+ }
52
+
53
+ connectedCallback() {
54
+ this.#ensureStructure();
55
+ this.#applyDefaults();
56
+ super.connectedCallback();
57
+ this.#syncCurve();
58
+ }
59
+
60
+ attributeChangedCallback(name, oldValue, newValue) {
61
+ super.attributeChangedCallback(name, oldValue, newValue);
62
+ if (
63
+ oldValue !== newValue &&
64
+ EASING_ATTRIBUTES.includes(name) &&
65
+ !this.#syncingValueAttribute
66
+ ) {
67
+ this.#syncCurveAttribute(name);
68
+ }
69
+ }
70
+
71
+ #applyDefaults() {
72
+ if (!this.hasAttribute("aria-label")) {
73
+ this.setAttribute("aria-label", "Edit easing");
74
+ }
75
+ if (!this.hasAttribute("position")) {
76
+ this.setAttribute("position", "bottom left");
77
+ }
78
+ if (!this.hasAttribute("offset")) this.setAttribute("offset", "0 8");
79
+ if (!this.hasAttribute("viewport-margin")) {
80
+ this.setAttribute("viewport-margin", "8");
81
+ }
82
+ if (!this.hasAttribute("popover")) {
83
+ this.setAttribute("popover", "manual");
84
+ }
85
+ }
86
+
87
+ #ensureStructure() {
88
+ if (this.#curve?.isConnected) return;
89
+ let content = this.querySelector(
90
+ ":scope > fig-content.fig-easing-curve-content",
91
+ );
92
+ let curve = content?.querySelector(":scope > fig-easing-curve");
93
+ if (!content) {
94
+ content = document.createElement("fig-content");
95
+ content.className = "fig-easing-curve-content";
96
+ this.append(content);
97
+ }
98
+ if (!curve) {
99
+ curve = document.createElement("fig-easing-curve");
100
+ curve.setAttribute("aspect-ratio", "4/3");
101
+ content.append(curve);
102
+ }
103
+ curve.setAttribute("aspect-ratio", "4/3");
104
+ curve.addEventListener("input", this.#handleCurveEvent);
105
+ curve.addEventListener("change", this.#handleCurveEvent);
106
+ this.#curve = curve;
107
+ }
108
+
109
+ #syncCurve() {
110
+ for (const name of EASING_ATTRIBUTES) {
111
+ this.#syncCurveAttribute(name);
112
+ }
113
+ }
114
+
115
+ #syncCurveAttribute(name) {
116
+ if (!this.#curve) return;
117
+ const value = this.getAttribute(name);
118
+ if (value === null) this.#curve.removeAttribute(name);
119
+ else this.#curve.setAttribute(name, value);
120
+ }
121
+
122
+ #handleCurveEvent = (event) => {
123
+ event.stopPropagation();
124
+ const value = this.#curve?.value ?? event.detail;
125
+ this.#syncingValueAttribute = true;
126
+ this.setAttribute("value", String(value ?? ""));
127
+ this.#syncingValueAttribute = false;
128
+ this.dispatchEvent(
129
+ new CustomEvent(event.type, {
130
+ bubbles: true,
131
+ composed: true,
132
+ detail: event.detail,
133
+ }),
134
+ );
135
+ };
136
+ }
137
+
138
+ customElements.define("fig-popup-easing", FigPopupEasing, {
139
+ extends: "dialog",
140
+ });
141
+ });
@@ -0,0 +1,11 @@
1
+ /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
+ dialog[is="fig-popup-gradient"] {
3
+ width: 15rem;
4
+ min-width: 0;
5
+ padding: 0;
6
+ pointer-events: auto;
7
+
8
+ .fig-gradient-editor-content{
9
+ padding-top: var(--spacer-1);
10
+ }
11
+ }
@@ -0,0 +1,138 @@
1
+ /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
+ import "./fig.js";
3
+ import "./fig-gradient-editor.js";
4
+
5
+ const GRADIENT_ATTRIBUTES = ["value", "color-space", "disabled"];
6
+
7
+ customElements.whenDefined("fig-popup").then(() => {
8
+ if (customElements.get("fig-popup-gradient")) return;
9
+
10
+ const FigPopup = customElements.get("fig-popup");
11
+
12
+ /**
13
+ * A reusable popup containing a full gradient editor.
14
+ *
15
+ * @element fig-popup-gradient
16
+ * @attr {string} value - JSON gradient config or full gradient fill value
17
+ * @attr {string} color-space - `srgb` or `display-p3`
18
+ * @attr {boolean} disabled - Disables gradient editing
19
+ * @fires input - During gradient edits
20
+ * @fires change - On committed gradient edits
21
+ */
22
+ class FigPopupGradient extends FigPopup {
23
+ #editor = null;
24
+ #syncingValueAttribute = false;
25
+
26
+ static get observedAttributes() {
27
+ return [
28
+ ...new Set([
29
+ ...(super.observedAttributes || []),
30
+ ...GRADIENT_ATTRIBUTES,
31
+ ]),
32
+ ];
33
+ }
34
+
35
+ get value() {
36
+ return this.#editor?.value ?? {};
37
+ }
38
+
39
+ set value(value) {
40
+ this.setAttribute(
41
+ "value",
42
+ typeof value === "string" ? value : JSON.stringify(value ?? {}),
43
+ );
44
+ }
45
+
46
+ connectedCallback() {
47
+ this.#ensureStructure();
48
+ this.#applyDefaults();
49
+ super.connectedCallback();
50
+ this.#syncEditor();
51
+ }
52
+
53
+ attributeChangedCallback(name, oldValue, newValue) {
54
+ super.attributeChangedCallback(name, oldValue, newValue);
55
+ if (
56
+ oldValue !== newValue &&
57
+ GRADIENT_ATTRIBUTES.includes(name) &&
58
+ !this.#syncingValueAttribute
59
+ ) {
60
+ this.#syncEditorAttribute(name);
61
+ }
62
+ }
63
+
64
+ #applyDefaults() {
65
+ if (!this.hasAttribute("aria-label")) {
66
+ this.setAttribute("aria-label", "Edit gradient");
67
+ }
68
+ if (!this.hasAttribute("position")) {
69
+ this.setAttribute("position", "bottom left");
70
+ }
71
+ if (!this.hasAttribute("offset")) this.setAttribute("offset", "0 8");
72
+ if (!this.hasAttribute("viewport-margin")) {
73
+ this.setAttribute("viewport-margin", "8");
74
+ }
75
+ if (!this.hasAttribute("popover")) {
76
+ this.setAttribute("popover", "manual");
77
+ }
78
+ }
79
+
80
+ #ensureStructure() {
81
+ if (this.#editor?.isConnected) return;
82
+ let content = this.querySelector(
83
+ ":scope > fig-content.fig-gradient-editor-content",
84
+ );
85
+ let editor = content?.querySelector(":scope > fig-gradient-editor");
86
+ if (!content) {
87
+ content = document.createElement("fig-content");
88
+ content.className = "fig-gradient-editor-content";
89
+ this.append(content);
90
+ }
91
+ if (!editor) {
92
+ editor = document.createElement("fig-gradient-editor");
93
+ content.append(editor);
94
+ }
95
+ editor.pickerAnchor = this;
96
+ editor.addEventListener("input", this.#handleEditorEvent);
97
+ editor.addEventListener("change", this.#handleEditorEvent);
98
+ this.#editor = editor;
99
+ }
100
+
101
+ #syncEditor() {
102
+ for (const name of GRADIENT_ATTRIBUTES) {
103
+ this.#syncEditorAttribute(name);
104
+ }
105
+ if (this.#editor) this.#editor.pickerAnchor = this;
106
+ }
107
+
108
+ #syncEditorAttribute(name) {
109
+ if (!this.#editor) return;
110
+ const value = this.getAttribute(name);
111
+ if (value === null) this.#editor.removeAttribute(name);
112
+ else this.#editor.setAttribute(name, value);
113
+ }
114
+
115
+ #handleEditorEvent = (event) => {
116
+ event.stopPropagation();
117
+ const detail = event.detail ?? {
118
+ type: "gradient",
119
+ gradient: this.#editor?.value,
120
+ css: this.#editor?.cssValue,
121
+ };
122
+ this.#syncingValueAttribute = true;
123
+ this.setAttribute("value", JSON.stringify(detail.gradient ?? {}));
124
+ this.#syncingValueAttribute = false;
125
+ this.dispatchEvent(
126
+ new CustomEvent(event.type, {
127
+ bubbles: true,
128
+ composed: true,
129
+ detail,
130
+ }),
131
+ );
132
+ };
133
+ }
134
+
135
+ customElements.define("fig-popup-gradient", FigPopupGradient, {
136
+ extends: "dialog",
137
+ });
138
+ });
@@ -0,0 +1,7 @@
1
+ /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
+ dialog[is="fig-popup-palette"] {
3
+ width: 12rem;
4
+ min-width: 0;
5
+ padding: 0;
6
+ pointer-events: auto;
7
+ }
@@ -0,0 +1,173 @@
1
+ /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
+ import "./fig.js";
3
+
4
+ const PALETTE_ATTRIBUTES = ["value", "min", "max", "fixed", "disabled"];
5
+
6
+ function normalizePaletteValue(value) {
7
+ let entries = value;
8
+ if (typeof entries === "string") {
9
+ const raw = entries.trim();
10
+ if (!raw) return [];
11
+ try {
12
+ entries = JSON.parse(raw);
13
+ } catch {
14
+ entries = raw.includes(",") ? raw.split(",") : [raw];
15
+ }
16
+ }
17
+ if (!Array.isArray(entries)) return [];
18
+ return entries.map((entry) => {
19
+ if (typeof entry === "string") {
20
+ const color = entry.trim();
21
+ return {
22
+ color: color.slice(0, 7) || "#D9D9D9",
23
+ alpha:
24
+ color.length > 7
25
+ ? Math.max(0, Math.min(1, parseInt(color.slice(7, 9), 16) / 255))
26
+ : 1,
27
+ };
28
+ }
29
+ if (entry && typeof entry === "object") {
30
+ const alpha =
31
+ entry.alpha !== undefined
32
+ ? Number(entry.alpha)
33
+ : entry.opacity !== undefined
34
+ ? Number(entry.opacity) / 100
35
+ : 1;
36
+ return {
37
+ color: String(entry.color || "#D9D9D9").slice(0, 7),
38
+ alpha: Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1,
39
+ };
40
+ }
41
+ return { color: "#D9D9D9", alpha: 1 };
42
+ });
43
+ }
44
+
45
+ customElements.whenDefined("fig-popup").then(() => {
46
+ if (customElements.get("fig-popup-palette")) return;
47
+
48
+ const FigPopup = customElements.get("fig-popup");
49
+
50
+ /**
51
+ * A reusable popup editor for an ordered palette of solid colors.
52
+ *
53
+ * @element fig-popup-palette
54
+ * @attr {string} value - JSON color array or comma-separated hex colors
55
+ * @attr {number} min - Minimum number of colors
56
+ * @attr {number} max - Maximum number of colors
57
+ * @attr {boolean} fixed - Locks the palette length
58
+ * @attr {boolean} disabled - Disables palette editing
59
+ * @fires input - During editing with the normalized color array in detail
60
+ * @fires change - On committed edits with the normalized color array in detail
61
+ */
62
+ class FigPopupPalette extends FigPopup {
63
+ #editor = null;
64
+ #syncingValueAttribute = false;
65
+
66
+ static get observedAttributes() {
67
+ return [
68
+ ...new Set([
69
+ ...(super.observedAttributes || []),
70
+ ...PALETTE_ATTRIBUTES,
71
+ ]),
72
+ ];
73
+ }
74
+
75
+ get value() {
76
+ return normalizePaletteValue(this.getAttribute("value"));
77
+ }
78
+
79
+ set value(value) {
80
+ this.setAttribute(
81
+ "value",
82
+ typeof value === "string" ? value : JSON.stringify(value ?? []),
83
+ );
84
+ }
85
+
86
+ connectedCallback() {
87
+ this.#ensureStructure();
88
+ this.#applyDefaults();
89
+ super.connectedCallback();
90
+ this.#syncEditor();
91
+ }
92
+
93
+ attributeChangedCallback(name, oldValue, newValue) {
94
+ super.attributeChangedCallback(name, oldValue, newValue);
95
+ if (
96
+ oldValue !== newValue &&
97
+ PALETTE_ATTRIBUTES.includes(name) &&
98
+ !this.#syncingValueAttribute
99
+ ) {
100
+ this.#syncEditorAttribute(name);
101
+ }
102
+ }
103
+
104
+ #applyDefaults() {
105
+ if (!this.hasAttribute("aria-label")) {
106
+ this.setAttribute("aria-label", "Edit palette");
107
+ }
108
+ if (!this.hasAttribute("position")) {
109
+ this.setAttribute("position", "bottom left");
110
+ }
111
+ if (!this.hasAttribute("offset")) this.setAttribute("offset", "0 8");
112
+ if (!this.hasAttribute("viewport-margin")) {
113
+ this.setAttribute("viewport-margin", "8");
114
+ }
115
+ if (!this.hasAttribute("popover")) {
116
+ this.setAttribute("popover", "manual");
117
+ }
118
+ this.classList.add("fig-palette-editor-popup");
119
+ }
120
+
121
+ #ensureStructure() {
122
+ if (this.#editor?.isConnected) return;
123
+ let content = this.querySelector(
124
+ ":scope > fig-content.fig-palette-editor-content",
125
+ );
126
+ let editor = content?.querySelector(":scope > fig-palette-editor");
127
+ if (!content) {
128
+ content = document.createElement("fig-content");
129
+ content.className = "fig-palette-editor-content";
130
+ this.append(content);
131
+ }
132
+ if (!editor) {
133
+ editor = document.createElement("fig-palette-editor");
134
+ content.append(editor);
135
+ }
136
+ editor.addEventListener("input", this.#handleEditorEvent);
137
+ editor.addEventListener("change", this.#handleEditorEvent);
138
+ this.#editor = editor;
139
+ }
140
+
141
+ #syncEditor() {
142
+ for (const name of PALETTE_ATTRIBUTES) {
143
+ this.#syncEditorAttribute(name);
144
+ }
145
+ }
146
+
147
+ #syncEditorAttribute(name) {
148
+ if (!this.#editor) return;
149
+ const value = this.getAttribute(name);
150
+ if (value === null) this.#editor.removeAttribute(name);
151
+ else this.#editor.setAttribute(name, value);
152
+ }
153
+
154
+ #handleEditorEvent = (event) => {
155
+ event.stopPropagation();
156
+ const value = normalizePaletteValue(event.detail);
157
+ this.#syncingValueAttribute = true;
158
+ this.setAttribute("value", JSON.stringify(value));
159
+ this.#syncingValueAttribute = false;
160
+ this.dispatchEvent(
161
+ new CustomEvent(event.type, {
162
+ bubbles: true,
163
+ composed: true,
164
+ detail: value,
165
+ }),
166
+ );
167
+ };
168
+ }
169
+
170
+ customElements.define("fig-popup-palette", FigPopupPalette, {
171
+ extends: "dialog",
172
+ });
173
+ });