@rogieking/figui3 11.4.2 → 11.6.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.
@@ -0,0 +1,220 @@
1
+ /* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
2
+ import "./fig.js";
3
+
4
+ let figPropertyId = 0;
5
+
6
+ function defineElement(name, constructor) {
7
+ if (!customElements.get(name)) customElements.define(name, constructor);
8
+ }
9
+
10
+ function createIcon(name) {
11
+ const icon = document.createElement("fig-icon");
12
+ icon.setAttribute("name", name);
13
+ icon.setAttribute("size", "small");
14
+ icon.setAttribute("aria-hidden", "true");
15
+ return icon;
16
+ }
17
+
18
+ class FigProperty extends HTMLElement {
19
+ static observedAttributes = [
20
+ "label",
21
+ "leading",
22
+ "trailing",
23
+ "show-leading",
24
+ "show-trailing",
25
+ ];
26
+
27
+ #observer = null;
28
+ #labelElement = null;
29
+ #associatedInput = null;
30
+ #syncQueued = false;
31
+
32
+ connectedCallback() {
33
+ this.#sync();
34
+ this.#observer = new MutationObserver(() => this.#queueSync());
35
+ this.#observer.observe(this, {
36
+ childList: true,
37
+ attributes: true,
38
+ attributeFilter: ["slot"],
39
+ });
40
+ }
41
+
42
+ disconnectedCallback() {
43
+ this.#observer?.disconnect();
44
+ this.#observer = null;
45
+ this.#clearLabelAssociation();
46
+ }
47
+
48
+ attributeChangedCallback() {
49
+ if (this.isConnected) this.#queueSync();
50
+ }
51
+
52
+ get input() {
53
+ return this.querySelector(':scope > [slot="input"]');
54
+ }
55
+
56
+ #queueSync() {
57
+ if (this.#syncQueued) return;
58
+ this.#syncQueued = true;
59
+ queueMicrotask(() => {
60
+ this.#syncQueued = false;
61
+ if (this.isConnected) this.#sync();
62
+ });
63
+ }
64
+
65
+ #authoredSlot(name) {
66
+ return this.querySelector(
67
+ `:scope > [slot="${name}"]:not([data-fig-property-generated])`,
68
+ );
69
+ }
70
+
71
+ #generatedSlot(name) {
72
+ return this.querySelector(
73
+ `:scope > [slot="${name}"][data-fig-property-generated]`,
74
+ );
75
+ }
76
+
77
+ #sync() {
78
+ this.#syncLabel();
79
+ this.#syncLeading();
80
+ this.#syncTrailing();
81
+ }
82
+
83
+ #syncLabel() {
84
+ const labelText = this.getAttribute("label");
85
+ const authoredLabel =
86
+ this.querySelector(
87
+ ':scope > [slot="label"]:not([data-fig-property-generated])',
88
+ ) ||
89
+ this.querySelector(
90
+ ":scope > label:not([data-fig-property-generated])",
91
+ );
92
+ let generatedLabel = this.querySelector(
93
+ ":scope > label[data-fig-property-generated]",
94
+ );
95
+
96
+ if (labelText === null || authoredLabel) {
97
+ generatedLabel?.remove();
98
+ generatedLabel = null;
99
+ } else {
100
+ if (!generatedLabel) {
101
+ generatedLabel = document.createElement("label");
102
+ generatedLabel.setAttribute("slot", "label");
103
+ generatedLabel.setAttribute("data-fig-property-generated", "");
104
+ this.insertBefore(generatedLabel, this.input);
105
+ }
106
+ generatedLabel.textContent = labelText;
107
+ }
108
+
109
+ const label = authoredLabel || generatedLabel;
110
+ this.#associateLabel(label, this.input);
111
+ }
112
+
113
+ #associateLabel(label, input) {
114
+ this.#clearLabelAssociation();
115
+ if (!(label instanceof HTMLElement) || !(input instanceof HTMLElement)) {
116
+ return;
117
+ }
118
+
119
+ if (!label.id) {
120
+ figPropertyId += 1;
121
+ label.id = `fig-property-label-${figPropertyId}`;
122
+ label.setAttribute("data-fig-property-generated-id", "");
123
+ }
124
+
125
+ if (
126
+ !input.hasAttribute("aria-label") &&
127
+ !input.hasAttribute("aria-labelledby")
128
+ ) {
129
+ input.setAttribute("aria-labelledby", label.id);
130
+ input.setAttribute("data-fig-property-labelledby", "");
131
+ }
132
+
133
+ label.addEventListener("click", this.#focusInput);
134
+ this.#labelElement = label;
135
+ this.#associatedInput = input;
136
+ }
137
+
138
+ #clearLabelAssociation() {
139
+ this.#labelElement?.removeEventListener("click", this.#focusInput);
140
+ if (this.#associatedInput?.hasAttribute("data-fig-property-labelledby")) {
141
+ this.#associatedInput.removeAttribute("aria-labelledby");
142
+ this.#associatedInput.removeAttribute("data-fig-property-labelledby");
143
+ }
144
+ this.#labelElement = null;
145
+ this.#associatedInput = null;
146
+ }
147
+
148
+ #focusInput = () => {
149
+ const input = this.input;
150
+ if (!(input instanceof HTMLElement)) return;
151
+ input.focus();
152
+ if (document.activeElement !== input) {
153
+ input.querySelector("input, select, textarea, button")?.focus();
154
+ }
155
+ };
156
+
157
+ #syncLeading() {
158
+ const authored = this.#authoredSlot("leading");
159
+ const generated = this.#generatedSlot("leading");
160
+ const value = (this.getAttribute("leading") || "").trim().toLowerCase();
161
+
162
+ if (authored || !["drag", "chevron"].includes(value)) {
163
+ generated?.remove();
164
+ return;
165
+ }
166
+
167
+ const iconName = value === "drag" ? "drag-handle" : "chevron";
168
+ if (generated?.getAttribute("name") === iconName) return;
169
+
170
+ generated?.remove();
171
+ const icon = createIcon(iconName);
172
+ icon.setAttribute("slot", "leading");
173
+ icon.setAttribute("data-fig-property-generated", "");
174
+ this.prepend(icon);
175
+ }
176
+
177
+ #syncTrailing() {
178
+ const authored = this.#authoredSlot("trailing");
179
+ const generated = this.#generatedSlot("trailing");
180
+ const value = (this.getAttribute("trailing") || "").trim().toLowerCase();
181
+
182
+ if (authored || !["remove", "add", "bind"].includes(value)) {
183
+ generated?.remove();
184
+ return;
185
+ }
186
+
187
+ if (generated?.getAttribute("data-fig-property-action") === value) return;
188
+
189
+ generated?.remove();
190
+ const button = document.createElement("fig-button");
191
+ button.setAttribute("slot", "trailing");
192
+ button.setAttribute("variant", "ghost");
193
+ button.setAttribute("size", "small");
194
+ button.setAttribute("data-fig-property-generated", "");
195
+ button.setAttribute("data-fig-property-action", value);
196
+ button.setAttribute("aria-label", `${value[0].toUpperCase()}${value.slice(1)}`);
197
+
198
+ if (value === "bind") {
199
+ button.textContent = "Bind";
200
+ } else {
201
+ button.setAttribute("icon", "");
202
+ button.append(createIcon(value === "add" ? "add" : "minus"));
203
+ }
204
+
205
+ button.addEventListener("click", () => {
206
+ this.dispatchEvent(
207
+ new CustomEvent(value, {
208
+ bubbles: true,
209
+ composed: true,
210
+ detail: { input: this.input },
211
+ }),
212
+ );
213
+ });
214
+ this.append(button);
215
+ }
216
+ }
217
+
218
+ defineElement("fig-property", FigProperty);
219
+
220
+ export { FigProperty };