@rogieking/figui3 6.15.0 → 6.17.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 +21 -0
- package/components.css +45 -2
- package/dist/components.css +1 -1
- package/dist/fig-editor.css +1 -1
- package/dist/fig-lab.css +1 -1
- package/dist/fig-lab.js +88 -16
- package/dist/fig.css +1 -1
- package/dist/fig.js +22 -22
- package/fig-lab.css +391 -7
- package/fig-lab.js +1206 -2
- package/fig.js +36 -1
- package/package.json +1 -1
package/fig-lab.js
CHANGED
|
@@ -22,6 +22,7 @@ class PropskitSwitch extends HTMLElement {
|
|
|
22
22
|
#managedSwitchAttrs = new Set();
|
|
23
23
|
#boundHandleInput = null;
|
|
24
24
|
#boundHandleChange = null;
|
|
25
|
+
#boundHandleClick = this.#handleClick.bind(this);
|
|
25
26
|
|
|
26
27
|
static get observedAttributes() {
|
|
27
28
|
return ["label", "direction"];
|
|
@@ -32,6 +33,8 @@ class PropskitSwitch extends HTMLElement {
|
|
|
32
33
|
this.#syncField();
|
|
33
34
|
this.#syncSwitchAttributes();
|
|
34
35
|
this.#bindSwitchEvents();
|
|
36
|
+
this.removeEventListener("click", this.#boundHandleClick);
|
|
37
|
+
this.addEventListener("click", this.#boundHandleClick);
|
|
35
38
|
|
|
36
39
|
if (!this.#observer) {
|
|
37
40
|
this.#observer = new MutationObserver((mutations) => {
|
|
@@ -61,6 +64,7 @@ class PropskitSwitch extends HTMLElement {
|
|
|
61
64
|
disconnectedCallback() {
|
|
62
65
|
this.#observer?.disconnect();
|
|
63
66
|
this.#unbindSwitchEvents();
|
|
67
|
+
this.removeEventListener("click", this.#boundHandleClick);
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
@@ -193,6 +197,14 @@ class PropskitSwitch extends HTMLElement {
|
|
|
193
197
|
);
|
|
194
198
|
}
|
|
195
199
|
|
|
200
|
+
#handleClick(event) {
|
|
201
|
+
if (event.target instanceof Element && event.target.closest("fig-segmented-control")) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const value = this.#switch?.value === "on" ? "off" : "on";
|
|
205
|
+
this.#switch?.querySelector(`fig-segment[value="${value}"]`)?.click();
|
|
206
|
+
}
|
|
207
|
+
|
|
196
208
|
get checked() {
|
|
197
209
|
return this.#switch
|
|
198
210
|
? this.#switch.value === "on"
|
|
@@ -220,6 +232,216 @@ class PropskitSwitch extends HTMLElement {
|
|
|
220
232
|
}
|
|
221
233
|
customElements.define("propskit-switch", PropskitSwitch);
|
|
222
234
|
|
|
235
|
+
/* Field + Color wrapper */
|
|
236
|
+
class PropskitColor extends HTMLElement {
|
|
237
|
+
#field = null;
|
|
238
|
+
#label = null;
|
|
239
|
+
#input = null;
|
|
240
|
+
#hasCustomLabel = false;
|
|
241
|
+
#observer = null;
|
|
242
|
+
#managedInputAttrs = new Set();
|
|
243
|
+
#boundHandleInput = null;
|
|
244
|
+
#boundHandleChange = null;
|
|
245
|
+
#boundHandleClick = this.#handleClick.bind(this);
|
|
246
|
+
|
|
247
|
+
static get observedAttributes() {
|
|
248
|
+
return ["label", "direction", "aria-label"];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
connectedCallback() {
|
|
252
|
+
if (!this.#field) this.#initialize();
|
|
253
|
+
this.#syncField();
|
|
254
|
+
this.#syncInputAttributes();
|
|
255
|
+
this.#bindInputEvents();
|
|
256
|
+
this.removeEventListener("click", this.#boundHandleClick);
|
|
257
|
+
this.addEventListener("click", this.#boundHandleClick);
|
|
258
|
+
|
|
259
|
+
if (!this.#observer) {
|
|
260
|
+
this.#observer = new MutationObserver((mutations) => {
|
|
261
|
+
let syncField = false;
|
|
262
|
+
let syncInput = false;
|
|
263
|
+
|
|
264
|
+
for (const mutation of mutations) {
|
|
265
|
+
if (mutation.type !== "attributes") continue;
|
|
266
|
+
if (
|
|
267
|
+
mutation.attributeName === "label" ||
|
|
268
|
+
mutation.attributeName === "direction" ||
|
|
269
|
+
mutation.attributeName === "aria-label"
|
|
270
|
+
) {
|
|
271
|
+
syncField = true;
|
|
272
|
+
} else {
|
|
273
|
+
syncInput = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (syncField) this.#syncField();
|
|
278
|
+
if (syncInput) this.#syncInputAttributes();
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.#observer.observe(this, { attributes: true });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
disconnectedCallback() {
|
|
286
|
+
this.#observer?.disconnect();
|
|
287
|
+
this.#unbindInputEvents();
|
|
288
|
+
this.removeEventListener("click", this.#boundHandleClick);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
292
|
+
if (oldValue === newValue || !this.#field) return;
|
|
293
|
+
if (name === "label" || name === "direction" || name === "aria-label") {
|
|
294
|
+
this.#syncField();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#initialize() {
|
|
299
|
+
const initialChildren = Array.from(this.childNodes).filter(
|
|
300
|
+
(node) =>
|
|
301
|
+
node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
|
|
302
|
+
);
|
|
303
|
+
const customLabel = initialChildren.find(
|
|
304
|
+
(node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
|
|
305
|
+
);
|
|
306
|
+
const field = document.createElement("fig-field");
|
|
307
|
+
const label = customLabel || document.createElement("label");
|
|
308
|
+
const input = document.createElement("fig-input-color");
|
|
309
|
+
|
|
310
|
+
for (const node of initialChildren) {
|
|
311
|
+
if (node !== customLabel) input.appendChild(node);
|
|
312
|
+
}
|
|
313
|
+
field.append(label, input);
|
|
314
|
+
this.#field = field;
|
|
315
|
+
this.#label = label;
|
|
316
|
+
this.#input = input;
|
|
317
|
+
this.#hasCustomLabel = Boolean(customLabel);
|
|
318
|
+
this.replaceChildren(field);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#syncField() {
|
|
322
|
+
if (!this.#field || !this.#label || !this.#input) return;
|
|
323
|
+
const hasLabelAttr = this.hasAttribute("label");
|
|
324
|
+
const rawLabel = this.getAttribute("label");
|
|
325
|
+
const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
|
|
326
|
+
|
|
327
|
+
if (isBlankLabel) {
|
|
328
|
+
this.#label.remove();
|
|
329
|
+
} else {
|
|
330
|
+
if (!this.#hasCustomLabel) {
|
|
331
|
+
this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
|
|
332
|
+
}
|
|
333
|
+
if (this.#label.parentElement !== this.#field) {
|
|
334
|
+
this.#field.prepend(this.#label);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
this.#field.setAttribute(
|
|
339
|
+
"direction",
|
|
340
|
+
this.getAttribute("direction") || "horizontal",
|
|
341
|
+
);
|
|
342
|
+
this.#input.setAttribute(
|
|
343
|
+
"aria-label",
|
|
344
|
+
this.getAttribute("aria-label") ||
|
|
345
|
+
this.#label.textContent?.trim() ||
|
|
346
|
+
"Color",
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
#getForwardedInputAttrNames() {
|
|
351
|
+
const reserved = new Set([
|
|
352
|
+
"label",
|
|
353
|
+
"direction",
|
|
354
|
+
"oninput",
|
|
355
|
+
"onchange",
|
|
356
|
+
"class",
|
|
357
|
+
"style",
|
|
358
|
+
"id",
|
|
359
|
+
"size",
|
|
360
|
+
"aria-label",
|
|
361
|
+
"text",
|
|
362
|
+
]);
|
|
363
|
+
return this.getAttributeNames().filter(
|
|
364
|
+
(name) => !reserved.has(name) && !name.startsWith("data-"),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#syncInputAttributes() {
|
|
369
|
+
if (!this.#input) return;
|
|
370
|
+
const inputAttrs = this.#getForwardedInputAttrNames();
|
|
371
|
+
const nextManaged = new Set(inputAttrs);
|
|
372
|
+
|
|
373
|
+
for (const attrName of this.#managedInputAttrs) {
|
|
374
|
+
if (!nextManaged.has(attrName)) this.#input.removeAttribute(attrName);
|
|
375
|
+
}
|
|
376
|
+
for (const attrName of inputAttrs) {
|
|
377
|
+
this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
this.#input.setAttribute("text", "true");
|
|
381
|
+
this.#managedInputAttrs = nextManaged;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#bindInputEvents() {
|
|
385
|
+
if (!this.#input) return;
|
|
386
|
+
this.#boundHandleInput ??= this.#forwardInputEvent.bind(this, "input");
|
|
387
|
+
this.#boundHandleChange ??= this.#forwardInputEvent.bind(this, "change");
|
|
388
|
+
this.#input.addEventListener("input", this.#boundHandleInput);
|
|
389
|
+
this.#input.addEventListener("change", this.#boundHandleChange);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#unbindInputEvents() {
|
|
393
|
+
if (!this.#input) return;
|
|
394
|
+
if (this.#boundHandleInput) {
|
|
395
|
+
this.#input.removeEventListener("input", this.#boundHandleInput);
|
|
396
|
+
}
|
|
397
|
+
if (this.#boundHandleChange) {
|
|
398
|
+
this.#input.removeEventListener("change", this.#boundHandleChange);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#forwardInputEvent(type, event) {
|
|
403
|
+
event.stopImmediatePropagation();
|
|
404
|
+
const value = this.#input?.getAttribute("value") ?? "";
|
|
405
|
+
this.setAttribute("value", value);
|
|
406
|
+
const detail =
|
|
407
|
+
event instanceof CustomEvent && event.detail !== undefined
|
|
408
|
+
? event.detail
|
|
409
|
+
: value;
|
|
410
|
+
this.dispatchEvent(
|
|
411
|
+
new CustomEvent(type, {
|
|
412
|
+
detail,
|
|
413
|
+
bubbles: true,
|
|
414
|
+
cancelable: true,
|
|
415
|
+
composed: true,
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#handleClick(event) {
|
|
421
|
+
if (event.target instanceof Element && event.target.closest("fig-input-color")) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
this.focus();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
get value() {
|
|
428
|
+
return this.#input?.getAttribute("value") ?? this.getAttribute("value") ?? "";
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
set value(nextValue) {
|
|
432
|
+
if (nextValue === null || nextValue === undefined || nextValue === "") {
|
|
433
|
+
this.removeAttribute("value");
|
|
434
|
+
} else {
|
|
435
|
+
this.setAttribute("value", String(nextValue));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
focus(options) {
|
|
440
|
+
this.#input?.querySelector("input:not([tabindex='-1'])")?.focus(options);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
customElements.define("propskit-color", PropskitColor);
|
|
444
|
+
|
|
223
445
|
/* Field + Select wrapper */
|
|
224
446
|
class PropskitSelect extends HTMLElement {
|
|
225
447
|
#field = null;
|
|
@@ -230,6 +452,7 @@ class PropskitSelect extends HTMLElement {
|
|
|
230
452
|
#managedSelectAttrs = new Set();
|
|
231
453
|
#boundHandleInput = null;
|
|
232
454
|
#boundHandleChange = null;
|
|
455
|
+
#boundHandleClick = this.#handleClick.bind(this);
|
|
233
456
|
|
|
234
457
|
static get observedAttributes() {
|
|
235
458
|
return ["label", "direction", "aria-label"];
|
|
@@ -240,6 +463,8 @@ class PropskitSelect extends HTMLElement {
|
|
|
240
463
|
this.#syncField();
|
|
241
464
|
this.#syncSelectAttributes();
|
|
242
465
|
this.#bindSelectEvents();
|
|
466
|
+
this.removeEventListener("click", this.#boundHandleClick);
|
|
467
|
+
this.addEventListener("click", this.#boundHandleClick);
|
|
243
468
|
|
|
244
469
|
if (!this.#observer) {
|
|
245
470
|
this.#observer = new MutationObserver((mutations) => {
|
|
@@ -270,6 +495,7 @@ class PropskitSelect extends HTMLElement {
|
|
|
270
495
|
disconnectedCallback() {
|
|
271
496
|
this.#observer?.disconnect();
|
|
272
497
|
this.#unbindSelectEvents();
|
|
498
|
+
this.removeEventListener("click", this.#boundHandleClick);
|
|
273
499
|
}
|
|
274
500
|
|
|
275
501
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
@@ -289,11 +515,21 @@ class PropskitSelect extends HTMLElement {
|
|
|
289
515
|
);
|
|
290
516
|
const field = document.createElement("fig-field");
|
|
291
517
|
const label = customLabel || document.createElement("label");
|
|
292
|
-
const select = document.createElement("fig-
|
|
518
|
+
const select = document.createElement("fig-select");
|
|
519
|
+
const existingPanel = initialChildren.find(
|
|
520
|
+
(node) =>
|
|
521
|
+
node.nodeType === Node.ELEMENT_NODE &&
|
|
522
|
+
node.tagName === "FIG-SELECT-OPTIONS",
|
|
523
|
+
);
|
|
524
|
+
const panel =
|
|
525
|
+
existingPanel || document.createElement("fig-select-options");
|
|
526
|
+
panel.setAttribute("slot", "panel");
|
|
293
527
|
|
|
294
528
|
for (const node of initialChildren) {
|
|
295
|
-
if (node
|
|
529
|
+
if (node === customLabel || node === existingPanel) continue;
|
|
530
|
+
panel.appendChild(node);
|
|
296
531
|
}
|
|
532
|
+
select.append(panel);
|
|
297
533
|
field.append(label, select);
|
|
298
534
|
this.#field = field;
|
|
299
535
|
this.#label = label;
|
|
@@ -382,6 +618,7 @@ class PropskitSelect extends HTMLElement {
|
|
|
382
618
|
}
|
|
383
619
|
|
|
384
620
|
#forwardSelectEvent(type, event) {
|
|
621
|
+
if (event.target !== this.#select) return;
|
|
385
622
|
event.stopImmediatePropagation();
|
|
386
623
|
const value = this.#select?.value ?? "";
|
|
387
624
|
this.setAttribute("value", String(value));
|
|
@@ -399,6 +636,16 @@ class PropskitSelect extends HTMLElement {
|
|
|
399
636
|
);
|
|
400
637
|
}
|
|
401
638
|
|
|
639
|
+
#handleClick(event) {
|
|
640
|
+
if (event.target instanceof Element && event.target.closest("fig-select")) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
this.#select?.focus();
|
|
644
|
+
if (this.#select && !figLabBooleanAttribute(this.#select, "disabled")) {
|
|
645
|
+
this.#select.open = true;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
402
649
|
get value() {
|
|
403
650
|
return this.#select?.value ?? this.getAttribute("value") ?? "";
|
|
404
651
|
}
|
|
@@ -427,6 +674,7 @@ class PropskitText extends HTMLElement {
|
|
|
427
674
|
#managedInputAttrs = new Set();
|
|
428
675
|
#boundHandleInput = null;
|
|
429
676
|
#boundHandleChange = null;
|
|
677
|
+
#boundHandleClick = this.#handleClick.bind(this);
|
|
430
678
|
|
|
431
679
|
static get observedAttributes() {
|
|
432
680
|
return ["label", "direction", "aria-label"];
|
|
@@ -437,6 +685,8 @@ class PropskitText extends HTMLElement {
|
|
|
437
685
|
this.#syncField();
|
|
438
686
|
this.#syncInputAttributes();
|
|
439
687
|
this.#bindInputEvents();
|
|
688
|
+
this.removeEventListener("click", this.#boundHandleClick);
|
|
689
|
+
this.addEventListener("click", this.#boundHandleClick);
|
|
440
690
|
|
|
441
691
|
if (!this.#observer) {
|
|
442
692
|
this.#observer = new MutationObserver((mutations) => {
|
|
@@ -467,6 +717,7 @@ class PropskitText extends HTMLElement {
|
|
|
467
717
|
disconnectedCallback() {
|
|
468
718
|
this.#observer?.disconnect();
|
|
469
719
|
this.#unbindInputEvents();
|
|
720
|
+
this.removeEventListener("click", this.#boundHandleClick);
|
|
470
721
|
}
|
|
471
722
|
|
|
472
723
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
@@ -598,6 +849,13 @@ class PropskitText extends HTMLElement {
|
|
|
598
849
|
);
|
|
599
850
|
}
|
|
600
851
|
|
|
852
|
+
#handleClick(event) {
|
|
853
|
+
if (event.target instanceof Element && event.target.closest("fig-input-text")) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
this.focus();
|
|
857
|
+
}
|
|
858
|
+
|
|
601
859
|
get value() {
|
|
602
860
|
return this.#input?.value ?? this.getAttribute("value") ?? "";
|
|
603
861
|
}
|
|
@@ -626,6 +884,7 @@ class PropskitNumber extends HTMLElement {
|
|
|
626
884
|
#managedInputAttrs = new Set();
|
|
627
885
|
#boundHandleInput = null;
|
|
628
886
|
#boundHandleChange = null;
|
|
887
|
+
#boundHandleClick = this.#handleClick.bind(this);
|
|
629
888
|
|
|
630
889
|
static get observedAttributes() {
|
|
631
890
|
return ["label", "direction"];
|
|
@@ -636,6 +895,8 @@ class PropskitNumber extends HTMLElement {
|
|
|
636
895
|
this.#syncField();
|
|
637
896
|
this.#syncInputAttributes();
|
|
638
897
|
this.#bindInputEvents();
|
|
898
|
+
this.removeEventListener("click", this.#boundHandleClick);
|
|
899
|
+
this.addEventListener("click", this.#boundHandleClick);
|
|
639
900
|
|
|
640
901
|
if (!this.#observer) {
|
|
641
902
|
this.#observer = new MutationObserver((mutations) => {
|
|
@@ -665,6 +926,7 @@ class PropskitNumber extends HTMLElement {
|
|
|
665
926
|
disconnectedCallback() {
|
|
666
927
|
this.#observer?.disconnect();
|
|
667
928
|
this.#unbindInputEvents();
|
|
929
|
+
this.removeEventListener("click", this.#boundHandleClick);
|
|
668
930
|
}
|
|
669
931
|
|
|
670
932
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
@@ -786,6 +1048,13 @@ class PropskitNumber extends HTMLElement {
|
|
|
786
1048
|
);
|
|
787
1049
|
}
|
|
788
1050
|
|
|
1051
|
+
#handleClick(event) {
|
|
1052
|
+
if (event.target instanceof Element && event.target.closest("fig-input-number")) {
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
this.focus();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
789
1058
|
get value() {
|
|
790
1059
|
return this.#input?.value ?? this.getAttribute("value") ?? "";
|
|
791
1060
|
}
|
|
@@ -4051,6 +4320,7 @@ class FigReorder extends HTMLElement {
|
|
|
4051
4320
|
"fig-slider",
|
|
4052
4321
|
"fig-input-number",
|
|
4053
4322
|
"fig-input-text",
|
|
4323
|
+
"propskit-color",
|
|
4054
4324
|
"propskit-number",
|
|
4055
4325
|
"propskit-select",
|
|
4056
4326
|
"propskit-slider",
|
|
@@ -4465,3 +4735,937 @@ class FigReorder extends HTMLElement {
|
|
|
4465
4735
|
}
|
|
4466
4736
|
|
|
4467
4737
|
customElements.define("fig-reorder", FigReorder);
|
|
4738
|
+
|
|
4739
|
+
/* Select — dropdown-styled trigger + fig-popup listbox */
|
|
4740
|
+
let figLabSelectId = 0;
|
|
4741
|
+
function figLabUniqueId(prefix = "fig-select") {
|
|
4742
|
+
figLabSelectId += 1;
|
|
4743
|
+
return `${prefix}-${figLabSelectId}`;
|
|
4744
|
+
}
|
|
4745
|
+
|
|
4746
|
+
class FigSelectOption extends HTMLElement {
|
|
4747
|
+
static get observedAttributes() {
|
|
4748
|
+
return ["value", "disabled", "selected"];
|
|
4749
|
+
}
|
|
4750
|
+
|
|
4751
|
+
get value() {
|
|
4752
|
+
const attr = this.getAttribute("value");
|
|
4753
|
+
if (attr !== null) return attr;
|
|
4754
|
+
return (this.textContent || "").trim();
|
|
4755
|
+
}
|
|
4756
|
+
|
|
4757
|
+
set value(val) {
|
|
4758
|
+
if (val === null || val === undefined) {
|
|
4759
|
+
this.removeAttribute("value");
|
|
4760
|
+
} else {
|
|
4761
|
+
this.setAttribute("value", String(val));
|
|
4762
|
+
}
|
|
4763
|
+
}
|
|
4764
|
+
|
|
4765
|
+
get disabled() {
|
|
4766
|
+
return figLabBooleanAttribute(this, "disabled");
|
|
4767
|
+
}
|
|
4768
|
+
|
|
4769
|
+
set disabled(val) {
|
|
4770
|
+
if (val) this.setAttribute("disabled", "");
|
|
4771
|
+
else this.removeAttribute("disabled");
|
|
4772
|
+
}
|
|
4773
|
+
|
|
4774
|
+
get selected() {
|
|
4775
|
+
return figLabBooleanAttribute(this, "selected");
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4778
|
+
set selected(val) {
|
|
4779
|
+
if (val) this.setAttribute("selected", "");
|
|
4780
|
+
else this.removeAttribute("selected");
|
|
4781
|
+
}
|
|
4782
|
+
|
|
4783
|
+
connectedCallback() {
|
|
4784
|
+
if (!this.hasAttribute("role")) this.setAttribute("role", "option");
|
|
4785
|
+
if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
|
|
4786
|
+
this.#syncDisabled();
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4789
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
4790
|
+
if (oldValue === newValue) return;
|
|
4791
|
+
if (name === "disabled") this.#syncDisabled();
|
|
4792
|
+
}
|
|
4793
|
+
|
|
4794
|
+
#syncDisabled() {
|
|
4795
|
+
const disabled = this.disabled;
|
|
4796
|
+
if (disabled) {
|
|
4797
|
+
this.setAttribute("aria-disabled", "true");
|
|
4798
|
+
this.setAttribute("tabindex", "-1");
|
|
4799
|
+
} else {
|
|
4800
|
+
this.removeAttribute("aria-disabled");
|
|
4801
|
+
if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
|
|
4802
|
+
}
|
|
4803
|
+
}
|
|
4804
|
+
}
|
|
4805
|
+
customElements.define("fig-select-option", FigSelectOption);
|
|
4806
|
+
|
|
4807
|
+
function figLabSyncOverflowState(host, scrollEl, threshold = 2) {
|
|
4808
|
+
if (!host || !scrollEl) return false;
|
|
4809
|
+
const scrollable = scrollEl.scrollHeight - scrollEl.clientHeight > threshold;
|
|
4810
|
+
const atStart = !scrollable || scrollEl.scrollTop <= threshold;
|
|
4811
|
+
const atEnd =
|
|
4812
|
+
!scrollable ||
|
|
4813
|
+
scrollEl.scrollTop + scrollEl.clientHeight >=
|
|
4814
|
+
scrollEl.scrollHeight - threshold;
|
|
4815
|
+
host.classList.toggle("overflow-start", !atStart);
|
|
4816
|
+
host.classList.toggle("overflow-end", !atEnd);
|
|
4817
|
+
return scrollable;
|
|
4818
|
+
}
|
|
4819
|
+
|
|
4820
|
+
function figLabScrollOverflowPage(scrollEl, direction = 1) {
|
|
4821
|
+
if (!scrollEl) return;
|
|
4822
|
+
scrollEl.scrollBy({
|
|
4823
|
+
top: scrollEl.clientHeight * 0.8 * direction,
|
|
4824
|
+
behavior: "smooth",
|
|
4825
|
+
});
|
|
4826
|
+
}
|
|
4827
|
+
|
|
4828
|
+
function figLabCreateOverflowButtons({ onStart, onEnd } = {}) {
|
|
4829
|
+
const makeButton = (direction, onClick) => {
|
|
4830
|
+
const button = document.createElement("button");
|
|
4831
|
+
button.type = "button";
|
|
4832
|
+
button.className = `fig-overflow fig-overflow-${direction}`;
|
|
4833
|
+
button.dataset.figOverflow = direction;
|
|
4834
|
+
button.setAttribute("data-fig-select-nav", direction);
|
|
4835
|
+
button.setAttribute("tabindex", "-1");
|
|
4836
|
+
button.setAttribute(
|
|
4837
|
+
"aria-label",
|
|
4838
|
+
direction === "start" ? "Scroll up" : "Scroll down",
|
|
4839
|
+
);
|
|
4840
|
+
const icon = document.createElement("fig-icon");
|
|
4841
|
+
icon.setAttribute("name", "chevron");
|
|
4842
|
+
icon.setAttribute("size", "small");
|
|
4843
|
+
icon.className = "fig-overflow-chevron";
|
|
4844
|
+
button.appendChild(icon);
|
|
4845
|
+
button.addEventListener("click", (event) => {
|
|
4846
|
+
event.preventDefault();
|
|
4847
|
+
event.stopPropagation();
|
|
4848
|
+
onClick?.(event);
|
|
4849
|
+
});
|
|
4850
|
+
return button;
|
|
4851
|
+
};
|
|
4852
|
+
return {
|
|
4853
|
+
start: makeButton("start", onStart),
|
|
4854
|
+
end: makeButton("end", onEnd),
|
|
4855
|
+
};
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4858
|
+
/** Light-DOM panel wrapper projected into fig-select's popup; owns overflow buttons. */
|
|
4859
|
+
class FigSelectOptions extends HTMLElement {
|
|
4860
|
+
#navStart = null;
|
|
4861
|
+
#navEnd = null;
|
|
4862
|
+
#resizeObserver = null;
|
|
4863
|
+
#boundSyncOverflow = this.syncOverflow.bind(this);
|
|
4864
|
+
|
|
4865
|
+
connectedCallback() {
|
|
4866
|
+
if (!this.hasAttribute("slot")) this.setAttribute("slot", "panel");
|
|
4867
|
+
this.#unwrapLegacyChooser();
|
|
4868
|
+
this.#ensureNavButtons();
|
|
4869
|
+
this.addEventListener("scroll", this.#boundSyncOverflow, { passive: true });
|
|
4870
|
+
this.#resizeObserver?.disconnect();
|
|
4871
|
+
this.#resizeObserver = new ResizeObserver(() => this.syncOverflow());
|
|
4872
|
+
this.#resizeObserver.observe(this);
|
|
4873
|
+
requestAnimationFrame(() => this.syncOverflow());
|
|
4874
|
+
}
|
|
4875
|
+
|
|
4876
|
+
disconnectedCallback() {
|
|
4877
|
+
this.removeEventListener("scroll", this.#boundSyncOverflow);
|
|
4878
|
+
this.#resizeObserver?.disconnect();
|
|
4879
|
+
this.#resizeObserver = null;
|
|
4880
|
+
this.#removeNavButtons();
|
|
4881
|
+
}
|
|
4882
|
+
|
|
4883
|
+
syncOverflow() {
|
|
4884
|
+
return figLabSyncOverflowState(this, this);
|
|
4885
|
+
}
|
|
4886
|
+
|
|
4887
|
+
scrollToOption(option, behavior = "auto") {
|
|
4888
|
+
if (!option || !this.contains(option)) return;
|
|
4889
|
+
requestAnimationFrame(() => {
|
|
4890
|
+
if (!option.isConnected) return;
|
|
4891
|
+
if (this.scrollHeight <= this.clientHeight + 1) {
|
|
4892
|
+
this.syncOverflow();
|
|
4893
|
+
return;
|
|
4894
|
+
}
|
|
4895
|
+
const optionRect = option.getBoundingClientRect();
|
|
4896
|
+
const hostRect = this.getBoundingClientRect();
|
|
4897
|
+
const optionTop = optionRect.top - hostRect.top + this.scrollTop;
|
|
4898
|
+
const maxScroll = this.scrollHeight - this.clientHeight;
|
|
4899
|
+
const top = Math.max(
|
|
4900
|
+
0,
|
|
4901
|
+
Math.min(
|
|
4902
|
+
optionTop + optionRect.height / 2 - this.clientHeight / 2,
|
|
4903
|
+
maxScroll,
|
|
4904
|
+
),
|
|
4905
|
+
);
|
|
4906
|
+
this.scrollTo({ top, behavior });
|
|
4907
|
+
this.syncOverflow();
|
|
4908
|
+
});
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4911
|
+
#unwrapLegacyChooser() {
|
|
4912
|
+
const chooser = this.querySelector(":scope > fig-chooser");
|
|
4913
|
+
if (!chooser) return;
|
|
4914
|
+
while (chooser.firstChild) {
|
|
4915
|
+
this.insertBefore(chooser.firstChild, chooser);
|
|
4916
|
+
}
|
|
4917
|
+
chooser.remove();
|
|
4918
|
+
}
|
|
4919
|
+
|
|
4920
|
+
#ensureNavButtons() {
|
|
4921
|
+
if (
|
|
4922
|
+
this.#navStart &&
|
|
4923
|
+
this.#navEnd &&
|
|
4924
|
+
this.contains(this.#navStart) &&
|
|
4925
|
+
this.contains(this.#navEnd)
|
|
4926
|
+
) {
|
|
4927
|
+
return;
|
|
4928
|
+
}
|
|
4929
|
+
this.#removeNavButtons();
|
|
4930
|
+
const buttons = figLabCreateOverflowButtons({
|
|
4931
|
+
onStart: () => figLabScrollOverflowPage(this, -1),
|
|
4932
|
+
onEnd: () => figLabScrollOverflowPage(this, 1),
|
|
4933
|
+
});
|
|
4934
|
+
this.#navStart = buttons.start;
|
|
4935
|
+
this.#navEnd = buttons.end;
|
|
4936
|
+
this.prepend(this.#navStart);
|
|
4937
|
+
this.append(this.#navEnd);
|
|
4938
|
+
}
|
|
4939
|
+
|
|
4940
|
+
#removeNavButtons() {
|
|
4941
|
+
this.#navStart?.remove();
|
|
4942
|
+
this.#navEnd?.remove();
|
|
4943
|
+
this.#navStart = null;
|
|
4944
|
+
this.#navEnd = null;
|
|
4945
|
+
this.classList.remove("overflow-start", "overflow-end");
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4948
|
+
customElements.define("fig-select-options", FigSelectOptions);
|
|
4949
|
+
|
|
4950
|
+
class FigSelect extends HTMLElement {
|
|
4951
|
+
#button = null;
|
|
4952
|
+
#popup = null;
|
|
4953
|
+
#labelEl = null;
|
|
4954
|
+
#panelSlot = null;
|
|
4955
|
+
#observer = null;
|
|
4956
|
+
#initialized = false;
|
|
4957
|
+
#focusedIndex = -1;
|
|
4958
|
+
#syncingValue = false;
|
|
4959
|
+
#popupPositionPatched = false;
|
|
4960
|
+
#originalPositionPopup = null;
|
|
4961
|
+
/** After open align, stop repositioning so overflow scroll isn't yanked back. */
|
|
4962
|
+
#freezeMenuPosition = false;
|
|
4963
|
+
#boundTriggerClick = this.#handleTriggerClick.bind(this);
|
|
4964
|
+
#boundOptionClick = this.#handleOptionClick.bind(this);
|
|
4965
|
+
#boundKeydown = this.#handleKeydown.bind(this);
|
|
4966
|
+
#boundPopupClose = this.#handlePopupClose.bind(this);
|
|
4967
|
+
#boundSlotChange = this.#handleSlotChange.bind(this);
|
|
4968
|
+
|
|
4969
|
+
static get observedAttributes() {
|
|
4970
|
+
return ["value", "disabled", "label", "position", "offset", "closedby", "open"];
|
|
4971
|
+
}
|
|
4972
|
+
|
|
4973
|
+
get value() {
|
|
4974
|
+
return this.getAttribute("value") ?? "";
|
|
4975
|
+
}
|
|
4976
|
+
|
|
4977
|
+
set value(val) {
|
|
4978
|
+
if (val === null || val === undefined) this.removeAttribute("value");
|
|
4979
|
+
else this.setAttribute("value", String(val));
|
|
4980
|
+
}
|
|
4981
|
+
|
|
4982
|
+
get open() {
|
|
4983
|
+
return figLabBooleanAttribute(this, "open");
|
|
4984
|
+
}
|
|
4985
|
+
|
|
4986
|
+
set open(val) {
|
|
4987
|
+
if (val) this.setAttribute("open", "");
|
|
4988
|
+
else this.removeAttribute("open");
|
|
4989
|
+
}
|
|
4990
|
+
|
|
4991
|
+
connectedCallback() {
|
|
4992
|
+
if (!this.#initialized) this.#initialize();
|
|
4993
|
+
this.#ensurePanelSlotAttrs();
|
|
4994
|
+
this.#syncDisabled();
|
|
4995
|
+
this.#syncPopupAttrs();
|
|
4996
|
+
this.#syncValue();
|
|
4997
|
+
this.#setupObserver();
|
|
4998
|
+
if (this.open) this.#openList();
|
|
4999
|
+
}
|
|
5000
|
+
|
|
5001
|
+
disconnectedCallback() {
|
|
5002
|
+
this.#teardownListeners();
|
|
5003
|
+
document.removeEventListener("keydown", this.#boundKeydown, true);
|
|
5004
|
+
this.#observer?.disconnect();
|
|
5005
|
+
this.#observer = null;
|
|
5006
|
+
}
|
|
5007
|
+
|
|
5008
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
5009
|
+
if (oldValue === newValue || !this.#initialized) return;
|
|
5010
|
+
if (name === "value" || name === "label") {
|
|
5011
|
+
this.#syncValue();
|
|
5012
|
+
return;
|
|
5013
|
+
}
|
|
5014
|
+
if (name === "disabled") {
|
|
5015
|
+
this.#syncDisabled();
|
|
5016
|
+
return;
|
|
5017
|
+
}
|
|
5018
|
+
if (name === "open") {
|
|
5019
|
+
if (newValue === null || newValue === "false") this.#closeList();
|
|
5020
|
+
else this.#openList();
|
|
5021
|
+
return;
|
|
5022
|
+
}
|
|
5023
|
+
if (name === "position" || name === "offset" || name === "closedby") {
|
|
5024
|
+
this.#syncPopupAttrs();
|
|
5025
|
+
}
|
|
5026
|
+
}
|
|
5027
|
+
|
|
5028
|
+
focus(options) {
|
|
5029
|
+
this.#button?.focus(options);
|
|
5030
|
+
}
|
|
5031
|
+
|
|
5032
|
+
blur() {
|
|
5033
|
+
this.#button?.blur();
|
|
5034
|
+
}
|
|
5035
|
+
|
|
5036
|
+
#isMenuChild(node) {
|
|
5037
|
+
return (
|
|
5038
|
+
node?.nodeType === 1 &&
|
|
5039
|
+
(node.tagName === "FIG-SELECT-OPTION" ||
|
|
5040
|
+
node.tagName === "FIG-MENU-SEPARATOR" ||
|
|
5041
|
+
node.tagName === "FIG-SELECT-OPTIONS")
|
|
5042
|
+
);
|
|
5043
|
+
}
|
|
5044
|
+
|
|
5045
|
+
#ensurePanelSlotAttrs() {
|
|
5046
|
+
for (const panel of this.querySelectorAll(":scope > fig-select-options")) {
|
|
5047
|
+
if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
|
|
5048
|
+
}
|
|
5049
|
+
}
|
|
5050
|
+
|
|
5051
|
+
#getPanel() {
|
|
5052
|
+
const assigned = this.#panelSlot?.assignedElements({ flatten: true }) ?? [];
|
|
5053
|
+
const fromSlot = assigned.find(
|
|
5054
|
+
(el) => el.tagName === "FIG-SELECT-OPTIONS",
|
|
5055
|
+
);
|
|
5056
|
+
if (fromSlot) return fromSlot;
|
|
5057
|
+
return this.querySelector(":scope > fig-select-options");
|
|
5058
|
+
}
|
|
5059
|
+
|
|
5060
|
+
#initialize() {
|
|
5061
|
+
this.#initialized = true;
|
|
5062
|
+
const shadow = this.attachShadow({ mode: "open" });
|
|
5063
|
+
shadow.innerHTML = `
|
|
5064
|
+
<style>
|
|
5065
|
+
:host {
|
|
5066
|
+
display: inline-flex;
|
|
5067
|
+
position: relative;
|
|
5068
|
+
align-items: center;
|
|
5069
|
+
min-width: 0;
|
|
5070
|
+
}
|
|
5071
|
+
:host([full]:not([full="false"])) {
|
|
5072
|
+
display: flex;
|
|
5073
|
+
width: 100%;
|
|
5074
|
+
}
|
|
5075
|
+
.fig-select-trigger {
|
|
5076
|
+
display: flex;
|
|
5077
|
+
align-items: center;
|
|
5078
|
+
justify-content: flex-start;
|
|
5079
|
+
flex: 1;
|
|
5080
|
+
min-width: 0;
|
|
5081
|
+
width: var(--fig-select-trigger-width, 100%);
|
|
5082
|
+
height: 100%;
|
|
5083
|
+
margin: 0;
|
|
5084
|
+
padding: 0 var(--spacer-4, 1rem) 0 var(--spacer-2, 0.5rem);
|
|
5085
|
+
border: 0;
|
|
5086
|
+
border-radius: inherit;
|
|
5087
|
+
background: transparent;
|
|
5088
|
+
box-shadow: none;
|
|
5089
|
+
color: inherit;
|
|
5090
|
+
font: inherit;
|
|
5091
|
+
font-weight: inherit;
|
|
5092
|
+
text-align: left;
|
|
5093
|
+
white-space: nowrap;
|
|
5094
|
+
overflow: hidden;
|
|
5095
|
+
text-overflow: ellipsis;
|
|
5096
|
+
cursor: default;
|
|
5097
|
+
}
|
|
5098
|
+
.fig-select-trigger:hover,
|
|
5099
|
+
.fig-select-trigger:active,
|
|
5100
|
+
.fig-select-trigger:active:hover {
|
|
5101
|
+
background: transparent;
|
|
5102
|
+
box-shadow: none;
|
|
5103
|
+
color: inherit;
|
|
5104
|
+
}
|
|
5105
|
+
.fig-select-trigger:focus-visible,
|
|
5106
|
+
.fig-select-trigger[data-focus-visible] {
|
|
5107
|
+
outline: var(--figma-focus-outline);
|
|
5108
|
+
outline-offset: var(--figma-focus-outline-offset);
|
|
5109
|
+
}
|
|
5110
|
+
.fig-select-label {
|
|
5111
|
+
display: block;
|
|
5112
|
+
width: 100%;
|
|
5113
|
+
min-width: 0;
|
|
5114
|
+
overflow: hidden;
|
|
5115
|
+
text-overflow: ellipsis;
|
|
5116
|
+
white-space: nowrap;
|
|
5117
|
+
text-align: left;
|
|
5118
|
+
}
|
|
5119
|
+
/* Listbox chrome from document fig-select::part(listbox).
|
|
5120
|
+
Overflow UI lives on slotted fig-select-options.
|
|
5121
|
+
Never set display except when open — closed <dialog> must stay display:none. */
|
|
5122
|
+
dialog[is="fig-popup"] {
|
|
5123
|
+
flex-direction: column;
|
|
5124
|
+
overflow: hidden;
|
|
5125
|
+
}
|
|
5126
|
+
dialog[is="fig-popup"][open] {
|
|
5127
|
+
display: flex;
|
|
5128
|
+
}
|
|
5129
|
+
::slotted(fig-select-options) {
|
|
5130
|
+
flex: 1 1 auto;
|
|
5131
|
+
min-height: 0;
|
|
5132
|
+
max-height: inherit;
|
|
5133
|
+
}
|
|
5134
|
+
</style>
|
|
5135
|
+
`;
|
|
5136
|
+
|
|
5137
|
+
const button = document.createElement("fig-button");
|
|
5138
|
+
button.className = "fig-select-trigger";
|
|
5139
|
+
button.setAttribute("part", "trigger");
|
|
5140
|
+
button.setAttribute("variant", "ghost");
|
|
5141
|
+
button.setAttribute("aria-haspopup", "listbox");
|
|
5142
|
+
button.setAttribute("aria-expanded", "false");
|
|
5143
|
+
|
|
5144
|
+
const labelEl = document.createElement("span");
|
|
5145
|
+
labelEl.className = "fig-select-label";
|
|
5146
|
+
labelEl.setAttribute("part", "label");
|
|
5147
|
+
button.appendChild(labelEl);
|
|
5148
|
+
|
|
5149
|
+
const popup = document.createElement("dialog", { is: "fig-popup" });
|
|
5150
|
+
popup.setAttribute("is", "fig-popup");
|
|
5151
|
+
popup.setAttribute("part", "listbox");
|
|
5152
|
+
popup.setAttribute("theme", "menu");
|
|
5153
|
+
popup.setAttribute("role", "listbox");
|
|
5154
|
+
popup.id = figLabUniqueId("fig-select-list");
|
|
5155
|
+
button.setAttribute("aria-controls", popup.id);
|
|
5156
|
+
|
|
5157
|
+
const panelSlot = document.createElement("slot");
|
|
5158
|
+
panelSlot.setAttribute("name", "panel");
|
|
5159
|
+
popup.appendChild(panelSlot);
|
|
5160
|
+
|
|
5161
|
+
shadow.append(button, popup);
|
|
5162
|
+
|
|
5163
|
+
this.#button = button;
|
|
5164
|
+
this.#labelEl = labelEl;
|
|
5165
|
+
this.#popup = popup;
|
|
5166
|
+
this.#panelSlot = panelSlot;
|
|
5167
|
+
popup.anchor = button;
|
|
5168
|
+
|
|
5169
|
+
this.#ensurePanelSlotAttrs();
|
|
5170
|
+
this.#setupListeners();
|
|
5171
|
+
this.#installPopupPositioning();
|
|
5172
|
+
|
|
5173
|
+
if (!this.hasAttribute("value")) {
|
|
5174
|
+
const selected = this.#getOptions().find((opt) =>
|
|
5175
|
+
figLabBooleanAttribute(opt, "selected"),
|
|
5176
|
+
);
|
|
5177
|
+
if (selected) this.setAttribute("value", selected.value);
|
|
5178
|
+
}
|
|
5179
|
+
}
|
|
5180
|
+
|
|
5181
|
+
#installPopupPositioning() {
|
|
5182
|
+
if (!this.#popup || this.#popupPositionPatched) return;
|
|
5183
|
+
if (typeof this.#popup.positionPopup !== "function") return;
|
|
5184
|
+
this.#originalPositionPopup = this.#popup.positionPopup.bind(this.#popup);
|
|
5185
|
+
this.#popup.positionPopup = () => {
|
|
5186
|
+
if (!this.open) {
|
|
5187
|
+
this.#originalPositionPopup?.();
|
|
5188
|
+
return;
|
|
5189
|
+
}
|
|
5190
|
+
this.#positionPopupOverSelected();
|
|
5191
|
+
};
|
|
5192
|
+
this.#popupPositionPatched = true;
|
|
5193
|
+
}
|
|
5194
|
+
|
|
5195
|
+
#getOptionTextRect(option) {
|
|
5196
|
+
if (!option) return null;
|
|
5197
|
+
const range = document.createRange();
|
|
5198
|
+
range.selectNodeContents(option);
|
|
5199
|
+
const rects = [...range.getClientRects()].filter(
|
|
5200
|
+
(rect) => rect.width > 0 && rect.height > 0,
|
|
5201
|
+
);
|
|
5202
|
+
if (rects.length) return rects[0];
|
|
5203
|
+
return option.getBoundingClientRect();
|
|
5204
|
+
}
|
|
5205
|
+
|
|
5206
|
+
#getViewportMargins() {
|
|
5207
|
+
if (typeof this.#popup?.parseViewportMargins === "function") {
|
|
5208
|
+
return this.#popup.parseViewportMargins();
|
|
5209
|
+
}
|
|
5210
|
+
return { top: 8, right: 8, bottom: 8, left: 8 };
|
|
5211
|
+
}
|
|
5212
|
+
|
|
5213
|
+
#positionPopupOverSelected() {
|
|
5214
|
+
// Content ResizeObserver / anchor tracking re-enter here after overflow
|
|
5215
|
+
// scroll; keep the open-time alignment instead of fighting the scroller.
|
|
5216
|
+
if (this.#freezeMenuPosition) return;
|
|
5217
|
+
|
|
5218
|
+
const popup = this.#popup;
|
|
5219
|
+
const label = this.#labelEl;
|
|
5220
|
+
if (!popup || !label) {
|
|
5221
|
+
this.#originalPositionPopup?.();
|
|
5222
|
+
return;
|
|
5223
|
+
}
|
|
5224
|
+
|
|
5225
|
+
const options = this.#getOptions();
|
|
5226
|
+
const selected =
|
|
5227
|
+
options.find((opt) => this.#optionValue(opt) === this.value) ||
|
|
5228
|
+
options[0];
|
|
5229
|
+
if (!selected) {
|
|
5230
|
+
this.#originalPositionPopup?.();
|
|
5231
|
+
return;
|
|
5232
|
+
}
|
|
5233
|
+
|
|
5234
|
+
// Lay out with the default positioning first so option metrics are valid.
|
|
5235
|
+
this.#originalPositionPopup?.();
|
|
5236
|
+
|
|
5237
|
+
const popupRect = popup.getBoundingClientRect();
|
|
5238
|
+
const labelRect = label.getBoundingClientRect();
|
|
5239
|
+
const optionTextRect = this.#getOptionTextRect(selected);
|
|
5240
|
+
if (
|
|
5241
|
+
!popupRect.width ||
|
|
5242
|
+
!popupRect.height ||
|
|
5243
|
+
!labelRect.width ||
|
|
5244
|
+
!optionTextRect
|
|
5245
|
+
) {
|
|
5246
|
+
return;
|
|
5247
|
+
}
|
|
5248
|
+
|
|
5249
|
+
const selectedOffsetX = optionTextRect.left - popupRect.left;
|
|
5250
|
+
const selectedOffsetY = optionTextRect.top - popupRect.top;
|
|
5251
|
+
let left = labelRect.left - selectedOffsetX;
|
|
5252
|
+
let top = labelRect.top - selectedOffsetY;
|
|
5253
|
+
|
|
5254
|
+
const margins = this.#getViewportMargins();
|
|
5255
|
+
const minLeft = margins.left;
|
|
5256
|
+
const minTop = margins.top;
|
|
5257
|
+
const maxLeft = window.innerWidth - popupRect.width - margins.right;
|
|
5258
|
+
const maxTop = window.innerHeight - popupRect.height - margins.bottom;
|
|
5259
|
+
left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft));
|
|
5260
|
+
top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop));
|
|
5261
|
+
|
|
5262
|
+
popup.style.left = `${Math.round(left)}px`;
|
|
5263
|
+
popup.style.top = `${Math.round(top)}px`;
|
|
5264
|
+
|
|
5265
|
+
// Nudge the panel scroller so the selected label stays over the trigger.
|
|
5266
|
+
const panel = this.#getPanel();
|
|
5267
|
+
const alignedTextRect = this.#getOptionTextRect(selected);
|
|
5268
|
+
if (
|
|
5269
|
+
alignedTextRect &&
|
|
5270
|
+
panel &&
|
|
5271
|
+
panel.scrollHeight > panel.clientHeight + 1
|
|
5272
|
+
) {
|
|
5273
|
+
const deltaY = alignedTextRect.top - labelRect.top;
|
|
5274
|
+
if (Math.abs(deltaY) > 0.5) {
|
|
5275
|
+
panel.scrollTop += deltaY;
|
|
5276
|
+
}
|
|
5277
|
+
panel.syncOverflow?.();
|
|
5278
|
+
}
|
|
5279
|
+
}
|
|
5280
|
+
|
|
5281
|
+
#setupListeners() {
|
|
5282
|
+
this.#button?.addEventListener("click", this.#boundTriggerClick);
|
|
5283
|
+
this.#button?.addEventListener("keydown", this.#boundKeydown);
|
|
5284
|
+
// Host click: slotted options stay in light DOM (not dialog.contains).
|
|
5285
|
+
this.addEventListener("click", this.#boundOptionClick);
|
|
5286
|
+
this.#popup?.addEventListener("keydown", this.#boundKeydown);
|
|
5287
|
+
this.#popup?.addEventListener("close", this.#boundPopupClose);
|
|
5288
|
+
this.#panelSlot?.addEventListener("slotchange", this.#boundSlotChange);
|
|
5289
|
+
}
|
|
5290
|
+
|
|
5291
|
+
#teardownListeners() {
|
|
5292
|
+
this.#button?.removeEventListener("click", this.#boundTriggerClick);
|
|
5293
|
+
this.#button?.removeEventListener("keydown", this.#boundKeydown);
|
|
5294
|
+
this.removeEventListener("click", this.#boundOptionClick);
|
|
5295
|
+
this.#popup?.removeEventListener("keydown", this.#boundKeydown);
|
|
5296
|
+
this.#popup?.removeEventListener("close", this.#boundPopupClose);
|
|
5297
|
+
this.#panelSlot?.removeEventListener("slotchange", this.#boundSlotChange);
|
|
5298
|
+
}
|
|
5299
|
+
|
|
5300
|
+
#handleSlotChange() {
|
|
5301
|
+
this.#ensurePanelSlotAttrs();
|
|
5302
|
+
this.#syncValue();
|
|
5303
|
+
}
|
|
5304
|
+
|
|
5305
|
+
#setupObserver() {
|
|
5306
|
+
if (this.#observer) return;
|
|
5307
|
+
this.#observer = new MutationObserver((mutations) => {
|
|
5308
|
+
if (this.#syncingValue) return;
|
|
5309
|
+
let needsSync = false;
|
|
5310
|
+
for (const mutation of mutations) {
|
|
5311
|
+
if (mutation.type === "childList") {
|
|
5312
|
+
if (
|
|
5313
|
+
[...mutation.addedNodes].some((node) => this.#isMenuChild(node)) ||
|
|
5314
|
+
[...mutation.removedNodes].some((node) => this.#isMenuChild(node))
|
|
5315
|
+
) {
|
|
5316
|
+
needsSync = true;
|
|
5317
|
+
}
|
|
5318
|
+
}
|
|
5319
|
+
if (
|
|
5320
|
+
mutation.type === "attributes" &&
|
|
5321
|
+
mutation.target?.tagName === "FIG-SELECT-OPTION" &&
|
|
5322
|
+
(mutation.attributeName === "value" ||
|
|
5323
|
+
mutation.attributeName === "disabled")
|
|
5324
|
+
) {
|
|
5325
|
+
needsSync = true;
|
|
5326
|
+
}
|
|
5327
|
+
if (
|
|
5328
|
+
mutation.type === "characterData" &&
|
|
5329
|
+
mutation.target?.parentElement?.tagName === "FIG-SELECT-OPTION"
|
|
5330
|
+
) {
|
|
5331
|
+
needsSync = true;
|
|
5332
|
+
}
|
|
5333
|
+
}
|
|
5334
|
+
if (needsSync) this.#syncValue();
|
|
5335
|
+
});
|
|
5336
|
+
this.#observer.observe(this, {
|
|
5337
|
+
childList: true,
|
|
5338
|
+
subtree: true,
|
|
5339
|
+
characterData: true,
|
|
5340
|
+
attributes: true,
|
|
5341
|
+
attributeFilter: ["value", "disabled", "selected"],
|
|
5342
|
+
});
|
|
5343
|
+
}
|
|
5344
|
+
|
|
5345
|
+
#getOptions({ enabledOnly = false } = {}) {
|
|
5346
|
+
const panel = this.#getPanel();
|
|
5347
|
+
const options = panel
|
|
5348
|
+
? Array.from(panel.querySelectorAll(":scope > fig-select-option"))
|
|
5349
|
+
: [];
|
|
5350
|
+
if (!enabledOnly) return options;
|
|
5351
|
+
return options.filter((opt) => !figLabBooleanAttribute(opt, "disabled"));
|
|
5352
|
+
}
|
|
5353
|
+
|
|
5354
|
+
#optionValue(option) {
|
|
5355
|
+
if (!option) return "";
|
|
5356
|
+
if (typeof option.value === "string") return option.value;
|
|
5357
|
+
const attr = option.getAttribute?.("value");
|
|
5358
|
+
if (attr != null) return attr;
|
|
5359
|
+
return (option.textContent || "").trim();
|
|
5360
|
+
}
|
|
5361
|
+
|
|
5362
|
+
#optionLabel(option) {
|
|
5363
|
+
return (option?.textContent || "").trim();
|
|
5364
|
+
}
|
|
5365
|
+
|
|
5366
|
+
#syncPopupAttrs() {
|
|
5367
|
+
if (!this.#popup) return;
|
|
5368
|
+
this.#popup.setAttribute(
|
|
5369
|
+
"position",
|
|
5370
|
+
this.getAttribute("position") || "bottom left",
|
|
5371
|
+
);
|
|
5372
|
+
const offset = this.getAttribute("offset");
|
|
5373
|
+
if (offset) this.#popup.setAttribute("offset", offset);
|
|
5374
|
+
else this.#popup.removeAttribute("offset");
|
|
5375
|
+
const closedby = this.getAttribute("closedby");
|
|
5376
|
+
if (closedby) this.#popup.setAttribute("closedby", closedby);
|
|
5377
|
+
else this.#popup.removeAttribute("closedby");
|
|
5378
|
+
}
|
|
5379
|
+
|
|
5380
|
+
#syncDisabled() {
|
|
5381
|
+
const disabled = figLabBooleanAttribute(this, "disabled");
|
|
5382
|
+
if (this.#button) {
|
|
5383
|
+
if (disabled) this.#button.setAttribute("disabled", "");
|
|
5384
|
+
else this.#button.removeAttribute("disabled");
|
|
5385
|
+
}
|
|
5386
|
+
if (disabled && this.open) this.open = false;
|
|
5387
|
+
}
|
|
5388
|
+
|
|
5389
|
+
#pickFallbackOption(options) {
|
|
5390
|
+
if (!options.length) return null;
|
|
5391
|
+
const selected = options.find((opt) =>
|
|
5392
|
+
figLabBooleanAttribute(opt, "selected"),
|
|
5393
|
+
);
|
|
5394
|
+
if (selected && !figLabBooleanAttribute(selected, "disabled")) {
|
|
5395
|
+
return selected;
|
|
5396
|
+
}
|
|
5397
|
+
return (
|
|
5398
|
+
options.find((opt) => !figLabBooleanAttribute(opt, "disabled")) ||
|
|
5399
|
+
options[0] ||
|
|
5400
|
+
null
|
|
5401
|
+
);
|
|
5402
|
+
}
|
|
5403
|
+
|
|
5404
|
+
#emitValueEvents(value) {
|
|
5405
|
+
this.dispatchEvent(
|
|
5406
|
+
new CustomEvent("input", {
|
|
5407
|
+
detail: value,
|
|
5408
|
+
bubbles: true,
|
|
5409
|
+
composed: true,
|
|
5410
|
+
}),
|
|
5411
|
+
);
|
|
5412
|
+
this.dispatchEvent(
|
|
5413
|
+
new CustomEvent("change", {
|
|
5414
|
+
detail: value,
|
|
5415
|
+
bubbles: true,
|
|
5416
|
+
composed: true,
|
|
5417
|
+
}),
|
|
5418
|
+
);
|
|
5419
|
+
}
|
|
5420
|
+
|
|
5421
|
+
#syncValue() {
|
|
5422
|
+
if (this.#syncingValue) return;
|
|
5423
|
+
this.#syncingValue = true;
|
|
5424
|
+
try {
|
|
5425
|
+
const options = this.#getOptions();
|
|
5426
|
+
const hasValueAttr = this.hasAttribute("value");
|
|
5427
|
+
const previousValue = hasValueAttr ? this.getAttribute("value") : null;
|
|
5428
|
+
let match = hasValueAttr
|
|
5429
|
+
? options.find((opt) => this.#optionValue(opt) === previousValue)
|
|
5430
|
+
: null;
|
|
5431
|
+
let valueCorrected = false;
|
|
5432
|
+
|
|
5433
|
+
if (!match) {
|
|
5434
|
+
if (hasValueAttr) {
|
|
5435
|
+
// Value orphaned (option removed / value attr changed) — clamp or clear.
|
|
5436
|
+
match = this.#pickFallbackOption(options);
|
|
5437
|
+
if (match) {
|
|
5438
|
+
const nextValue = this.#optionValue(match);
|
|
5439
|
+
if (previousValue !== nextValue) {
|
|
5440
|
+
this.setAttribute("value", nextValue);
|
|
5441
|
+
valueCorrected = true;
|
|
5442
|
+
}
|
|
5443
|
+
} else {
|
|
5444
|
+
this.removeAttribute("value");
|
|
5445
|
+
valueCorrected = true;
|
|
5446
|
+
}
|
|
5447
|
+
} else {
|
|
5448
|
+
// No host value yet — honor a selected option if present.
|
|
5449
|
+
match = options.find((opt) =>
|
|
5450
|
+
figLabBooleanAttribute(opt, "selected"),
|
|
5451
|
+
);
|
|
5452
|
+
if (match) {
|
|
5453
|
+
this.setAttribute("value", this.#optionValue(match));
|
|
5454
|
+
valueCorrected = true;
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
}
|
|
5458
|
+
|
|
5459
|
+
for (const opt of options) {
|
|
5460
|
+
const selected = opt === match;
|
|
5461
|
+
opt.setAttribute("aria-selected", selected ? "true" : "false");
|
|
5462
|
+
if (selected) opt.setAttribute("selected", "");
|
|
5463
|
+
else opt.removeAttribute("selected");
|
|
5464
|
+
}
|
|
5465
|
+
|
|
5466
|
+
const label =
|
|
5467
|
+
(match && this.#optionLabel(match)) || this.getAttribute("label") || "";
|
|
5468
|
+
if (this.#labelEl) this.#labelEl.textContent = label;
|
|
5469
|
+
|
|
5470
|
+
const ariaLabel = this.getAttribute("label") || "Select";
|
|
5471
|
+
this.#button?.setAttribute("aria-label", ariaLabel);
|
|
5472
|
+
|
|
5473
|
+
// Don't scrollToOption while open — reposition/sync would fight overflow paging.
|
|
5474
|
+
this.#getPanel()?.syncOverflow?.();
|
|
5475
|
+
|
|
5476
|
+
if (valueCorrected) {
|
|
5477
|
+
this.#emitValueEvents(this.getAttribute("value") ?? "");
|
|
5478
|
+
}
|
|
5479
|
+
} finally {
|
|
5480
|
+
this.#syncingValue = false;
|
|
5481
|
+
}
|
|
5482
|
+
}
|
|
5483
|
+
|
|
5484
|
+
#handleTriggerClick(e) {
|
|
5485
|
+
if (figLabBooleanAttribute(this, "disabled")) return;
|
|
5486
|
+
e.preventDefault();
|
|
5487
|
+
e.stopPropagation();
|
|
5488
|
+
const popupShowing = this.#popup?.matches?.(":open") ?? false;
|
|
5489
|
+
if (this.open && popupShowing) this.open = false;
|
|
5490
|
+
else {
|
|
5491
|
+
if (this.#popup && this.#button) this.#popup.anchor = this.#button;
|
|
5492
|
+
this.open = true;
|
|
5493
|
+
}
|
|
5494
|
+
}
|
|
5495
|
+
|
|
5496
|
+
#handleOptionClick(e) {
|
|
5497
|
+
const path = typeof e.composedPath === "function" ? e.composedPath() : [];
|
|
5498
|
+
const option = path.find(
|
|
5499
|
+
(node) => node?.tagName === "FIG-SELECT-OPTION",
|
|
5500
|
+
);
|
|
5501
|
+
if (!option || !this.contains(option)) return;
|
|
5502
|
+
if (figLabBooleanAttribute(option, "disabled")) return;
|
|
5503
|
+
// Do not stopPropagation — React light-DOM onClick must still fire.
|
|
5504
|
+
this.#selectOption(option);
|
|
5505
|
+
}
|
|
5506
|
+
|
|
5507
|
+
#handleKeydown(e) {
|
|
5508
|
+
if (e.currentTarget === document && e.key !== "Escape") return;
|
|
5509
|
+
|
|
5510
|
+
const listOpen = this.open && (this.#popup?.matches?.(":open") ?? false);
|
|
5511
|
+
if (!listOpen) {
|
|
5512
|
+
if (
|
|
5513
|
+
this.#button?.contains(e.target) &&
|
|
5514
|
+
(e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")
|
|
5515
|
+
) {
|
|
5516
|
+
e.preventDefault();
|
|
5517
|
+
if (this.#popup && this.#button) this.#popup.anchor = this.#button;
|
|
5518
|
+
this.open = true;
|
|
5519
|
+
requestAnimationFrame(() => {
|
|
5520
|
+
const options = this.#getOptions({ enabledOnly: true });
|
|
5521
|
+
const selectedIndex = options.findIndex(
|
|
5522
|
+
(opt) => this.#optionValue(opt) === this.value,
|
|
5523
|
+
);
|
|
5524
|
+
this.#focusOptionAt(selectedIndex >= 0 ? selectedIndex : 0);
|
|
5525
|
+
});
|
|
5526
|
+
}
|
|
5527
|
+
return;
|
|
5528
|
+
}
|
|
5529
|
+
|
|
5530
|
+
const options = this.#getOptions({ enabledOnly: true });
|
|
5531
|
+
if (!options.length) return;
|
|
5532
|
+
|
|
5533
|
+
switch (e.key) {
|
|
5534
|
+
case "ArrowDown":
|
|
5535
|
+
e.preventDefault();
|
|
5536
|
+
this.#syncFocusedIndex();
|
|
5537
|
+
this.#focusOptionAt(this.#focusedIndex + 1);
|
|
5538
|
+
break;
|
|
5539
|
+
case "ArrowUp":
|
|
5540
|
+
e.preventDefault();
|
|
5541
|
+
this.#syncFocusedIndex();
|
|
5542
|
+
this.#focusOptionAt(this.#focusedIndex - 1);
|
|
5543
|
+
break;
|
|
5544
|
+
case "Home":
|
|
5545
|
+
e.preventDefault();
|
|
5546
|
+
this.#focusOptionAt(0);
|
|
5547
|
+
break;
|
|
5548
|
+
case "End":
|
|
5549
|
+
e.preventDefault();
|
|
5550
|
+
this.#focusOptionAt(options.length - 1);
|
|
5551
|
+
break;
|
|
5552
|
+
case "Escape":
|
|
5553
|
+
e.preventDefault();
|
|
5554
|
+
this.open = false;
|
|
5555
|
+
this.#button?.focus();
|
|
5556
|
+
break;
|
|
5557
|
+
case "Enter":
|
|
5558
|
+
case " ": {
|
|
5559
|
+
this.#syncFocusedIndex();
|
|
5560
|
+
const focused = options[this.#focusedIndex];
|
|
5561
|
+
if (!focused) return;
|
|
5562
|
+
e.preventDefault();
|
|
5563
|
+
this.#selectOption(focused);
|
|
5564
|
+
break;
|
|
5565
|
+
}
|
|
5566
|
+
}
|
|
5567
|
+
}
|
|
5568
|
+
|
|
5569
|
+
#handlePopupClose() {
|
|
5570
|
+
if (this.hasAttribute("open")) this.removeAttribute("open");
|
|
5571
|
+
this.#button?.setAttribute("aria-expanded", "false");
|
|
5572
|
+
this.#button?.focus();
|
|
5573
|
+
this.#focusedIndex = -1;
|
|
5574
|
+
}
|
|
5575
|
+
|
|
5576
|
+
#selectOption(option) {
|
|
5577
|
+
const value = this.#optionValue(option);
|
|
5578
|
+
this.setAttribute("value", value);
|
|
5579
|
+
this.#syncValue();
|
|
5580
|
+
this.#emitValueEvents(value);
|
|
5581
|
+
this.open = false;
|
|
5582
|
+
}
|
|
5583
|
+
|
|
5584
|
+
#getEnabledOptions() {
|
|
5585
|
+
return this.#getOptions({ enabledOnly: true });
|
|
5586
|
+
}
|
|
5587
|
+
|
|
5588
|
+
#syncFocusedIndex() {
|
|
5589
|
+
const options = this.#getEnabledOptions();
|
|
5590
|
+
if (!options.length) {
|
|
5591
|
+
this.#focusedIndex = -1;
|
|
5592
|
+
return;
|
|
5593
|
+
}
|
|
5594
|
+
const active = options.find((opt) => opt === document.activeElement);
|
|
5595
|
+
const index = active ? options.indexOf(active) : -1;
|
|
5596
|
+
this.#focusedIndex = index >= 0 ? index : this.#focusedIndex;
|
|
5597
|
+
}
|
|
5598
|
+
|
|
5599
|
+
#focusOptionAt(index) {
|
|
5600
|
+
const options = this.#getEnabledOptions();
|
|
5601
|
+
if (!options.length) return;
|
|
5602
|
+
const next = ((index % options.length) + options.length) % options.length;
|
|
5603
|
+
this.#focusedIndex = next;
|
|
5604
|
+
options[next]?.focus();
|
|
5605
|
+
}
|
|
5606
|
+
|
|
5607
|
+
#syncPopupWidth() {
|
|
5608
|
+
if (!this.#popup || !this.#button) return;
|
|
5609
|
+
const hostWidth = Math.ceil(this.getBoundingClientRect().width);
|
|
5610
|
+
const triggerWidth = Math.ceil(this.#button.getBoundingClientRect().width);
|
|
5611
|
+
const anchorWidth = Math.max(hostWidth, triggerWidth, 96);
|
|
5612
|
+
const full = figLabBooleanAttribute(this, "full");
|
|
5613
|
+
|
|
5614
|
+
// Use !important — fig-select::part(listbox) width rules beat element.style.
|
|
5615
|
+
if (full) {
|
|
5616
|
+
this.#popup.style.setProperty("width", `${anchorWidth}px`, "important");
|
|
5617
|
+
this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
|
|
5618
|
+
this.#popup.style.setProperty("max-width", `${anchorWidth}px`, "important");
|
|
5619
|
+
} else {
|
|
5620
|
+
this.#popup.style.setProperty("width", "max-content", "important");
|
|
5621
|
+
this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
|
|
5622
|
+
this.#popup.style.setProperty(
|
|
5623
|
+
"max-width",
|
|
5624
|
+
"min(20rem, calc(100vw - 1rem))",
|
|
5625
|
+
"important",
|
|
5626
|
+
);
|
|
5627
|
+
}
|
|
5628
|
+
}
|
|
5629
|
+
|
|
5630
|
+
#openList() {
|
|
5631
|
+
if (!this.#popup || figLabBooleanAttribute(this, "disabled")) return;
|
|
5632
|
+
if (this.#button) this.#popup.anchor = this.#button;
|
|
5633
|
+
this.#installPopupPositioning();
|
|
5634
|
+
this.#freezeMenuPosition = false;
|
|
5635
|
+
this.#syncValue();
|
|
5636
|
+
this.#syncPopupWidth();
|
|
5637
|
+
this.#popup.open = true;
|
|
5638
|
+
document.addEventListener("keydown", this.#boundKeydown, true);
|
|
5639
|
+
this.#button?.setAttribute("aria-expanded", "true");
|
|
5640
|
+
this.#focusedIndex = -1;
|
|
5641
|
+
requestAnimationFrame(() => {
|
|
5642
|
+
this.#syncPopupWidth();
|
|
5643
|
+
this.#positionPopupOverSelected();
|
|
5644
|
+
const panel = this.#getPanel();
|
|
5645
|
+
const options = this.#getEnabledOptions();
|
|
5646
|
+
const selectedIndex = options.findIndex(
|
|
5647
|
+
(opt) => this.#optionValue(opt) === this.value,
|
|
5648
|
+
);
|
|
5649
|
+
if (selectedIndex >= 0) {
|
|
5650
|
+
this.#focusOptionAt(selectedIndex);
|
|
5651
|
+
} else if (
|
|
5652
|
+
this.#button?.hasAttribute("data-focus-visible") ||
|
|
5653
|
+
this.#button?.matches?.(":focus-visible")
|
|
5654
|
+
) {
|
|
5655
|
+
this.#focusOptionAt(0);
|
|
5656
|
+
}
|
|
5657
|
+
panel?.syncOverflow?.();
|
|
5658
|
+
// Freeze after open align so later positionPopup passes don't undo scroll.
|
|
5659
|
+
this.#freezeMenuPosition = true;
|
|
5660
|
+
});
|
|
5661
|
+
}
|
|
5662
|
+
|
|
5663
|
+
#closeList() {
|
|
5664
|
+
if (!this.#popup) return;
|
|
5665
|
+
this.#freezeMenuPosition = false;
|
|
5666
|
+
document.removeEventListener("keydown", this.#boundKeydown, true);
|
|
5667
|
+
this.#popup.open = false;
|
|
5668
|
+
this.#button?.setAttribute("aria-expanded", "false");
|
|
5669
|
+
}
|
|
5670
|
+
}
|
|
5671
|
+
customElements.define("fig-select", FigSelect);
|