@rogieking/figui3 7.0.0 → 8.0.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/README.md +2 -2
- package/components.css +3 -267
- package/dist/components.css +1 -1
- package/dist/fig-editor.css +1 -1
- package/dist/fig-editor.js +129 -38
- package/dist/fig-lab.js +15 -13
- package/dist/fig.css +1 -1
- package/dist/fig.js +20 -111
- package/fig-editor.css +265 -0
- package/fig-editor.js +1320 -0
- package/fig-lab.js +85 -178
- package/fig.js +36 -1172
- package/package.json +1 -1
package/fig-editor.js
CHANGED
|
@@ -16,6 +16,1326 @@ function figEditorEscapeAttribute(value) {
|
|
|
16
16
|
.replace(/>/g, ">");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function figEditorBooleanAttribute(element, name) {
|
|
20
|
+
return element.hasAttribute(name) && element.getAttribute(name) !== "false";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function figEditorUniqueId() {
|
|
24
|
+
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function figEditorCreateIcon(name, options = {}) {
|
|
28
|
+
const icon = document.createElement("fig-icon");
|
|
29
|
+
if (name) icon.setAttribute("name", name);
|
|
30
|
+
if (options.size) icon.setAttribute("size", options.size);
|
|
31
|
+
if (options.className) icon.className = options.className;
|
|
32
|
+
return icon;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function figEditorCreateOverflowButtons({
|
|
36
|
+
owner,
|
|
37
|
+
onStart,
|
|
38
|
+
onEnd,
|
|
39
|
+
startClass = "",
|
|
40
|
+
endClass = "",
|
|
41
|
+
chevronClass = "",
|
|
42
|
+
startLabel = "Scroll back",
|
|
43
|
+
endLabel = "Scroll forward",
|
|
44
|
+
} = {}) {
|
|
45
|
+
const makeButton = (direction, onPointerDown) => {
|
|
46
|
+
const button = document.createElement("button");
|
|
47
|
+
button.type = "button";
|
|
48
|
+
button.className = [
|
|
49
|
+
"fig-overflow",
|
|
50
|
+
`fig-overflow-${direction}`,
|
|
51
|
+
direction === "start" ? startClass : endClass,
|
|
52
|
+
]
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join(" ");
|
|
55
|
+
button.dataset.figOverflow = direction;
|
|
56
|
+
if (owner) button.setAttribute(`data-fig-${owner}-nav`, direction);
|
|
57
|
+
button.setAttribute("tabindex", "-1");
|
|
58
|
+
button.setAttribute(
|
|
59
|
+
"aria-label",
|
|
60
|
+
direction === "start" ? startLabel : endLabel,
|
|
61
|
+
);
|
|
62
|
+
button.appendChild(
|
|
63
|
+
figEditorCreateIcon("chevron", {
|
|
64
|
+
size: "small",
|
|
65
|
+
className: ["fig-overflow-chevron", chevronClass].filter(Boolean).join(" "),
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
button.addEventListener("click", (event) => {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
event.stopPropagation();
|
|
71
|
+
onPointerDown?.(event);
|
|
72
|
+
});
|
|
73
|
+
return button;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
start: makeButton("start", onStart),
|
|
78
|
+
end: makeButton("end", onEnd),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function figEditorSyncOverflowState(host, scrollEl, axis = "x", threshold = 2) {
|
|
83
|
+
if (!host || !scrollEl) return false;
|
|
84
|
+
const isHorizontal = axis === "x";
|
|
85
|
+
const scrollSize = isHorizontal ? scrollEl.scrollWidth : scrollEl.scrollHeight;
|
|
86
|
+
const clientSize = isHorizontal ? scrollEl.clientWidth : scrollEl.clientHeight;
|
|
87
|
+
const scrollPosition = isHorizontal ? scrollEl.scrollLeft : scrollEl.scrollTop;
|
|
88
|
+
const scrollable = scrollSize - clientSize > threshold;
|
|
89
|
+
const atStart = !scrollable || scrollPosition <= threshold;
|
|
90
|
+
const atEnd = !scrollable || scrollPosition + clientSize >= scrollSize - threshold;
|
|
91
|
+
host.classList.toggle("overflow-start", !atStart);
|
|
92
|
+
host.classList.toggle("overflow-end", !atEnd);
|
|
93
|
+
return scrollable;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function figEditorScrollOverflowPage(scrollEl, axis = "x", direction = 1) {
|
|
97
|
+
if (!scrollEl) return;
|
|
98
|
+
const isHorizontal = axis === "x";
|
|
99
|
+
const pageSize = isHorizontal ? scrollEl.clientWidth : scrollEl.clientHeight;
|
|
100
|
+
const scrollAmount = pageSize * 0.8 * direction;
|
|
101
|
+
scrollEl.scrollBy({
|
|
102
|
+
[isHorizontal ? "left" : "top"]: scrollAmount,
|
|
103
|
+
behavior: "smooth",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function figEditorScrollElementToCenter(
|
|
108
|
+
scrollEl,
|
|
109
|
+
element,
|
|
110
|
+
axis = "y",
|
|
111
|
+
behavior = "auto",
|
|
112
|
+
) {
|
|
113
|
+
if (!scrollEl || !element || !scrollEl.contains(element)) return;
|
|
114
|
+
requestAnimationFrame(() => {
|
|
115
|
+
if (!scrollEl.isConnected || !element.isConnected) return;
|
|
116
|
+
const isHorizontal = axis === "x";
|
|
117
|
+
const scrollSize = isHorizontal
|
|
118
|
+
? scrollEl.scrollWidth
|
|
119
|
+
: scrollEl.scrollHeight;
|
|
120
|
+
const clientSize = isHorizontal
|
|
121
|
+
? scrollEl.clientWidth
|
|
122
|
+
: scrollEl.clientHeight;
|
|
123
|
+
if (scrollSize <= clientSize + 1) {
|
|
124
|
+
figEditorSyncOverflowState(scrollEl, scrollEl, axis);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const elementRect = element.getBoundingClientRect();
|
|
128
|
+
const hostRect = scrollEl.getBoundingClientRect();
|
|
129
|
+
const currentScroll = isHorizontal
|
|
130
|
+
? scrollEl.scrollLeft
|
|
131
|
+
: scrollEl.scrollTop;
|
|
132
|
+
const elementStart =
|
|
133
|
+
(isHorizontal ? elementRect.left - hostRect.left : elementRect.top - hostRect.top) +
|
|
134
|
+
currentScroll;
|
|
135
|
+
const elementSize = isHorizontal ? elementRect.width : elementRect.height;
|
|
136
|
+
const maxScroll = scrollSize - clientSize;
|
|
137
|
+
const nextScroll = Math.max(
|
|
138
|
+
0,
|
|
139
|
+
Math.min(elementStart + elementSize / 2 - clientSize / 2, maxScroll),
|
|
140
|
+
);
|
|
141
|
+
scrollEl.scrollTo({
|
|
142
|
+
[isHorizontal ? "left" : "top"]: nextScroll,
|
|
143
|
+
behavior,
|
|
144
|
+
});
|
|
145
|
+
figEditorSyncOverflowState(scrollEl, scrollEl, axis);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
/* Select — dropdown-styled trigger + fig-popup listbox */
|
|
151
|
+
/** Parse options attr — same formats as fig-options / propskit-select. */
|
|
152
|
+
function figSelectParseOptionsAttribute(raw) {
|
|
153
|
+
const text = raw || "";
|
|
154
|
+
if (text.startsWith("[")) {
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(text);
|
|
157
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
158
|
+
} catch {
|
|
159
|
+
/* fall through */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const delimiter = text.includes("\n") ? "\n" : ",";
|
|
163
|
+
return text
|
|
164
|
+
.split(delimiter)
|
|
165
|
+
.map((s) => s.trim())
|
|
166
|
+
.filter(Boolean);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function figSelectOptionEntryValue(opt) {
|
|
170
|
+
if (opt && typeof opt === "object") {
|
|
171
|
+
return String(opt.value ?? opt.label ?? "");
|
|
172
|
+
}
|
|
173
|
+
return String(opt ?? "");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function figSelectOptionEntryLabel(opt) {
|
|
177
|
+
if (opt && typeof opt === "object") {
|
|
178
|
+
return String(opt.label ?? opt.value ?? "");
|
|
179
|
+
}
|
|
180
|
+
return String(opt ?? "");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A selectable option for fig-select.
|
|
185
|
+
* Supports light-DOM slots: `slot="prepend"` (leading) and `slot="append"` (trailing).
|
|
186
|
+
* Use the `label` attribute for the closed-trigger label when option content is rich.
|
|
187
|
+
*
|
|
188
|
+
* @attr {string} value - Option value
|
|
189
|
+
* @attr {string} label - Optional display label for the select trigger
|
|
190
|
+
* @attr {boolean} disabled - Whether the option is disabled
|
|
191
|
+
* @attr {boolean} selected - Whether the option is selected
|
|
192
|
+
*/
|
|
193
|
+
class FigSelectOption extends HTMLElement {
|
|
194
|
+
static get observedAttributes() {
|
|
195
|
+
return ["value", "disabled", "selected", "label"];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get value() {
|
|
199
|
+
const attr = this.getAttribute("value");
|
|
200
|
+
if (attr !== null) return attr;
|
|
201
|
+
return (this.textContent || "").trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
set value(val) {
|
|
205
|
+
if (val === null || val === undefined) {
|
|
206
|
+
this.removeAttribute("value");
|
|
207
|
+
} else {
|
|
208
|
+
this.setAttribute("value", String(val));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
get disabled() {
|
|
213
|
+
return figEditorBooleanAttribute(this, "disabled");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
set disabled(val) {
|
|
217
|
+
if (val) this.setAttribute("disabled", "");
|
|
218
|
+
else this.removeAttribute("disabled");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get selected() {
|
|
222
|
+
return figEditorBooleanAttribute(this, "selected");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
set selected(val) {
|
|
226
|
+
if (val) this.setAttribute("selected", "");
|
|
227
|
+
else this.removeAttribute("selected");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
connectedCallback() {
|
|
231
|
+
if (!this.hasAttribute("role")) this.setAttribute("role", "option");
|
|
232
|
+
if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
|
|
233
|
+
this.#syncDisabled();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
237
|
+
if (oldValue === newValue) return;
|
|
238
|
+
if (name === "disabled") this.#syncDisabled();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#syncDisabled() {
|
|
242
|
+
const disabled = this.disabled;
|
|
243
|
+
if (disabled) {
|
|
244
|
+
this.setAttribute("aria-disabled", "true");
|
|
245
|
+
this.setAttribute("tabindex", "-1");
|
|
246
|
+
} else {
|
|
247
|
+
this.removeAttribute("aria-disabled");
|
|
248
|
+
if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
figEditorDefineElement("fig-select-option", FigSelectOption);
|
|
253
|
+
|
|
254
|
+
/** Light-DOM panel wrapper projected into fig-select's popup; owns overflow buttons. */
|
|
255
|
+
class FigSelectOptions extends HTMLElement {
|
|
256
|
+
#navStart = null;
|
|
257
|
+
#navEnd = null;
|
|
258
|
+
#resizeObserver = null;
|
|
259
|
+
#boundSyncOverflow = this.syncOverflow.bind(this);
|
|
260
|
+
|
|
261
|
+
connectedCallback() {
|
|
262
|
+
if (!this.hasAttribute("slot")) this.setAttribute("slot", "panel");
|
|
263
|
+
this.#unwrapLegacyChooser();
|
|
264
|
+
this.#markFirstSeparatorBorderless();
|
|
265
|
+
this.#ensureNavButtons();
|
|
266
|
+
this.addEventListener("scroll", this.#boundSyncOverflow, { passive: true });
|
|
267
|
+
this.#resizeObserver?.disconnect();
|
|
268
|
+
this.#resizeObserver = new ResizeObserver(() => this.syncOverflow());
|
|
269
|
+
this.#resizeObserver.observe(this);
|
|
270
|
+
requestAnimationFrame(() => this.syncOverflow());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
disconnectedCallback() {
|
|
274
|
+
this.removeEventListener("scroll", this.#boundSyncOverflow);
|
|
275
|
+
this.#resizeObserver?.disconnect();
|
|
276
|
+
this.#resizeObserver = null;
|
|
277
|
+
this.#removeNavButtons();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
syncOverflow() {
|
|
281
|
+
this.#markFirstSeparatorBorderless();
|
|
282
|
+
return figEditorSyncOverflowState(this, this, "y");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
scrollToOption(option, behavior = "auto") {
|
|
286
|
+
figEditorScrollElementToCenter(this, option, "y", behavior);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#unwrapLegacyChooser() {
|
|
290
|
+
const chooser = this.querySelector(":scope > fig-chooser");
|
|
291
|
+
if (!chooser) return;
|
|
292
|
+
while (chooser.firstChild) {
|
|
293
|
+
this.insertBefore(chooser.firstChild, chooser);
|
|
294
|
+
}
|
|
295
|
+
chooser.remove();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#markFirstSeparatorBorderless() {
|
|
299
|
+
const firstContent = Array.from(this.children).find(
|
|
300
|
+
(child) => !child.hasAttribute("data-fig-select-nav"),
|
|
301
|
+
);
|
|
302
|
+
if (firstContent?.tagName === "FIG-SEPARATOR") {
|
|
303
|
+
firstContent.setAttribute("borderless", "");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#ensureNavButtons() {
|
|
308
|
+
if (
|
|
309
|
+
this.#navStart &&
|
|
310
|
+
this.#navEnd &&
|
|
311
|
+
this.contains(this.#navStart) &&
|
|
312
|
+
this.contains(this.#navEnd)
|
|
313
|
+
) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
this.#removeNavButtons();
|
|
317
|
+
const buttons = figEditorCreateOverflowButtons({
|
|
318
|
+
owner: "select",
|
|
319
|
+
startLabel: "Scroll up",
|
|
320
|
+
endLabel: "Scroll down",
|
|
321
|
+
onStart: () => figEditorScrollOverflowPage(this, "y", -1),
|
|
322
|
+
onEnd: () => figEditorScrollOverflowPage(this, "y", 1),
|
|
323
|
+
});
|
|
324
|
+
this.#navStart = buttons.start;
|
|
325
|
+
this.#navEnd = buttons.end;
|
|
326
|
+
this.prepend(this.#navStart);
|
|
327
|
+
this.append(this.#navEnd);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#removeNavButtons() {
|
|
331
|
+
this.#navStart?.remove();
|
|
332
|
+
this.#navEnd?.remove();
|
|
333
|
+
this.#navStart = null;
|
|
334
|
+
this.#navEnd = null;
|
|
335
|
+
this.classList.remove("overflow-start", "overflow-end");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
figEditorDefineElement("fig-select-options", FigSelectOptions);
|
|
339
|
+
|
|
340
|
+
class FigSelect extends HTMLElement {
|
|
341
|
+
#button = null;
|
|
342
|
+
#popup = null;
|
|
343
|
+
#prependEl = null;
|
|
344
|
+
#labelEl = null;
|
|
345
|
+
#panelSlot = null;
|
|
346
|
+
#observer = null;
|
|
347
|
+
#initialized = false;
|
|
348
|
+
#focusedIndex = -1;
|
|
349
|
+
#syncingValue = false;
|
|
350
|
+
#popupPositionPatched = false;
|
|
351
|
+
#originalPositionPopup = null;
|
|
352
|
+
/**
|
|
353
|
+
* After open align, ignore content/scroll-driven positionPopup passes so
|
|
354
|
+
* overflow paging isn't yanked back. Still realign when the trigger moves
|
|
355
|
+
* or the viewport size changes (window resize, layout shift, page scroll).
|
|
356
|
+
*/
|
|
357
|
+
#freezeMenuPosition = false;
|
|
358
|
+
#frozenLabelRect = null;
|
|
359
|
+
#frozenViewport = null;
|
|
360
|
+
#syncingOptions = false;
|
|
361
|
+
#boundTriggerClick = this.#handleTriggerClick.bind(this);
|
|
362
|
+
#boundOptionClick = this.#handleOptionClick.bind(this);
|
|
363
|
+
#boundOptionPointerOver = this.#handleOptionPointerOver.bind(this);
|
|
364
|
+
#boundKeydown = this.#handleKeydown.bind(this);
|
|
365
|
+
#boundPopupClose = this.#handlePopupClose.bind(this);
|
|
366
|
+
#boundSlotChange = this.#handleSlotChange.bind(this);
|
|
367
|
+
|
|
368
|
+
static get observedAttributes() {
|
|
369
|
+
return [
|
|
370
|
+
"value",
|
|
371
|
+
"disabled",
|
|
372
|
+
"label",
|
|
373
|
+
"options",
|
|
374
|
+
"position",
|
|
375
|
+
"offset",
|
|
376
|
+
"closedby",
|
|
377
|
+
"open",
|
|
378
|
+
];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
get value() {
|
|
382
|
+
return this.getAttribute("value") ?? "";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
set value(val) {
|
|
386
|
+
if (val === null || val === undefined) this.removeAttribute("value");
|
|
387
|
+
else this.setAttribute("value", String(val));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
get open() {
|
|
391
|
+
return figEditorBooleanAttribute(this, "open");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
set open(val) {
|
|
395
|
+
if (val) this.setAttribute("open", "");
|
|
396
|
+
else this.removeAttribute("open");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
connectedCallback() {
|
|
400
|
+
if (!this.#initialized) this.#initialize();
|
|
401
|
+
this.#ensurePanelSlotAttrs();
|
|
402
|
+
this.#syncOptionsFromAttribute();
|
|
403
|
+
this.#syncDisabled();
|
|
404
|
+
this.#syncPopupAttrs();
|
|
405
|
+
this.#syncValue();
|
|
406
|
+
this.#setupListeners();
|
|
407
|
+
this.#setupObserver();
|
|
408
|
+
if (this.open) this.#openList();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
disconnectedCallback() {
|
|
412
|
+
this.#teardownListeners();
|
|
413
|
+
document.removeEventListener("keydown", this.#boundKeydown, true);
|
|
414
|
+
this.#observer?.disconnect();
|
|
415
|
+
this.#observer = null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
419
|
+
if (oldValue === newValue || !this.#initialized) return;
|
|
420
|
+
if (name === "options") {
|
|
421
|
+
this.#syncOptionsFromAttribute();
|
|
422
|
+
this.#syncValue();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (name === "value" || name === "label") {
|
|
426
|
+
this.#syncValue();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (name === "disabled") {
|
|
430
|
+
this.#syncDisabled();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (name === "open") {
|
|
434
|
+
if (newValue === null || newValue === "false") this.#closeList();
|
|
435
|
+
else this.#openList();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (name === "position" || name === "offset" || name === "closedby") {
|
|
439
|
+
this.#syncPopupAttrs();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
focus(options) {
|
|
444
|
+
this.#button?.focus(options);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
blur() {
|
|
448
|
+
this.#button?.blur();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
#isMenuChild(node) {
|
|
452
|
+
return (
|
|
453
|
+
node?.nodeType === 1 &&
|
|
454
|
+
(node.tagName === "FIG-SELECT-OPTION" ||
|
|
455
|
+
node.tagName === "FIG-SEPARATOR" ||
|
|
456
|
+
node.tagName === "FIG-SELECT-OPTIONS")
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
#ensurePanelSlotAttrs() {
|
|
461
|
+
for (const panel of this.querySelectorAll(":scope > fig-select-options")) {
|
|
462
|
+
if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#getPanel() {
|
|
467
|
+
const assigned = this.#panelSlot?.assignedElements({ flatten: true }) ?? [];
|
|
468
|
+
const fromSlot = assigned.find(
|
|
469
|
+
(el) => el.tagName === "FIG-SELECT-OPTIONS",
|
|
470
|
+
);
|
|
471
|
+
if (fromSlot) return fromSlot;
|
|
472
|
+
return this.querySelector(":scope > fig-select-options");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#hasAuthoredOptions() {
|
|
476
|
+
return Boolean(
|
|
477
|
+
this.querySelector(
|
|
478
|
+
":scope > fig-select-option:not([data-fig-generated]), :scope > fig-select-options > fig-select-option:not([data-fig-generated])",
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#ensureOptionsPanel() {
|
|
484
|
+
let panel = this.#getPanel();
|
|
485
|
+
if (panel) {
|
|
486
|
+
if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
|
|
487
|
+
return panel;
|
|
488
|
+
}
|
|
489
|
+
panel = document.createElement("fig-select-options");
|
|
490
|
+
panel.setAttribute("slot", "panel");
|
|
491
|
+
panel.setAttribute("data-fig-generated", "");
|
|
492
|
+
this.appendChild(panel);
|
|
493
|
+
return panel;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* When no authored fig-select-option exists, build panel/options from the
|
|
498
|
+
* options attribute (comma / newline / JSON — same as fig-options).
|
|
499
|
+
*/
|
|
500
|
+
#syncOptionsFromAttribute() {
|
|
501
|
+
if (this.#hasAuthoredOptions()) return;
|
|
502
|
+
|
|
503
|
+
const hasOptionsAttr = this.hasAttribute("options");
|
|
504
|
+
const panel = hasOptionsAttr
|
|
505
|
+
? this.#ensureOptionsPanel()
|
|
506
|
+
: this.#getPanel();
|
|
507
|
+
if (!panel) return;
|
|
508
|
+
|
|
509
|
+
this.#syncingOptions = true;
|
|
510
|
+
try {
|
|
511
|
+
for (const opt of panel.querySelectorAll(
|
|
512
|
+
":scope > fig-select-option[data-fig-generated]",
|
|
513
|
+
)) {
|
|
514
|
+
opt.remove();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (!hasOptionsAttr) return;
|
|
518
|
+
|
|
519
|
+
const parsed = figSelectParseOptionsAttribute(this.getAttribute("options"));
|
|
520
|
+
const endBtn = panel.querySelector(":scope > .fig-overflow-end");
|
|
521
|
+
for (const entry of parsed) {
|
|
522
|
+
const el = document.createElement("fig-select-option");
|
|
523
|
+
el.setAttribute("data-fig-generated", "");
|
|
524
|
+
el.setAttribute("value", figSelectOptionEntryValue(entry));
|
|
525
|
+
el.textContent = figSelectOptionEntryLabel(entry);
|
|
526
|
+
if (endBtn) panel.insertBefore(el, endBtn);
|
|
527
|
+
else panel.appendChild(el);
|
|
528
|
+
}
|
|
529
|
+
} finally {
|
|
530
|
+
this.#syncingOptions = false;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
#initialize() {
|
|
535
|
+
this.#initialized = true;
|
|
536
|
+
const shadow = this.attachShadow({ mode: "open" });
|
|
537
|
+
shadow.innerHTML = `
|
|
538
|
+
<style>
|
|
539
|
+
:host {
|
|
540
|
+
display: inline-flex;
|
|
541
|
+
position: relative;
|
|
542
|
+
align-items: center;
|
|
543
|
+
min-width: 0;
|
|
544
|
+
}
|
|
545
|
+
:host([full]:not([full="false"])) {
|
|
546
|
+
display: flex;
|
|
547
|
+
width: 100%;
|
|
548
|
+
}
|
|
549
|
+
.fig-select-trigger {
|
|
550
|
+
display: flex;
|
|
551
|
+
align-items: center;
|
|
552
|
+
justify-content: flex-start;
|
|
553
|
+
flex: 1;
|
|
554
|
+
min-width: 0;
|
|
555
|
+
width: var(--fig-select-trigger-width, 100%);
|
|
556
|
+
height: 100%;
|
|
557
|
+
margin: 0;
|
|
558
|
+
padding: 0 var(--spacer-4, 1rem) 0 var(--spacer-2, 0.5rem);
|
|
559
|
+
border: 0;
|
|
560
|
+
border-radius: inherit;
|
|
561
|
+
background: transparent;
|
|
562
|
+
box-shadow: none;
|
|
563
|
+
color: inherit;
|
|
564
|
+
font: inherit;
|
|
565
|
+
font-weight: inherit;
|
|
566
|
+
text-align: left;
|
|
567
|
+
white-space: nowrap;
|
|
568
|
+
overflow: hidden;
|
|
569
|
+
text-overflow: ellipsis;
|
|
570
|
+
cursor: default;
|
|
571
|
+
}
|
|
572
|
+
.fig-select-trigger:has(.fig-select-prepend:not(:empty)) {
|
|
573
|
+
padding-left: 0;
|
|
574
|
+
}
|
|
575
|
+
.fig-select-trigger:hover,
|
|
576
|
+
.fig-select-trigger:active,
|
|
577
|
+
.fig-select-trigger:active:hover {
|
|
578
|
+
background: transparent;
|
|
579
|
+
box-shadow: none;
|
|
580
|
+
color: inherit;
|
|
581
|
+
}
|
|
582
|
+
.fig-select-trigger:focus-visible,
|
|
583
|
+
.fig-select-trigger[data-focus-visible] {
|
|
584
|
+
outline: var(--figma-focus-outline);
|
|
585
|
+
outline-offset: var(--figma-focus-outline-offset);
|
|
586
|
+
}
|
|
587
|
+
:host([disabled]:not([disabled="false"])) .fig-select-trigger,
|
|
588
|
+
:host([disabled]:not([disabled="false"])) .fig-select-label {
|
|
589
|
+
color: var(--figma-color-text-tertiary);
|
|
590
|
+
}
|
|
591
|
+
.fig-select-label {
|
|
592
|
+
display: block;
|
|
593
|
+
width: 100%;
|
|
594
|
+
min-width: 0;
|
|
595
|
+
overflow: hidden;
|
|
596
|
+
text-overflow: ellipsis;
|
|
597
|
+
white-space: nowrap;
|
|
598
|
+
text-align: left;
|
|
599
|
+
}
|
|
600
|
+
.fig-select-prepend {
|
|
601
|
+
display: inline-flex;
|
|
602
|
+
flex: 0 0 auto;
|
|
603
|
+
align-items: center;
|
|
604
|
+
margin-right: var(--spacer-1, 0.25rem);
|
|
605
|
+
pointer-events: none;
|
|
606
|
+
}
|
|
607
|
+
.fig-select-prepend:empty {
|
|
608
|
+
display: none;
|
|
609
|
+
}
|
|
610
|
+
/* Listbox chrome from document fig-select::part(listbox).
|
|
611
|
+
Overflow UI lives on slotted fig-select-options.
|
|
612
|
+
Never set display except when open — closed <dialog> must stay display:none. */
|
|
613
|
+
dialog[is="fig-popup"] {
|
|
614
|
+
flex-direction: column;
|
|
615
|
+
overflow: hidden;
|
|
616
|
+
}
|
|
617
|
+
dialog[is="fig-popup"][open] {
|
|
618
|
+
display: flex;
|
|
619
|
+
}
|
|
620
|
+
::slotted(fig-select-options) {
|
|
621
|
+
flex: 1 1 auto;
|
|
622
|
+
min-height: 0;
|
|
623
|
+
max-height: inherit;
|
|
624
|
+
}
|
|
625
|
+
</style>
|
|
626
|
+
`;
|
|
627
|
+
|
|
628
|
+
const button = document.createElement("fig-button");
|
|
629
|
+
button.className = "fig-select-trigger";
|
|
630
|
+
button.setAttribute("part", "trigger");
|
|
631
|
+
button.setAttribute("variant", "ghost");
|
|
632
|
+
button.setAttribute("aria-haspopup", "listbox");
|
|
633
|
+
button.setAttribute("aria-expanded", "false");
|
|
634
|
+
|
|
635
|
+
const prependEl = document.createElement("span");
|
|
636
|
+
prependEl.className = "fig-select-prepend";
|
|
637
|
+
prependEl.setAttribute("part", "prepend");
|
|
638
|
+
prependEl.setAttribute("aria-hidden", "true");
|
|
639
|
+
|
|
640
|
+
const labelEl = document.createElement("span");
|
|
641
|
+
labelEl.className = "fig-select-label";
|
|
642
|
+
labelEl.setAttribute("part", "label");
|
|
643
|
+
button.append(prependEl, labelEl);
|
|
644
|
+
|
|
645
|
+
const popup = document.createElement("dialog", { is: "fig-popup" });
|
|
646
|
+
popup.setAttribute("is", "fig-popup");
|
|
647
|
+
popup.setAttribute("part", "listbox");
|
|
648
|
+
popup.setAttribute("theme", "menu");
|
|
649
|
+
popup.setAttribute("role", "listbox");
|
|
650
|
+
// Top-layer via popover so the menu escapes ancestor contain/overflow
|
|
651
|
+
// (e.g. fig-fill-picker-dialog). Stays in shadow so option slots still work —
|
|
652
|
+
// unlike tooltips, we cannot portal this popup to the overlay root.
|
|
653
|
+
if ("popover" in HTMLElement.prototype) {
|
|
654
|
+
popup.setAttribute("popover", "manual");
|
|
655
|
+
}
|
|
656
|
+
popup.id = figEditorUniqueId();
|
|
657
|
+
button.setAttribute("aria-controls", popup.id);
|
|
658
|
+
|
|
659
|
+
const panelSlot = document.createElement("slot");
|
|
660
|
+
panelSlot.setAttribute("name", "panel");
|
|
661
|
+
popup.appendChild(panelSlot);
|
|
662
|
+
|
|
663
|
+
shadow.append(button, popup);
|
|
664
|
+
|
|
665
|
+
this.#button = button;
|
|
666
|
+
this.#prependEl = prependEl;
|
|
667
|
+
this.#labelEl = labelEl;
|
|
668
|
+
this.#popup = popup;
|
|
669
|
+
this.#panelSlot = panelSlot;
|
|
670
|
+
popup.anchor = button;
|
|
671
|
+
|
|
672
|
+
this.#ensurePanelSlotAttrs();
|
|
673
|
+
this.#installPopupPositioning();
|
|
674
|
+
|
|
675
|
+
if (!this.hasAttribute("value")) {
|
|
676
|
+
const selected = this.#getOptions().find((opt) =>
|
|
677
|
+
figEditorBooleanAttribute(opt, "selected"),
|
|
678
|
+
);
|
|
679
|
+
if (selected) this.setAttribute("value", selected.value);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
#installPopupPositioning() {
|
|
684
|
+
if (!this.#popup || this.#popupPositionPatched) return;
|
|
685
|
+
if (typeof this.#popup.positionPopup !== "function") return;
|
|
686
|
+
this.#originalPositionPopup = this.#popup.positionPopup.bind(this.#popup);
|
|
687
|
+
this.#popup.positionPopup = () => {
|
|
688
|
+
if (!this.open) {
|
|
689
|
+
this.#originalPositionPopup?.();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
this.#positionPopupOverSelected();
|
|
693
|
+
};
|
|
694
|
+
this.#popupPositionPatched = true;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
#getOptionTextRect(option) {
|
|
698
|
+
if (!option) return null;
|
|
699
|
+
const range = document.createRange();
|
|
700
|
+
range.selectNodeContents(option);
|
|
701
|
+
const rects = [...range.getClientRects()].filter(
|
|
702
|
+
(rect) => rect.width > 0 && rect.height > 0,
|
|
703
|
+
);
|
|
704
|
+
if (rects.length) return rects[0];
|
|
705
|
+
return option.getBoundingClientRect();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
#getViewportMargins() {
|
|
709
|
+
if (typeof this.#popup?.parseViewportMargins === "function") {
|
|
710
|
+
return this.#popup.parseViewportMargins();
|
|
711
|
+
}
|
|
712
|
+
return { top: 8, right: 8, bottom: 8, left: 8 };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
#readLabelRectSnapshot() {
|
|
716
|
+
const rect = this.#labelEl?.getBoundingClientRect();
|
|
717
|
+
if (!rect) return null;
|
|
718
|
+
return {
|
|
719
|
+
x: rect.x,
|
|
720
|
+
y: rect.y,
|
|
721
|
+
width: rect.width,
|
|
722
|
+
height: rect.height,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
#readViewportSnapshot() {
|
|
727
|
+
const vv = window.visualViewport;
|
|
728
|
+
return {
|
|
729
|
+
width: vv?.width ?? window.innerWidth,
|
|
730
|
+
height: vv?.height ?? window.innerHeight,
|
|
731
|
+
offsetLeft: vv?.offsetLeft ?? 0,
|
|
732
|
+
offsetTop: vv?.offsetTop ?? 0,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
#rectSnapshotChanged(prev, next, epsilon = 0.25) {
|
|
737
|
+
if (!prev && !next) return false;
|
|
738
|
+
if (!prev || !next) return true;
|
|
739
|
+
return (
|
|
740
|
+
Math.abs(prev.x - next.x) > epsilon ||
|
|
741
|
+
Math.abs(prev.y - next.y) > epsilon ||
|
|
742
|
+
Math.abs(prev.width - next.width) > epsilon ||
|
|
743
|
+
Math.abs(prev.height - next.height) > epsilon
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
#viewportSnapshotChanged(prev, next, epsilon = 0.25) {
|
|
748
|
+
if (!prev && !next) return false;
|
|
749
|
+
if (!prev || !next) return true;
|
|
750
|
+
return (
|
|
751
|
+
Math.abs(prev.width - next.width) > epsilon ||
|
|
752
|
+
Math.abs(prev.height - next.height) > epsilon ||
|
|
753
|
+
Math.abs(prev.offsetLeft - next.offsetLeft) > epsilon ||
|
|
754
|
+
Math.abs(prev.offsetTop - next.offsetTop) > epsilon
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
#shouldSkipFrozenPositionPass() {
|
|
759
|
+
if (!this.#freezeMenuPosition) return false;
|
|
760
|
+
const labelMoved = this.#rectSnapshotChanged(
|
|
761
|
+
this.#frozenLabelRect,
|
|
762
|
+
this.#readLabelRectSnapshot(),
|
|
763
|
+
);
|
|
764
|
+
const viewportChanged = this.#viewportSnapshotChanged(
|
|
765
|
+
this.#frozenViewport,
|
|
766
|
+
this.#readViewportSnapshot(),
|
|
767
|
+
);
|
|
768
|
+
// Skip only when neither the trigger nor the viewport moved — typical of
|
|
769
|
+
// overflow scroll / content sync fighting the open-time alignment.
|
|
770
|
+
return !labelMoved && !viewportChanged;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
#rememberFrozenGeometry() {
|
|
774
|
+
this.#frozenLabelRect = this.#readLabelRectSnapshot();
|
|
775
|
+
this.#frozenViewport = this.#readViewportSnapshot();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
#positionPopupOverSelected() {
|
|
779
|
+
// Content ResizeObserver / overflow scroll re-enter here; keep the
|
|
780
|
+
// open-time alignment unless the trigger or viewport actually changed.
|
|
781
|
+
if (this.#shouldSkipFrozenPositionPass()) return;
|
|
782
|
+
|
|
783
|
+
const popup = this.#popup;
|
|
784
|
+
const label = this.#labelEl;
|
|
785
|
+
if (!popup || !label) {
|
|
786
|
+
this.#originalPositionPopup?.();
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const options = this.#getOptions();
|
|
791
|
+
const selected =
|
|
792
|
+
options.find((opt) => this.#optionValue(opt) === this.value) ||
|
|
793
|
+
options[0];
|
|
794
|
+
if (!selected) {
|
|
795
|
+
this.#originalPositionPopup?.();
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Lay out with the default positioning first so option metrics are valid.
|
|
800
|
+
this.#originalPositionPopup?.();
|
|
801
|
+
|
|
802
|
+
const popupRect = popup.getBoundingClientRect();
|
|
803
|
+
const labelRect = label.getBoundingClientRect();
|
|
804
|
+
const optionTextRect = this.#getOptionTextRect(selected);
|
|
805
|
+
if (
|
|
806
|
+
!popupRect.width ||
|
|
807
|
+
!popupRect.height ||
|
|
808
|
+
!labelRect.width ||
|
|
809
|
+
!optionTextRect
|
|
810
|
+
) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const selectedOffsetX = optionTextRect.left - popupRect.left;
|
|
815
|
+
const selectedOffsetY = optionTextRect.top - popupRect.top;
|
|
816
|
+
const full = figEditorBooleanAttribute(this, "full");
|
|
817
|
+
// [full]: pin menu to host width/edges. Otherwise overlay selected
|
|
818
|
+
// option text on the trigger label (blend-mode style).
|
|
819
|
+
let left = full
|
|
820
|
+
? this.getBoundingClientRect().left
|
|
821
|
+
: labelRect.left - selectedOffsetX;
|
|
822
|
+
let top = labelRect.top - selectedOffsetY;
|
|
823
|
+
|
|
824
|
+
// Keep the whole menu in-view when aligning over the selected option
|
|
825
|
+
// would otherwise push it past a viewport edge (corners / far sides).
|
|
826
|
+
const margins = this.#getViewportMargins();
|
|
827
|
+
if (typeof popup.clampToViewport === "function") {
|
|
828
|
+
({ left, top } = popup.clampToViewport({ left, top }, popupRect, margins));
|
|
829
|
+
} else {
|
|
830
|
+
const minLeft = margins.left;
|
|
831
|
+
const minTop = margins.top;
|
|
832
|
+
const maxLeft = window.innerWidth - popupRect.width - margins.right;
|
|
833
|
+
const maxTop = window.innerHeight - popupRect.height - margins.bottom;
|
|
834
|
+
left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft));
|
|
835
|
+
top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop));
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// !important: fig-select::part(listbox) and dialog UA rules can otherwise
|
|
839
|
+
// keep the menu at its static/anchor position past the viewport edge.
|
|
840
|
+
popup.style.setProperty("right", "auto", "important");
|
|
841
|
+
popup.style.setProperty("bottom", "auto", "important");
|
|
842
|
+
popup.style.setProperty("left", `${Math.round(left)}px`, "important");
|
|
843
|
+
popup.style.setProperty("top", `${Math.round(top)}px`, "important");
|
|
844
|
+
|
|
845
|
+
// Nudge the panel scroller so the selected label stays over the trigger.
|
|
846
|
+
const panel = this.#getPanel();
|
|
847
|
+
const alignedTextRect = this.#getOptionTextRect(selected);
|
|
848
|
+
if (
|
|
849
|
+
alignedTextRect &&
|
|
850
|
+
panel &&
|
|
851
|
+
panel.scrollHeight > panel.clientHeight + 1
|
|
852
|
+
) {
|
|
853
|
+
const deltaY = alignedTextRect.top - labelRect.top;
|
|
854
|
+
if (Math.abs(deltaY) > 0.5) {
|
|
855
|
+
panel.scrollTop += deltaY;
|
|
856
|
+
}
|
|
857
|
+
panel.syncOverflow?.();
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (this.#freezeMenuPosition || this.open) {
|
|
861
|
+
this.#rememberFrozenGeometry();
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
#setupListeners() {
|
|
866
|
+
this.#button?.addEventListener("click", this.#boundTriggerClick);
|
|
867
|
+
this.#button?.addEventListener("keydown", this.#boundKeydown);
|
|
868
|
+
// Host click: slotted options stay in light DOM (not dialog.contains).
|
|
869
|
+
this.addEventListener("click", this.#boundOptionClick);
|
|
870
|
+
this.addEventListener("pointerover", this.#boundOptionPointerOver);
|
|
871
|
+
this.#popup?.addEventListener("keydown", this.#boundKeydown);
|
|
872
|
+
this.#popup?.addEventListener("close", this.#boundPopupClose);
|
|
873
|
+
this.#panelSlot?.addEventListener("slotchange", this.#boundSlotChange);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
#teardownListeners() {
|
|
877
|
+
this.#button?.removeEventListener("click", this.#boundTriggerClick);
|
|
878
|
+
this.#button?.removeEventListener("keydown", this.#boundKeydown);
|
|
879
|
+
this.removeEventListener("click", this.#boundOptionClick);
|
|
880
|
+
this.removeEventListener("pointerover", this.#boundOptionPointerOver);
|
|
881
|
+
this.#popup?.removeEventListener("keydown", this.#boundKeydown);
|
|
882
|
+
this.#popup?.removeEventListener("close", this.#boundPopupClose);
|
|
883
|
+
this.#panelSlot?.removeEventListener("slotchange", this.#boundSlotChange);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
#handleSlotChange() {
|
|
887
|
+
this.#ensurePanelSlotAttrs();
|
|
888
|
+
this.#syncValue();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
#setupObserver() {
|
|
892
|
+
if (this.#observer) return;
|
|
893
|
+
this.#observer = new MutationObserver((mutations) => {
|
|
894
|
+
if (this.#syncingValue || this.#syncingOptions) return;
|
|
895
|
+
let needsSync = false;
|
|
896
|
+
for (const mutation of mutations) {
|
|
897
|
+
if (mutation.type === "childList") {
|
|
898
|
+
if (
|
|
899
|
+
[...mutation.addedNodes].some((node) => this.#isMenuChild(node)) ||
|
|
900
|
+
[...mutation.removedNodes].some((node) => this.#isMenuChild(node)) ||
|
|
901
|
+
mutation.target?.closest?.("fig-select-option")
|
|
902
|
+
) {
|
|
903
|
+
needsSync = true;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (
|
|
907
|
+
mutation.type === "attributes" &&
|
|
908
|
+
mutation.target?.tagName === "FIG-SELECT-OPTION"
|
|
909
|
+
) {
|
|
910
|
+
if (
|
|
911
|
+
mutation.attributeName === "value" ||
|
|
912
|
+
mutation.attributeName === "disabled" ||
|
|
913
|
+
mutation.attributeName === "label"
|
|
914
|
+
) {
|
|
915
|
+
needsSync = true;
|
|
916
|
+
} else if (mutation.attributeName === "selected") {
|
|
917
|
+
if (figEditorBooleanAttribute(mutation.target, "selected")) {
|
|
918
|
+
const nextValue = this.#optionValue(mutation.target);
|
|
919
|
+
if ((this.getAttribute("value") ?? "") !== nextValue) {
|
|
920
|
+
this.setAttribute("value", nextValue);
|
|
921
|
+
needsSync = true;
|
|
922
|
+
}
|
|
923
|
+
} else if (
|
|
924
|
+
this.#optionValue(mutation.target) ===
|
|
925
|
+
(this.getAttribute("value") ?? "")
|
|
926
|
+
) {
|
|
927
|
+
needsSync = true;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (
|
|
932
|
+
mutation.type === "characterData" &&
|
|
933
|
+
mutation.target?.parentElement?.tagName === "FIG-SELECT-OPTION"
|
|
934
|
+
) {
|
|
935
|
+
needsSync = true;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (needsSync) this.#syncValue();
|
|
939
|
+
});
|
|
940
|
+
this.#observer.observe(this, {
|
|
941
|
+
childList: true,
|
|
942
|
+
subtree: true,
|
|
943
|
+
characterData: true,
|
|
944
|
+
attributes: true,
|
|
945
|
+
attributeFilter: ["value", "disabled", "selected", "label"],
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
#getOptions({ enabledOnly = false } = {}) {
|
|
950
|
+
const panel = this.#getPanel();
|
|
951
|
+
const options = panel
|
|
952
|
+
? Array.from(panel.querySelectorAll(":scope > fig-select-option"))
|
|
953
|
+
: [];
|
|
954
|
+
if (!enabledOnly) return options;
|
|
955
|
+
return options.filter((opt) => !figEditorBooleanAttribute(opt, "disabled"));
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
#optionValue(option) {
|
|
959
|
+
if (!option) return "";
|
|
960
|
+
if (typeof option.value === "string") return option.value;
|
|
961
|
+
const attr = option.getAttribute?.("value");
|
|
962
|
+
if (attr != null) return attr;
|
|
963
|
+
return (option.textContent || "").trim();
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
#optionLabel(option) {
|
|
967
|
+
if (!option) return "";
|
|
968
|
+
const labelAttr = option.getAttribute?.("label");
|
|
969
|
+
if (labelAttr != null && labelAttr !== "") return labelAttr.trim();
|
|
970
|
+
|
|
971
|
+
// Ignore prepend/append slot content when deriving a label from children.
|
|
972
|
+
const parts = [];
|
|
973
|
+
for (const node of option.childNodes) {
|
|
974
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
975
|
+
const text = node.textContent?.trim();
|
|
976
|
+
if (text) parts.push(text);
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
if (!(node instanceof Element)) continue;
|
|
980
|
+
const slot = node.getAttribute("slot");
|
|
981
|
+
if (slot === "prepend" || slot === "append") continue;
|
|
982
|
+
const text = node.textContent?.trim();
|
|
983
|
+
if (text) parts.push(text);
|
|
984
|
+
}
|
|
985
|
+
if (parts.length) return parts.join(" ").trim();
|
|
986
|
+
return (option.textContent || "").trim();
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
#syncPrepend(option) {
|
|
990
|
+
if (!this.#prependEl) return;
|
|
991
|
+
const source = option?.querySelector?.(':scope > [slot="prepend"]');
|
|
992
|
+
this.#prependEl.replaceChildren(
|
|
993
|
+
...Array.from(source?.childNodes ?? [], (node) => node.cloneNode(true)),
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
#syncPopupAttrs() {
|
|
998
|
+
if (!this.#popup) return;
|
|
999
|
+
this.#popup.setAttribute(
|
|
1000
|
+
"position",
|
|
1001
|
+
this.getAttribute("position") || "bottom left",
|
|
1002
|
+
);
|
|
1003
|
+
const offset = this.getAttribute("offset");
|
|
1004
|
+
if (offset) this.#popup.setAttribute("offset", offset);
|
|
1005
|
+
else this.#popup.removeAttribute("offset");
|
|
1006
|
+
const closedby = this.getAttribute("closedby");
|
|
1007
|
+
if (closedby) this.#popup.setAttribute("closedby", closedby);
|
|
1008
|
+
else this.#popup.removeAttribute("closedby");
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
#syncDisabled() {
|
|
1012
|
+
const disabled = figEditorBooleanAttribute(this, "disabled");
|
|
1013
|
+
if (this.#button) {
|
|
1014
|
+
if (disabled) this.#button.setAttribute("disabled", "");
|
|
1015
|
+
else this.#button.removeAttribute("disabled");
|
|
1016
|
+
}
|
|
1017
|
+
if (disabled && this.open) this.open = false;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
#pickFallbackOption(options) {
|
|
1021
|
+
if (!options.length) return null;
|
|
1022
|
+
const selected = options.find((opt) =>
|
|
1023
|
+
figEditorBooleanAttribute(opt, "selected"),
|
|
1024
|
+
);
|
|
1025
|
+
if (selected && !figEditorBooleanAttribute(selected, "disabled")) {
|
|
1026
|
+
return selected;
|
|
1027
|
+
}
|
|
1028
|
+
return (
|
|
1029
|
+
options.find((opt) => !figEditorBooleanAttribute(opt, "disabled")) ||
|
|
1030
|
+
options[0] ||
|
|
1031
|
+
null
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
#emitValueEvents(value) {
|
|
1036
|
+
this.dispatchEvent(
|
|
1037
|
+
new CustomEvent("input", {
|
|
1038
|
+
detail: value,
|
|
1039
|
+
bubbles: true,
|
|
1040
|
+
composed: true,
|
|
1041
|
+
}),
|
|
1042
|
+
);
|
|
1043
|
+
this.dispatchEvent(
|
|
1044
|
+
new CustomEvent("change", {
|
|
1045
|
+
detail: value,
|
|
1046
|
+
bubbles: true,
|
|
1047
|
+
composed: true,
|
|
1048
|
+
}),
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
#syncValue() {
|
|
1053
|
+
if (this.#syncingValue) return;
|
|
1054
|
+
this.#syncingValue = true;
|
|
1055
|
+
try {
|
|
1056
|
+
const options = this.#getOptions();
|
|
1057
|
+
const hasValueAttr = this.hasAttribute("value");
|
|
1058
|
+
const previousValue = hasValueAttr ? this.getAttribute("value") : null;
|
|
1059
|
+
let match = hasValueAttr
|
|
1060
|
+
? options.find((opt) => this.#optionValue(opt) === previousValue)
|
|
1061
|
+
: null;
|
|
1062
|
+
let valueCorrected = false;
|
|
1063
|
+
|
|
1064
|
+
if (!match) {
|
|
1065
|
+
if (hasValueAttr) {
|
|
1066
|
+
// Options may not be built yet (options attr sync). Keep value until then.
|
|
1067
|
+
if (!options.length) {
|
|
1068
|
+
if (this.#labelEl) {
|
|
1069
|
+
this.#labelEl.textContent =
|
|
1070
|
+
previousValue || this.getAttribute("label") || "";
|
|
1071
|
+
}
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
// Value orphaned (option removed / value attr changed) — clamp or clear.
|
|
1075
|
+
match = this.#pickFallbackOption(options);
|
|
1076
|
+
if (match) {
|
|
1077
|
+
const nextValue = this.#optionValue(match);
|
|
1078
|
+
if (previousValue !== nextValue) {
|
|
1079
|
+
this.setAttribute("value", nextValue);
|
|
1080
|
+
valueCorrected = true;
|
|
1081
|
+
}
|
|
1082
|
+
} else {
|
|
1083
|
+
this.removeAttribute("value");
|
|
1084
|
+
valueCorrected = true;
|
|
1085
|
+
}
|
|
1086
|
+
} else {
|
|
1087
|
+
// No host value yet — honor a selected option if present.
|
|
1088
|
+
match = options.find((opt) =>
|
|
1089
|
+
figEditorBooleanAttribute(opt, "selected"),
|
|
1090
|
+
);
|
|
1091
|
+
if (match) {
|
|
1092
|
+
this.setAttribute("value", this.#optionValue(match));
|
|
1093
|
+
valueCorrected = true;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
for (const opt of options) {
|
|
1099
|
+
const selected = opt === match;
|
|
1100
|
+
opt.setAttribute("aria-selected", selected ? "true" : "false");
|
|
1101
|
+
if (selected) opt.setAttribute("selected", "");
|
|
1102
|
+
else opt.removeAttribute("selected");
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const label =
|
|
1106
|
+
(match && this.#optionLabel(match)) || this.getAttribute("label") || "";
|
|
1107
|
+
if (this.#labelEl) this.#labelEl.textContent = label;
|
|
1108
|
+
this.#syncPrepend(match);
|
|
1109
|
+
|
|
1110
|
+
const ariaLabel = this.getAttribute("label") || "Select";
|
|
1111
|
+
this.#button?.setAttribute("aria-label", ariaLabel);
|
|
1112
|
+
|
|
1113
|
+
// Don't scrollToOption while open — reposition/sync would fight overflow paging.
|
|
1114
|
+
this.#getPanel()?.syncOverflow?.();
|
|
1115
|
+
|
|
1116
|
+
if (valueCorrected) {
|
|
1117
|
+
this.#emitValueEvents(this.getAttribute("value") ?? "");
|
|
1118
|
+
}
|
|
1119
|
+
} finally {
|
|
1120
|
+
this.#syncingValue = false;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
#handleTriggerClick(e) {
|
|
1125
|
+
if (figEditorBooleanAttribute(this, "disabled")) return;
|
|
1126
|
+
e.preventDefault();
|
|
1127
|
+
e.stopPropagation();
|
|
1128
|
+
const nextOpen = !this.open;
|
|
1129
|
+
if (nextOpen && this.#popup && this.#button) {
|
|
1130
|
+
this.#popup.anchor = this.#button;
|
|
1131
|
+
}
|
|
1132
|
+
this.open = nextOpen;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
#handleOptionClick(e) {
|
|
1136
|
+
const path = typeof e.composedPath === "function" ? e.composedPath() : [];
|
|
1137
|
+
const option = path.find(
|
|
1138
|
+
(node) => node?.tagName === "FIG-SELECT-OPTION",
|
|
1139
|
+
);
|
|
1140
|
+
if (!option || !this.contains(option)) return;
|
|
1141
|
+
if (figEditorBooleanAttribute(option, "disabled")) return;
|
|
1142
|
+
// Do not stopPropagation — React light-DOM onClick must still fire.
|
|
1143
|
+
this.#selectOption(option);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Fires `optionhover` with the enabled option's value in `event.detail`
|
|
1148
|
+
* without changing the current selection.
|
|
1149
|
+
*/
|
|
1150
|
+
#handleOptionPointerOver(e) {
|
|
1151
|
+
const path = typeof e.composedPath === "function" ? e.composedPath() : [];
|
|
1152
|
+
const option = path.find(
|
|
1153
|
+
(node) => node?.tagName === "FIG-SELECT-OPTION",
|
|
1154
|
+
);
|
|
1155
|
+
if (!option || !this.contains(option)) return;
|
|
1156
|
+
if (figEditorBooleanAttribute(option, "disabled")) return;
|
|
1157
|
+
if (
|
|
1158
|
+
e.relatedTarget instanceof Node &&
|
|
1159
|
+
option.contains(e.relatedTarget)
|
|
1160
|
+
) {
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
this.dispatchEvent(
|
|
1164
|
+
new CustomEvent("optionhover", {
|
|
1165
|
+
detail: this.#optionValue(option),
|
|
1166
|
+
bubbles: true,
|
|
1167
|
+
composed: true,
|
|
1168
|
+
}),
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
#handleKeydown(e) {
|
|
1173
|
+
if (e.currentTarget === document && e.key !== "Escape") return;
|
|
1174
|
+
|
|
1175
|
+
const listOpen = this.open && (this.#popup?.matches?.(":open") ?? false);
|
|
1176
|
+
if (!listOpen) {
|
|
1177
|
+
if (
|
|
1178
|
+
this.#button?.contains(e.target) &&
|
|
1179
|
+
(e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")
|
|
1180
|
+
) {
|
|
1181
|
+
e.preventDefault();
|
|
1182
|
+
if (this.#popup && this.#button) this.#popup.anchor = this.#button;
|
|
1183
|
+
this.open = true;
|
|
1184
|
+
requestAnimationFrame(() => {
|
|
1185
|
+
const options = this.#getOptions({ enabledOnly: true });
|
|
1186
|
+
const selectedIndex = options.findIndex(
|
|
1187
|
+
(opt) => this.#optionValue(opt) === this.value,
|
|
1188
|
+
);
|
|
1189
|
+
this.#focusOptionAt(selectedIndex >= 0 ? selectedIndex : 0);
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const options = this.#getOptions({ enabledOnly: true });
|
|
1196
|
+
if (!options.length) return;
|
|
1197
|
+
|
|
1198
|
+
switch (e.key) {
|
|
1199
|
+
case "ArrowDown":
|
|
1200
|
+
e.preventDefault();
|
|
1201
|
+
this.#syncFocusedIndex();
|
|
1202
|
+
this.#focusOptionAt(this.#focusedIndex + 1);
|
|
1203
|
+
break;
|
|
1204
|
+
case "ArrowUp":
|
|
1205
|
+
e.preventDefault();
|
|
1206
|
+
this.#syncFocusedIndex();
|
|
1207
|
+
this.#focusOptionAt(this.#focusedIndex - 1);
|
|
1208
|
+
break;
|
|
1209
|
+
case "Home":
|
|
1210
|
+
e.preventDefault();
|
|
1211
|
+
this.#focusOptionAt(0);
|
|
1212
|
+
break;
|
|
1213
|
+
case "End":
|
|
1214
|
+
e.preventDefault();
|
|
1215
|
+
this.#focusOptionAt(options.length - 1);
|
|
1216
|
+
break;
|
|
1217
|
+
case "Escape":
|
|
1218
|
+
e.preventDefault();
|
|
1219
|
+
this.open = false;
|
|
1220
|
+
this.#button?.focus();
|
|
1221
|
+
break;
|
|
1222
|
+
case "Enter":
|
|
1223
|
+
case " ": {
|
|
1224
|
+
this.#syncFocusedIndex();
|
|
1225
|
+
const focused = options[this.#focusedIndex];
|
|
1226
|
+
if (!focused) return;
|
|
1227
|
+
e.preventDefault();
|
|
1228
|
+
this.#selectOption(focused);
|
|
1229
|
+
break;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
#handlePopupClose() {
|
|
1235
|
+
if (this.hasAttribute("open")) this.removeAttribute("open");
|
|
1236
|
+
this.#button?.setAttribute("aria-expanded", "false");
|
|
1237
|
+
this.#button?.focus();
|
|
1238
|
+
this.#focusedIndex = -1;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
#selectOption(option) {
|
|
1242
|
+
const value = this.#optionValue(option);
|
|
1243
|
+
this.setAttribute("value", value);
|
|
1244
|
+
this.#syncValue();
|
|
1245
|
+
this.#emitValueEvents(value);
|
|
1246
|
+
this.open = false;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
#getEnabledOptions() {
|
|
1250
|
+
return this.#getOptions({ enabledOnly: true });
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
#syncFocusedIndex() {
|
|
1254
|
+
const options = this.#getEnabledOptions();
|
|
1255
|
+
if (!options.length) {
|
|
1256
|
+
this.#focusedIndex = -1;
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const active = options.find((opt) => opt === document.activeElement);
|
|
1260
|
+
const index = active ? options.indexOf(active) : -1;
|
|
1261
|
+
this.#focusedIndex = index >= 0 ? index : this.#focusedIndex;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
#focusOptionAt(index) {
|
|
1265
|
+
const options = this.#getEnabledOptions();
|
|
1266
|
+
if (!options.length) return;
|
|
1267
|
+
const next = ((index % options.length) + options.length) % options.length;
|
|
1268
|
+
this.#focusedIndex = next;
|
|
1269
|
+
options[next]?.focus();
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
#syncPopupWidth() {
|
|
1273
|
+
if (!this.#popup || !this.#button) return;
|
|
1274
|
+
const hostWidth = Math.ceil(this.getBoundingClientRect().width);
|
|
1275
|
+
const triggerWidth = Math.ceil(this.#button.getBoundingClientRect().width);
|
|
1276
|
+
const anchorWidth = Math.max(hostWidth, triggerWidth, 96);
|
|
1277
|
+
|
|
1278
|
+
// Use !important — fig-select::part(listbox) width rules beat element.style.
|
|
1279
|
+
// Menus size to their options while remaining at least as wide as the trigger.
|
|
1280
|
+
this.#popup.style.setProperty("width", "max-content", "important");
|
|
1281
|
+
this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
|
|
1282
|
+
this.#popup.style.setProperty(
|
|
1283
|
+
"max-width",
|
|
1284
|
+
"min(20rem, calc(100vw - 1rem))",
|
|
1285
|
+
"important",
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
#openList() {
|
|
1290
|
+
if (!this.#popup || figEditorBooleanAttribute(this, "disabled")) return;
|
|
1291
|
+
if (this.#button) this.#popup.anchor = this.#button;
|
|
1292
|
+
this.#installPopupPositioning();
|
|
1293
|
+
this.#freezeMenuPosition = false;
|
|
1294
|
+
this.#frozenLabelRect = null;
|
|
1295
|
+
this.#frozenViewport = null;
|
|
1296
|
+
this.#syncValue();
|
|
1297
|
+
this.#syncPopupWidth();
|
|
1298
|
+
this.#popup.open = true;
|
|
1299
|
+
document.addEventListener("keydown", this.#boundKeydown, true);
|
|
1300
|
+
this.#button?.setAttribute("aria-expanded", "true");
|
|
1301
|
+
this.#focusedIndex = -1;
|
|
1302
|
+
requestAnimationFrame(() => {
|
|
1303
|
+
this.#syncPopupWidth();
|
|
1304
|
+
this.#positionPopupOverSelected();
|
|
1305
|
+
const panel = this.#getPanel();
|
|
1306
|
+
const options = this.#getEnabledOptions();
|
|
1307
|
+
const selectedIndex = options.findIndex(
|
|
1308
|
+
(opt) => this.#optionValue(opt) === this.value,
|
|
1309
|
+
);
|
|
1310
|
+
if (selectedIndex >= 0) {
|
|
1311
|
+
this.#focusOptionAt(selectedIndex);
|
|
1312
|
+
} else if (
|
|
1313
|
+
this.#button?.hasAttribute("data-focus-visible") ||
|
|
1314
|
+
this.#button?.matches?.(":focus-visible")
|
|
1315
|
+
) {
|
|
1316
|
+
this.#focusOptionAt(0);
|
|
1317
|
+
}
|
|
1318
|
+
panel?.syncOverflow?.();
|
|
1319
|
+
// Freeze after open align so later positionPopup passes don't undo scroll.
|
|
1320
|
+
// Window resize / trigger movement still realigns via geometry checks.
|
|
1321
|
+
this.#freezeMenuPosition = true;
|
|
1322
|
+
this.#rememberFrozenGeometry();
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
#closeList() {
|
|
1327
|
+
if (!this.#popup) return;
|
|
1328
|
+
this.#freezeMenuPosition = false;
|
|
1329
|
+
this.#frozenLabelRect = null;
|
|
1330
|
+
this.#frozenViewport = null;
|
|
1331
|
+
document.removeEventListener("keydown", this.#boundKeydown, true);
|
|
1332
|
+
this.#popup.open = false;
|
|
1333
|
+
this.#button?.setAttribute("aria-expanded", "false");
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
figEditorDefineElement("fig-select", FigSelect);
|
|
1337
|
+
|
|
1338
|
+
|
|
19
1339
|
// FigFillPicker
|
|
20
1340
|
const GRADIENT_INTERPOLATION_SPACES = [
|
|
21
1341
|
"srgb",
|