@rogieking/figui3 8.10.1 → 8.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig-lab.js CHANGED
@@ -173,10 +173,19 @@ function figLabPropskitEventDetail(host, value = host.value) {
173
173
  return detail;
174
174
  }
175
175
 
176
- function figLabDispatchPropskitEvent(host, type, value = host.value) {
176
+ function figLabDispatchPropskitEvent(
177
+ host,
178
+ type,
179
+ value = host.value,
180
+ additionalDetail = null,
181
+ ) {
182
+ const detail = figLabPropskitEventDetail(host, value);
183
+ if (additionalDetail && typeof additionalDetail === "object") {
184
+ Object.assign(detail, additionalDetail);
185
+ }
177
186
  host.dispatchEvent(
178
187
  new CustomEvent(type, {
179
- detail: figLabPropskitEventDetail(host, value),
188
+ detail,
180
189
  bubbles: true,
181
190
  cancelable: true,
182
191
  composed: true,
@@ -2579,203 +2588,1064 @@ class PropskitSelect extends FigLabPropskitElement {
2579
2588
  }
2580
2589
  figLabDefineElement("propskit-select", PropskitSelect);
2581
2590
 
2582
- /* PropsKit text surface */
2583
- class PropskitText extends FigLabPropskitElement {
2584
- #surface = null;
2585
- #label = null;
2591
+ /**
2592
+ * Compact selectable list with built-in add, rename, and delete actions.
2593
+ *
2594
+ * Options accept the same comma, newline, or JSON formats as fig-select.
2595
+ * Mutations normalize the options attribute to { value, label } objects so
2596
+ * labels can be renamed without changing stable option values.
2597
+ *
2598
+ * @attr {string} options - Select choices.
2599
+ * @attr {string} value - Selected option value.
2600
+ * @attr {string} default - Reset option value.
2601
+ * @attr {string} aria-label - Accessible control label.
2602
+ * @attr {boolean|string} disabled - Disables selection and list mutations.
2603
+ * @fires input - Shared PropsKit event with selected value and label.
2604
+ * @fires change - Shared PropsKit event with selected value and label.
2605
+ * @fires optionhover - Shared PropsKit event with hovered value and label.
2606
+ * @fires optionschange - Option mutation with action, option, index, and options.
2607
+ */
2608
+ class PropskitEditableSelect extends FigLabPropskitElement {
2609
+ static observedAttributes = [
2610
+ "options",
2611
+ "value",
2612
+ "default",
2613
+ "aria-label",
2614
+ "disabled",
2615
+ ];
2616
+
2617
+ #field = null;
2618
+ #select = null;
2619
+ #optionsPanel = null;
2586
2620
  #input = null;
2587
- #hasCustomLabel = false;
2621
+ #editButton = null;
2622
+ #addButton = null;
2623
+ #editingValue = "";
2624
+ #initialValue = "";
2625
+ #eventValue = undefined;
2626
+ #reflecting = false;
2627
+ #suppressSelectEvents = false;
2588
2628
  #observer = null;
2589
- #managedInputAttrs = new Set();
2590
- #boundHandleInput = null;
2591
- #boundHandleChange = null;
2592
- #boundHandleClick = this.#handleClick.bind(this);
2593
- #initialValue = null;
2594
-
2595
- static get observedAttributes() {
2596
- return ["label", "aria-label"];
2597
- }
2629
+ #menuResizeObserver = null;
2630
+ #menuFrame = 0;
2631
+ #managedSelectAttrs = new Set();
2632
+ #renderedOptionsSignature = null;
2633
+ #boundSelectInput = this.#forwardSelectEvent.bind(this, "input");
2634
+ #boundSelectChange = this.#forwardSelectEvent.bind(this, "change");
2635
+ #boundSelectOptionHover = this.#forwardSelectEvent.bind(
2636
+ this,
2637
+ "optionhover",
2638
+ );
2639
+ #boundEditClick = this.#handleEditClick.bind(this);
2640
+ #boundAddClick = this.#handleAddClick.bind(this);
2641
+ #boundHostClick = this.#handleHostClick.bind(this);
2642
+ #boundDeletePointerDown = this.#handleDeletePointerDown.bind(this);
2643
+ #boundDeleteClick = this.#handleDeleteClick.bind(this);
2644
+ #boundOptionKeydown = this.#handleOptionKeydown.bind(this);
2645
+ #boundPopupToggle = this.#handlePopupToggle.bind(this);
2646
+ #boundEditKeydown = this.#handleEditKeydown.bind(this);
2647
+ #boundEditFocusOut = this.#handleEditFocusOut.bind(this);
2648
+ #boundStopEditEvent = (event) => event.stopImmediatePropagation();
2598
2649
 
2599
2650
  connectedCallback() {
2600
- if (!this.#surface) this.#initialize();
2601
- this.#syncSurface();
2602
- this.#syncInputAttributes();
2603
- this.#bindInputEvents();
2604
- this.removeEventListener("click", this.#boundHandleClick);
2605
- this.addEventListener("click", this.#boundHandleClick);
2651
+ if (!this.#field) this.#initialize();
2652
+ this.#syncFromAttributes();
2653
+ this.#bindEvents();
2606
2654
  figLabConnectPropskitResetMenu(this);
2607
2655
 
2608
2656
  if (!this.#observer) {
2609
2657
  this.#observer = new MutationObserver((mutations) => {
2610
- let syncSurface = false;
2611
- let syncInput = false;
2612
-
2613
- for (const mutation of mutations) {
2614
- if (mutation.type !== "attributes") continue;
2615
- if (
2616
- mutation.attributeName === "label" ||
2617
- mutation.attributeName === "aria-label"
2618
- ) {
2619
- syncSurface = true;
2620
- } else if (mutation.attributeName === "direction") {
2621
- continue;
2622
- } else {
2623
- syncInput = true;
2624
- }
2658
+ if (
2659
+ mutations.some(
2660
+ (mutation) =>
2661
+ mutation.type === "attributes" &&
2662
+ mutation.attributeName !== "direction" &&
2663
+ mutation.attributeName !== "aria-disabled" &&
2664
+ !mutation.attributeName?.startsWith("data-") &&
2665
+ !PropskitEditableSelect.observedAttributes.includes(
2666
+ mutation.attributeName,
2667
+ ),
2668
+ )
2669
+ ) {
2670
+ this.#syncSelectAttributes();
2625
2671
  }
2626
-
2627
- if (syncSurface) this.#syncSurface();
2628
- if (syncInput) this.#syncInputAttributes();
2629
2672
  });
2630
2673
  }
2631
-
2632
2674
  this.#observer.observe(this, { attributes: true });
2633
2675
  }
2634
2676
 
2635
2677
  disconnectedCallback() {
2636
2678
  this.#observer?.disconnect();
2637
- this.#unbindInputEvents();
2638
- this.removeEventListener("click", this.#boundHandleClick);
2679
+ this.#unbindEvents();
2639
2680
  figLabDisconnectPropskitResetMenu(this);
2640
2681
  }
2641
2682
 
2642
2683
  attributeChangedCallback(name, oldValue, newValue) {
2643
- if (oldValue === newValue || !this.#surface) return;
2644
- if (name === "label" || name === "aria-label") {
2645
- this.#syncSurface();
2684
+ if (oldValue === newValue || !this.#field) return;
2685
+ if (name === "value" && this.#reflecting) return;
2686
+ if (name === "disabled") {
2687
+ this.#syncDisabled();
2688
+ return;
2646
2689
  }
2690
+ this.#syncFromAttributes();
2647
2691
  }
2648
2692
 
2649
2693
  #initialize() {
2650
- this.#initialValue = this.getAttribute("value") ?? "";
2651
- const initialChildren = Array.from(this.childNodes).filter(
2652
- (node) =>
2653
- node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
2694
+ this.#initialValue = this.#resolveValue(this.getAttribute("value"));
2695
+ const field = figLabCreateElement("div", {
2696
+ className: "propskit-editable-select-surface",
2697
+ });
2698
+ const select = figLabCreateElement("fig-select", {
2699
+ subtle: true,
2700
+ });
2701
+ const optionsPanel = figLabCreateElement("fig-select-options", {
2702
+ className: "propskit-editable-select-options",
2703
+ slot: "panel",
2704
+ });
2705
+ select.append(optionsPanel);
2706
+ const editButton = this.#createActionButton(
2707
+ "propskit-editable-select-edit",
2708
+ "Edit item",
2709
+ "edit",
2710
+ );
2711
+ const addButton = this.#createActionButton(
2712
+ "propskit-editable-select-add",
2713
+ "Add item",
2714
+ "add",
2715
+ );
2716
+ const editTooltip = figLabCreateElement(
2717
+ "fig-tooltip",
2718
+ {
2719
+ className: "propskit-editable-select-edit-tooltip",
2720
+ text: "Edit item",
2721
+ },
2722
+ editButton,
2654
2723
  );
2655
- const customLabel = initialChildren.find(
2656
- (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
2724
+ const addTooltip = figLabCreateElement(
2725
+ "fig-tooltip",
2726
+ {
2727
+ className: "propskit-editable-select-add-tooltip",
2728
+ text: "Add item",
2729
+ },
2730
+ addButton,
2657
2731
  );
2658
- const surface = figLabCreateElement("div", {
2659
- className: "propskit-text-surface",
2660
- });
2661
- const label = customLabel || document.createElement("label");
2662
- const input = document.createElement("fig-input-text");
2732
+ field.append(select, editTooltip, addTooltip);
2733
+ this.#field = field;
2734
+ this.#select = select;
2735
+ this.#optionsPanel = optionsPanel;
2736
+ this.#editButton = editButton;
2737
+ this.#addButton = addButton;
2738
+ this.replaceChildren(field);
2739
+ this.#reflectValue(this.#initialValue);
2740
+ }
2663
2741
 
2664
- for (const node of initialChildren) {
2665
- if (node !== customLabel) input.appendChild(node);
2742
+ #createActionButton(className, label, iconName) {
2743
+ return figLabCreateElement(
2744
+ "fig-button",
2745
+ {
2746
+ className,
2747
+ variant: "secondary",
2748
+ icon: true,
2749
+ "aria-label": label,
2750
+ },
2751
+ figLabCreateElement("fig-icon", {
2752
+ name: iconName,
2753
+ size: "medium",
2754
+ "aria-hidden": "true",
2755
+ }),
2756
+ );
2757
+ }
2758
+
2759
+ #parseOptions(value = this.getAttribute("options")) {
2760
+ let parsed = value;
2761
+ if (typeof value === "string") {
2762
+ const text = value.trim();
2763
+ if (text.startsWith("[")) {
2764
+ try {
2765
+ parsed = JSON.parse(text);
2766
+ } catch {
2767
+ parsed = [];
2768
+ }
2769
+ } else {
2770
+ const delimiter = text.includes("\n") ? "\n" : ",";
2771
+ parsed = text
2772
+ .split(delimiter)
2773
+ .map((entry) => entry.trim())
2774
+ .filter(Boolean);
2775
+ }
2666
2776
  }
2667
- surface.append(label, input);
2668
- this.#surface = surface;
2669
- this.#label = label;
2670
- this.#input = input;
2671
- this.#hasCustomLabel = Boolean(customLabel);
2672
- this.replaceChildren(surface);
2777
+ if (!Array.isArray(parsed)) return [];
2778
+
2779
+ const seen = new Set();
2780
+ const options = [];
2781
+ for (const option of parsed) {
2782
+ const objectOption =
2783
+ option && typeof option === "object" && !Array.isArray(option);
2784
+ const optionValue = objectOption
2785
+ ? option.value ?? option.label ?? ""
2786
+ : option;
2787
+ const optionLabel = objectOption
2788
+ ? option.label ?? option.value ?? ""
2789
+ : option;
2790
+ const normalizedValue = String(optionValue ?? "").trim();
2791
+ const normalizedLabel = String(optionLabel ?? "").trim();
2792
+ if (!normalizedValue || seen.has(normalizedValue)) continue;
2793
+ seen.add(normalizedValue);
2794
+ options.push({
2795
+ value: normalizedValue,
2796
+ label: normalizedLabel || normalizedValue,
2797
+ });
2798
+ }
2799
+ return options;
2673
2800
  }
2674
2801
 
2675
- #syncSurface() {
2676
- if (!this.#surface || !this.#label || !this.#input) return;
2677
- const labelId = figLabSyncPropskitLabel(
2678
- this,
2679
- this.#surface,
2680
- this.#label,
2681
- this.#hasCustomLabel,
2682
- );
2683
- figLabSyncPropskitControlLabel(this, this.#input, labelId, "Text");
2802
+ #resolveValue(value) {
2803
+ const options = this.#parseOptions();
2804
+ const requested = String(value ?? "").trim();
2805
+ if (requested && options.some((option) => option.value === requested)) {
2806
+ return requested;
2807
+ }
2808
+ return options[0]?.value || "";
2684
2809
  }
2685
2810
 
2686
- #getForwardedInputAttrNames() {
2811
+ #reflectValue(value) {
2812
+ const resolved = this.#resolveValue(value);
2813
+ const current = this.getAttribute("value");
2814
+ this.#reflecting = true;
2815
+ try {
2816
+ if (resolved) {
2817
+ if (current !== resolved) this.setAttribute("value", resolved);
2818
+ } else if (current !== null) {
2819
+ this.removeAttribute("value");
2820
+ }
2821
+ } finally {
2822
+ this.#reflecting = false;
2823
+ }
2824
+ if (this.#select) this.#select.value = resolved;
2825
+ return resolved;
2826
+ }
2827
+
2828
+ #getForwardedSelectAttrNames() {
2687
2829
  const reserved = new Set([
2830
+ "options",
2831
+ "value",
2832
+ "default",
2833
+ "disabled",
2834
+ "name",
2688
2835
  "label",
2836
+ "aria-label",
2837
+ "aria-disabled",
2689
2838
  "direction",
2690
2839
  "oninput",
2691
2840
  "onchange",
2841
+ "onoptionhover",
2692
2842
  "class",
2693
2843
  "style",
2694
2844
  "id",
2695
2845
  "size",
2696
- "type",
2697
- "aria-label",
2698
- "multiline",
2699
- "autoresize",
2700
- "resizable",
2701
- "default",
2702
2846
  "variant",
2847
+ "full",
2848
+ "subtle",
2849
+ "data-editing",
2703
2850
  ]);
2704
2851
  return this.getAttributeNames().filter(
2705
2852
  (name) => !reserved.has(name) && !name.startsWith("data-"),
2706
2853
  );
2707
2854
  }
2708
2855
 
2709
- #syncInputAttributes() {
2710
- if (!this.#input) return;
2711
- const inputAttrs = this.#getForwardedInputAttrNames();
2712
- const defaultEnabledAttrs = ["multiline", "autoresize"];
2713
- const nextManaged = new Set([...inputAttrs, ...defaultEnabledAttrs, "type"]);
2856
+ #syncFromAttributes() {
2857
+ const resolved = this.#reflectValue(this.getAttribute("value"));
2858
+ if (
2859
+ this.#input &&
2860
+ (!this.#editingValue || resolved !== this.#editingValue)
2861
+ ) {
2862
+ this.#cancelEditing(false);
2863
+ }
2864
+ this.#syncSelectAttributes();
2865
+ this.#syncDisabled();
2866
+ }
2714
2867
 
2715
- for (const attrName of this.#managedInputAttrs) {
2716
- if (!nextManaged.has(attrName)) this.#input.removeAttribute(attrName);
2868
+ #syncSelectAttributes() {
2869
+ if (!this.#select) return;
2870
+ const selectAttrs = this.#getForwardedSelectAttrNames();
2871
+ const nextManaged = new Set(selectAttrs);
2872
+ for (const name of this.#managedSelectAttrs) {
2873
+ if (!nextManaged.has(name)) this.#select.removeAttribute(name);
2717
2874
  }
2718
- for (const attrName of inputAttrs) {
2719
- this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
2875
+ for (const name of selectAttrs) {
2876
+ this.#select.setAttribute(name, this.getAttribute(name) ?? "");
2720
2877
  }
2721
- for (const attrName of defaultEnabledAttrs) {
2722
- this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
2878
+ this.#managedSelectAttrs = nextManaged;
2879
+
2880
+ this.#syncOptionElements();
2881
+ const label = this.getAttribute("aria-label")?.trim() || "Select item";
2882
+ this.#select.setAttribute("label", label);
2883
+ this.#select.setAttribute("aria-label", label);
2884
+ this.#select.setAttribute("subtle", "");
2885
+ this.#select.setAttribute("options", JSON.stringify(this.options));
2886
+ this.#select.value = this.#resolveValue(this.getAttribute("value"));
2887
+ }
2888
+
2889
+ #syncOptionElements() {
2890
+ if (!this.#optionsPanel) return;
2891
+ const options = this.options;
2892
+ const signature = JSON.stringify(options);
2893
+ if (signature === this.#renderedOptionsSignature) return;
2894
+ this.#renderedOptionsSignature = signature;
2895
+
2896
+ for (const option of this.#optionsPanel.querySelectorAll(
2897
+ ":scope > fig-select-option",
2898
+ )) {
2899
+ option.remove();
2723
2900
  }
2724
- this.#input.setAttribute("type", "text");
2901
+ const endButton = this.#optionsPanel.querySelector(
2902
+ ":scope > .fig-overflow-end",
2903
+ );
2904
+ for (const entry of options) {
2905
+ const option = figLabCreateElement("fig-select-option", {
2906
+ value: entry.value,
2907
+ label: entry.label,
2908
+ "aria-label": `${entry.label}. Press Delete to remove this item.`,
2909
+ });
2910
+ const label = figLabCreateElement(
2911
+ "span",
2912
+ { className: "propskit-editable-select-option-label" },
2913
+ entry.label,
2914
+ );
2915
+ const deleteButton = figLabCreateElement(
2916
+ "fig-button",
2917
+ {
2918
+ className: "propskit-editable-select-delete",
2919
+ variant: "ghost",
2920
+ icon: true,
2921
+ "aria-label": `Delete ${entry.label}`,
2922
+ "aria-hidden": "true",
2923
+ "data-value": entry.value,
2924
+ },
2925
+ figLabCreateElement("fig-icon", {
2926
+ name: "trash",
2927
+ size: "small",
2928
+ "aria-hidden": "true",
2929
+ }),
2930
+ );
2931
+ const deleteTooltip = figLabCreateElement(
2932
+ "fig-tooltip",
2933
+ {
2934
+ className: "propskit-editable-select-delete-tooltip",
2935
+ slot: "append",
2936
+ text: `Delete ${entry.label}`,
2937
+ },
2938
+ deleteButton,
2939
+ );
2940
+ option.append(label, deleteTooltip);
2941
+ if (endButton) this.#optionsPanel.insertBefore(option, endButton);
2942
+ else this.#optionsPanel.append(option);
2943
+ const innerButton =
2944
+ deleteButton.button ||
2945
+ deleteButton.shadowRoot?.querySelector("button, [role='button']");
2946
+ if (innerButton instanceof HTMLElement) {
2947
+ innerButton.inert = true;
2948
+ innerButton.style.pointerEvents = "none";
2949
+ }
2950
+ }
2951
+ }
2725
2952
 
2726
- this.#managedInputAttrs = nextManaged;
2953
+ #syncDisabled() {
2954
+ if (!this.#select || !this.#editButton || !this.#addButton) return;
2955
+ const disabled = figLabBooleanAttribute(this, "disabled");
2956
+ const listLocked = this.options.length <= 1;
2957
+ this.setAttribute("aria-disabled", String(disabled));
2958
+ this.#select.toggleAttribute("disabled", disabled || listLocked);
2959
+ this.#input?.toggleAttribute("disabled", disabled);
2960
+ for (const deleteButton of this.#optionsPanel?.querySelectorAll(
2961
+ ".propskit-editable-select-delete",
2962
+ ) || []) {
2963
+ deleteButton.toggleAttribute("disabled", disabled || listLocked);
2964
+ }
2965
+ this.#addButton.toggleAttribute("disabled", disabled || Boolean(this.#input));
2966
+ this.#editButton.toggleAttribute(
2967
+ "disabled",
2968
+ disabled || (!this.#input && !this.value),
2969
+ );
2727
2970
  }
2728
2971
 
2729
- #bindInputEvents() {
2730
- if (!this.#input) return;
2731
- this.#boundHandleInput ??= this.#forwardInputEvent.bind(this, "input");
2732
- this.#boundHandleChange ??= this.#forwardInputEvent.bind(this, "change");
2733
- this.#input.addEventListener("input", this.#boundHandleInput);
2734
- this.#input.addEventListener("change", this.#boundHandleChange);
2972
+ #syncEditButton() {
2973
+ if (!this.#editButton) return;
2974
+ const editing = Boolean(this.#input);
2975
+ this.toggleAttribute("data-editing", editing);
2976
+ this.#editButton.setAttribute(
2977
+ "aria-label",
2978
+ editing ? "Save item" : "Edit item",
2979
+ );
2980
+ this.#editButton
2981
+ .closest("fig-tooltip")
2982
+ ?.setAttribute("text", editing ? "Save item" : "Edit item");
2983
+ this.#editButton.setAttribute("variant", editing ? "primary" : "secondary");
2984
+ const icon = this.#editButton.querySelector("fig-icon");
2985
+ icon?.setAttribute("name", editing ? "checkmark" : "edit");
2986
+ this.#syncDisabled();
2735
2987
  }
2736
2988
 
2737
- #unbindInputEvents() {
2738
- if (!this.#input) return;
2739
- if (this.#boundHandleInput) {
2740
- this.#input.removeEventListener("input", this.#boundHandleInput);
2741
- }
2742
- if (this.#boundHandleChange) {
2743
- this.#input.removeEventListener("change", this.#boundHandleChange);
2989
+ #bindEvents() {
2990
+ this.#unbindEvents();
2991
+ this.#select?.addEventListener("input", this.#boundSelectInput);
2992
+ this.#select?.addEventListener("change", this.#boundSelectChange);
2993
+ this.#select?.addEventListener(
2994
+ "optionhover",
2995
+ this.#boundSelectOptionHover,
2996
+ );
2997
+ this.#editButton?.addEventListener("click", this.#boundEditClick);
2998
+ this.#addButton?.addEventListener("click", this.#boundAddClick);
2999
+ this.#optionsPanel?.addEventListener(
3000
+ "pointerdown",
3001
+ this.#boundDeletePointerDown,
3002
+ );
3003
+ this.#optionsPanel?.addEventListener("click", this.#boundDeleteClick);
3004
+ this.#optionsPanel?.addEventListener("keydown", this.#boundOptionKeydown);
3005
+ this.addEventListener("click", this.#boundHostClick);
3006
+ const popup = this.#getSelectPopup();
3007
+ popup?.addEventListener("toggle", this.#boundPopupToggle);
3008
+ this.#installMenuPositioning();
3009
+ if (!this.#menuResizeObserver && typeof ResizeObserver === "function") {
3010
+ this.#menuResizeObserver = new ResizeObserver(() => {
3011
+ if (this.#select?.open) this.#queueMenuSurfaceSync();
3012
+ });
2744
3013
  }
3014
+ if (this.#field) this.#menuResizeObserver?.observe(this.#field);
2745
3015
  }
2746
3016
 
2747
- #forwardInputEvent(type, event) {
2748
- event.stopImmediatePropagation();
2749
- if (figLabBooleanAttribute(this, "disabled")) return;
2750
- const value = this.#input?.value ?? "";
2751
- this.setAttribute("value", String(value));
2752
- figLabDispatchPropskitEvent(this, type);
3017
+ #unbindEvents() {
3018
+ this.#select?.removeEventListener("input", this.#boundSelectInput);
3019
+ this.#select?.removeEventListener("change", this.#boundSelectChange);
3020
+ this.#select?.removeEventListener(
3021
+ "optionhover",
3022
+ this.#boundSelectOptionHover,
3023
+ );
3024
+ this.#editButton?.removeEventListener("click", this.#boundEditClick);
3025
+ this.#addButton?.removeEventListener("click", this.#boundAddClick);
3026
+ this.#optionsPanel?.removeEventListener(
3027
+ "pointerdown",
3028
+ this.#boundDeletePointerDown,
3029
+ );
3030
+ this.#optionsPanel?.removeEventListener("click", this.#boundDeleteClick);
3031
+ this.#optionsPanel?.removeEventListener(
3032
+ "keydown",
3033
+ this.#boundOptionKeydown,
3034
+ );
3035
+ this.removeEventListener("click", this.#boundHostClick);
3036
+ this.#getSelectPopup()?.removeEventListener(
3037
+ "toggle",
3038
+ this.#boundPopupToggle,
3039
+ );
3040
+ this.#menuResizeObserver?.disconnect();
3041
+ this.#menuResizeObserver = null;
3042
+ cancelAnimationFrame(this.#menuFrame);
3043
+ this.#menuFrame = 0;
2753
3044
  }
2754
3045
 
2755
- #handleClick(event) {
2756
- if (figLabBooleanAttribute(this, "disabled")) return;
3046
+ #getSelectPopup() {
3047
+ return this.#select?.shadowRoot?.querySelector('dialog[is="fig-popup"]');
3048
+ }
3049
+
3050
+ #installMenuPositioning() {
3051
+ const popup = this.#getSelectPopup();
2757
3052
  if (
2758
- event.target instanceof Element &&
2759
- event.target.closest("fig-input-text, fig-menu")
3053
+ !popup ||
3054
+ popup.__propskitEditableSelectPositioning ||
3055
+ typeof popup.positionPopup !== "function"
2760
3056
  ) {
2761
3057
  return;
2762
3058
  }
2763
- this.focus();
3059
+ const positionPopup = popup.positionPopup.bind(popup);
3060
+ popup.positionPopup = (...args) => {
3061
+ const result = positionPopup(...args);
3062
+ this.#queueMenuSurfaceSync();
3063
+ return result;
3064
+ };
3065
+ popup.__propskitEditableSelectPositioning = true;
2764
3066
  }
2765
3067
 
2766
- get value() {
2767
- return this.#input?.value ?? this.getAttribute("value") ?? "";
3068
+ #handlePopupToggle(event) {
3069
+ if (event.newState === "open" || this.#select?.open) {
3070
+ this.#queueMenuSurfaceSync();
3071
+ }
2768
3072
  }
2769
3073
 
2770
- set value(nextValue) {
2771
- if (nextValue === null || nextValue === undefined) {
2772
- this.removeAttribute("value");
2773
- if (this.#input) this.#input.value = "";
2774
- } else {
2775
- const next = String(nextValue);
2776
- this.setAttribute("value", next);
2777
- if (this.#input) this.#input.value = next;
2778
- }
3074
+ #queueMenuSurfaceSync() {
3075
+ cancelAnimationFrame(this.#menuFrame);
3076
+ this.#menuFrame = requestAnimationFrame(() => {
3077
+ this.#syncMenuToSurface();
3078
+ this.#menuFrame = requestAnimationFrame(() => {
3079
+ this.#menuFrame = 0;
3080
+ this.#syncMenuToSurface();
3081
+ });
3082
+ });
3083
+ }
3084
+
3085
+ #syncMenuToSurface() {
3086
+ const popup = this.#getSelectPopup();
3087
+ if (!popup || !this.#field || !this.#select?.open) return;
3088
+ const surfaceRect = this.#field.getBoundingClientRect();
3089
+ if (!surfaceRect.width) return;
3090
+ popup.style.setProperty("box-sizing", "border-box", "important");
3091
+ popup.style.setProperty("left", `${surfaceRect.left}px`, "important");
3092
+ popup.style.setProperty("width", `${surfaceRect.width}px`, "important");
3093
+ popup.style.setProperty("min-width", `${surfaceRect.width}px`, "important");
3094
+ popup.style.setProperty("max-width", `${surfaceRect.width}px`, "important");
3095
+ }
3096
+
3097
+ #forwardSelectEvent(type, event) {
3098
+ if (event.target !== this.#select) return;
3099
+ event.stopImmediatePropagation();
3100
+ if (this.#suppressSelectEvents) return;
3101
+ if (figLabBooleanAttribute(this, "disabled")) return;
3102
+ const eventValue =
3103
+ type === "optionhover" && event instanceof CustomEvent
3104
+ ? String(event.detail ?? "")
3105
+ : this.#reflectValue(this.#select.value);
3106
+ this.#dispatchOptionEvent(type, eventValue);
3107
+ }
3108
+
3109
+ #dispatchOptionEvent(type, value = this.value) {
3110
+ const eventValue = String(value ?? "");
3111
+ const label =
3112
+ this.options.find((option) => option.value === eventValue)?.label || "";
3113
+ this.#eventValue = eventValue;
3114
+ try {
3115
+ figLabDispatchPropskitEvent(this, type, eventValue, { label });
3116
+ } finally {
3117
+ this.#eventValue = undefined;
3118
+ }
3119
+ }
3120
+
3121
+ #dispatchOptionsChange(action, option, index) {
3122
+ const value = this.value;
3123
+ const options = this.options.map((entry) => ({ ...entry }));
3124
+ const label =
3125
+ options.find((entry) => entry.value === value)?.label || "";
3126
+ this.dispatchEvent(
3127
+ new CustomEvent("optionschange", {
3128
+ detail: {
3129
+ ...figLabPropskitEventDetail(this, value),
3130
+ label,
3131
+ action,
3132
+ option: { ...option },
3133
+ index,
3134
+ options,
3135
+ },
3136
+ bubbles: true,
3137
+ composed: true,
3138
+ }),
3139
+ );
3140
+ }
3141
+
3142
+ #handleEditClick(event) {
3143
+ event.preventDefault();
3144
+ event.stopPropagation();
3145
+ if (figLabBooleanAttribute(this, "disabled")) return;
3146
+ if (this.#input) this.#commitEditing();
3147
+ else this.#startEditing();
3148
+ }
3149
+
3150
+ #handleHostClick(event) {
3151
+ if (
3152
+ this.#input ||
3153
+ !this.#select ||
3154
+ figLabBooleanAttribute(this, "disabled") ||
3155
+ figLabBooleanAttribute(this.#select, "disabled") ||
3156
+ (event.target instanceof Element &&
3157
+ event.target.closest(
3158
+ ".propskit-editable-select-edit, .propskit-editable-select-add, fig-menu",
3159
+ ))
3160
+ ) {
3161
+ return;
3162
+ }
3163
+ if (event.target instanceof Element && event.target.closest("fig-select")) {
3164
+ return;
3165
+ }
3166
+ event.preventDefault();
3167
+ this.#select.open = true;
3168
+ }
3169
+
3170
+ #handleAddClick(event) {
3171
+ event.preventDefault();
3172
+ event.stopPropagation();
3173
+ if (figLabBooleanAttribute(this, "disabled") || this.#input) return;
3174
+
3175
+ const options = this.options;
3176
+ const index = options.length;
3177
+ const option = { value: `item-${index}`, label: "New item" };
3178
+ this.options = [...options, option];
3179
+ const value = option.value;
3180
+ this.#reflectValue(value);
3181
+ this.#syncSelectAttributes();
3182
+ this.#dispatchOptionsChange("add", option, index);
3183
+ for (const type of ["input", "change"]) {
3184
+ this.#dispatchOptionEvent(type);
3185
+ }
3186
+ this.#startEditing();
3187
+ }
3188
+
3189
+ #handleDeletePointerDown(event) {
3190
+ if (
3191
+ !(event.target instanceof Element) ||
3192
+ !event.target.closest(".propskit-editable-select-delete")
3193
+ ) {
3194
+ return;
3195
+ }
3196
+ event.preventDefault();
3197
+ event.stopPropagation();
3198
+ }
3199
+
3200
+ #handleDeleteClick(event) {
3201
+ if (!(event.target instanceof Element)) return;
3202
+ const button = event.target.closest(".propskit-editable-select-delete");
3203
+ if (!button || !this.#optionsPanel?.contains(button)) return;
3204
+ event.preventDefault();
3205
+ event.stopPropagation();
3206
+ event.stopImmediatePropagation();
3207
+ if (
3208
+ figLabBooleanAttribute(this, "disabled") ||
3209
+ figLabBooleanAttribute(button, "disabled")
3210
+ ) {
3211
+ return;
3212
+ }
3213
+ this.#deleteOption(button.getAttribute("data-value") || "");
3214
+ }
3215
+
3216
+ #handleOptionKeydown(event) {
3217
+ if (
3218
+ (event.key !== "Delete" && event.key !== "Backspace") ||
3219
+ event.altKey ||
3220
+ event.ctrlKey ||
3221
+ event.metaKey ||
3222
+ event.shiftKey ||
3223
+ !(event.target instanceof Element)
3224
+ ) {
3225
+ return;
3226
+ }
3227
+ const option = event.target.closest("fig-select-option");
3228
+ if (!option || option.parentElement !== this.#optionsPanel) return;
3229
+ event.preventDefault();
3230
+ event.stopImmediatePropagation();
3231
+ this.#deleteOption(option.getAttribute("value") || "");
3232
+ }
3233
+
3234
+ #deleteOption(value) {
3235
+ if (
3236
+ !this.#select ||
3237
+ !this.#optionsPanel ||
3238
+ figLabBooleanAttribute(this, "disabled")
3239
+ ) {
3240
+ return;
3241
+ }
3242
+ const options = this.options;
3243
+ if (options.length <= 1) return;
3244
+ const index = options.findIndex((option) => option.value === value);
3245
+ if (index < 0) return;
3246
+ const removedOption = { ...options[index] };
3247
+
3248
+ const wasOpen = this.#select.open;
3249
+ const selectedValue = this.value;
3250
+ const nextOptions = options.filter((option) => option.value !== value);
3251
+ const nextFocusIndex = Math.min(index, nextOptions.length - 1);
3252
+ const nextValue =
3253
+ selectedValue === value
3254
+ ? nextOptions[Math.max(0, nextFocusIndex)]?.value || ""
3255
+ : selectedValue;
3256
+
3257
+ this.#suppressSelectEvents = true;
3258
+ try {
3259
+ if (nextValue) this.#reflectValue(nextValue);
3260
+ this.options = nextOptions;
3261
+ this.#reflectValue(nextValue);
3262
+ this.#syncSelectAttributes();
3263
+ } finally {
3264
+ this.#suppressSelectEvents = false;
3265
+ }
3266
+ this.#dispatchOptionsChange("delete", removedOption, index);
3267
+ if (selectedValue !== nextValue) {
3268
+ for (const type of ["input", "change"]) {
3269
+ this.#dispatchOptionEvent(type);
3270
+ }
3271
+ }
3272
+
3273
+ queueMicrotask(() => {
3274
+ if (nextOptions.length > 1) {
3275
+ if (wasOpen) this.#select.open = true;
3276
+ const optionElements = [
3277
+ ...this.#optionsPanel.querySelectorAll(
3278
+ ":scope > fig-select-option",
3279
+ ),
3280
+ ];
3281
+ optionElements[Math.max(0, nextFocusIndex)]?.focus();
3282
+ } else {
3283
+ this.#select.open = false;
3284
+ requestAnimationFrame(() => this.#addButton?.focus());
3285
+ }
3286
+ });
3287
+ }
3288
+
3289
+ #startEditing() {
3290
+ if (
3291
+ !this.#field ||
3292
+ !this.value ||
3293
+ figLabBooleanAttribute(this, "disabled")
3294
+ ) {
3295
+ return;
3296
+ }
3297
+ const entry = this.options.find((option) => option.value === this.value);
3298
+ if (!entry) return;
3299
+ const input = figLabCreateElement("fig-input-text", {
3300
+ type: "text",
3301
+ full: true,
3302
+ value: entry.label,
3303
+ "aria-label": `Rename ${entry.label}`,
3304
+ });
3305
+ input.addEventListener("input", this.#boundStopEditEvent);
3306
+ input.addEventListener("change", this.#boundStopEditEvent);
3307
+ input.addEventListener("keydown", this.#boundEditKeydown);
3308
+ input.addEventListener("focusout", this.#boundEditFocusOut);
3309
+ this.#editingValue = entry.value;
3310
+ this.#input = input;
3311
+ this.#select.replaceWith(input);
3312
+ this.#syncEditButton();
3313
+ queueMicrotask(() => {
3314
+ input.focus();
3315
+ input.input?.select?.();
3316
+ });
3317
+ }
3318
+
3319
+ #handleEditKeydown(event) {
3320
+ if (event.key === "Enter") {
3321
+ event.preventDefault();
3322
+ event.stopImmediatePropagation();
3323
+ this.#commitEditing();
3324
+ } else if (event.key === "Escape") {
3325
+ event.preventDefault();
3326
+ event.stopImmediatePropagation();
3327
+ this.#cancelEditing();
3328
+ }
3329
+ }
3330
+
3331
+ #handleEditFocusOut(event) {
3332
+ if (!this.#input) return;
3333
+ const next = event.relatedTarget;
3334
+ if (next instanceof Node && this.#input.contains(next)) return;
3335
+ if (
3336
+ next instanceof Node &&
3337
+ (next === this.#editButton ||
3338
+ this.#editButton?.contains(next) ||
3339
+ next.getRootNode() instanceof ShadowRoot &&
3340
+ next.getRootNode().host === this.#editButton)
3341
+ ) {
3342
+ return;
3343
+ }
3344
+ this.#commitEditing(false);
3345
+ }
3346
+
3347
+ #finishEditing(focus = true) {
3348
+ if (!this.#field || !this.#select) return;
3349
+ const input = this.#input;
3350
+ this.#input = null;
3351
+ this.#editingValue = "";
3352
+ if (input?.parentElement === this.#field) input.replaceWith(this.#select);
3353
+ else if (this.#select.parentElement !== this.#field) {
3354
+ this.#field.prepend(this.#select);
3355
+ }
3356
+ this.#syncSelectAttributes();
3357
+ this.#syncEditButton();
3358
+ if (focus) queueMicrotask(() => this.#select?.focus());
3359
+ }
3360
+
3361
+ #commitEditing(focus = true) {
3362
+ if (!this.#input || !this.#editingValue) return;
3363
+ const editingValue = this.#editingValue;
3364
+ const options = this.options;
3365
+ const index = options.findIndex(
3366
+ (option) => option.value === editingValue,
3367
+ );
3368
+ if (index < 0) {
3369
+ this.#cancelEditing(focus);
3370
+ return;
3371
+ }
3372
+ const label = String(this.#input.value ?? "").trim() || options[index].label;
3373
+ const changed = label !== options[index].label;
3374
+ this.#finishEditing(false);
3375
+ if (changed) {
3376
+ const option = { ...options[index], label };
3377
+ options[index] = option;
3378
+ this.options = options;
3379
+ this.#dispatchOptionsChange("rename", option, index);
3380
+ }
3381
+ this.#reflectValue(editingValue);
3382
+ this.#syncSelectAttributes();
3383
+ if (changed) {
3384
+ for (const type of ["input", "change"]) {
3385
+ this.#dispatchOptionEvent(type);
3386
+ }
3387
+ }
3388
+ if (focus) queueMicrotask(() => this.#select?.focus());
3389
+ }
3390
+
3391
+ #cancelEditing(focus = true) {
3392
+ this.#finishEditing(focus);
3393
+ }
3394
+
3395
+ get options() {
3396
+ return this.#parseOptions();
3397
+ }
3398
+
3399
+ set options(value) {
3400
+ if (value === null || value === undefined) {
3401
+ this.removeAttribute("options");
3402
+ return;
3403
+ }
3404
+ this.setAttribute("options", JSON.stringify(this.#parseOptions(value)));
3405
+ }
3406
+
3407
+ get value() {
3408
+ return (
3409
+ this.#eventValue ??
3410
+ this.#resolveValue(
3411
+ this.getAttribute("value") ?? this.#select?.value ?? "",
3412
+ )
3413
+ );
3414
+ }
3415
+
3416
+ set value(value) {
3417
+ this.#reflectValue(value);
3418
+ this.#syncSelectAttributes();
3419
+ }
3420
+
3421
+ get defaultValue() {
3422
+ const requested = this.hasAttribute("default")
3423
+ ? this.getAttribute("default")
3424
+ : this.#initialValue;
3425
+ return this.#resolveValue(requested);
3426
+ }
3427
+
3428
+ get isDefault() {
3429
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
3430
+ }
3431
+
3432
+ get editing() {
3433
+ return Boolean(this.#input);
3434
+ }
3435
+
3436
+ resetToDefault() {
3437
+ this.#cancelEditing(false);
3438
+ this.value = this.defaultValue;
3439
+ for (const type of ["input", "change"]) {
3440
+ this.#dispatchOptionEvent(type);
3441
+ }
3442
+ }
3443
+
3444
+ focus(options) {
3445
+ if (figLabBooleanAttribute(this, "disabled")) return;
3446
+ if (this.#input) this.#input.focus(options);
3447
+ else this.#select?.focus(options);
3448
+ }
3449
+ }
3450
+ figLabDefineElement("propskit-editable-select", PropskitEditableSelect);
3451
+
3452
+ /* PropsKit text surface */
3453
+ class PropskitText extends FigLabPropskitElement {
3454
+ #surface = null;
3455
+ #label = null;
3456
+ #input = null;
3457
+ #hasCustomLabel = false;
3458
+ #observer = null;
3459
+ #managedInputAttrs = new Set();
3460
+ #boundHandleInput = null;
3461
+ #boundHandleChange = null;
3462
+ #boundHandleClick = this.#handleClick.bind(this);
3463
+ #initialValue = null;
3464
+
3465
+ static get observedAttributes() {
3466
+ return ["label", "aria-label"];
3467
+ }
3468
+
3469
+ connectedCallback() {
3470
+ if (!this.#surface) this.#initialize();
3471
+ this.#syncSurface();
3472
+ this.#syncInputAttributes();
3473
+ this.#bindInputEvents();
3474
+ this.removeEventListener("click", this.#boundHandleClick);
3475
+ this.addEventListener("click", this.#boundHandleClick);
3476
+ figLabConnectPropskitResetMenu(this);
3477
+
3478
+ if (!this.#observer) {
3479
+ this.#observer = new MutationObserver((mutations) => {
3480
+ let syncSurface = false;
3481
+ let syncInput = false;
3482
+
3483
+ for (const mutation of mutations) {
3484
+ if (mutation.type !== "attributes") continue;
3485
+ if (
3486
+ mutation.attributeName === "label" ||
3487
+ mutation.attributeName === "aria-label"
3488
+ ) {
3489
+ syncSurface = true;
3490
+ } else if (mutation.attributeName === "direction") {
3491
+ continue;
3492
+ } else {
3493
+ syncInput = true;
3494
+ }
3495
+ }
3496
+
3497
+ if (syncSurface) this.#syncSurface();
3498
+ if (syncInput) this.#syncInputAttributes();
3499
+ });
3500
+ }
3501
+
3502
+ this.#observer.observe(this, { attributes: true });
3503
+ }
3504
+
3505
+ disconnectedCallback() {
3506
+ this.#observer?.disconnect();
3507
+ this.#unbindInputEvents();
3508
+ this.removeEventListener("click", this.#boundHandleClick);
3509
+ figLabDisconnectPropskitResetMenu(this);
3510
+ }
3511
+
3512
+ attributeChangedCallback(name, oldValue, newValue) {
3513
+ if (oldValue === newValue || !this.#surface) return;
3514
+ if (name === "label" || name === "aria-label") {
3515
+ this.#syncSurface();
3516
+ }
3517
+ }
3518
+
3519
+ #initialize() {
3520
+ this.#initialValue = this.getAttribute("value") ?? "";
3521
+ const initialChildren = Array.from(this.childNodes).filter(
3522
+ (node) =>
3523
+ node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
3524
+ );
3525
+ const customLabel = initialChildren.find(
3526
+ (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
3527
+ );
3528
+ const surface = figLabCreateElement("div", {
3529
+ className: "propskit-text-surface",
3530
+ });
3531
+ const label = customLabel || document.createElement("label");
3532
+ const input = document.createElement("fig-input-text");
3533
+
3534
+ for (const node of initialChildren) {
3535
+ if (node !== customLabel) input.appendChild(node);
3536
+ }
3537
+ surface.append(label, input);
3538
+ this.#surface = surface;
3539
+ this.#label = label;
3540
+ this.#input = input;
3541
+ this.#hasCustomLabel = Boolean(customLabel);
3542
+ this.replaceChildren(surface);
3543
+ }
3544
+
3545
+ #syncSurface() {
3546
+ if (!this.#surface || !this.#label || !this.#input) return;
3547
+ const labelId = figLabSyncPropskitLabel(
3548
+ this,
3549
+ this.#surface,
3550
+ this.#label,
3551
+ this.#hasCustomLabel,
3552
+ );
3553
+ figLabSyncPropskitControlLabel(this, this.#input, labelId, "Text");
3554
+ }
3555
+
3556
+ #getForwardedInputAttrNames() {
3557
+ const reserved = new Set([
3558
+ "label",
3559
+ "direction",
3560
+ "oninput",
3561
+ "onchange",
3562
+ "class",
3563
+ "style",
3564
+ "id",
3565
+ "size",
3566
+ "type",
3567
+ "aria-label",
3568
+ "multiline",
3569
+ "autoresize",
3570
+ "resizable",
3571
+ "default",
3572
+ "variant",
3573
+ ]);
3574
+ return this.getAttributeNames().filter(
3575
+ (name) => !reserved.has(name) && !name.startsWith("data-"),
3576
+ );
3577
+ }
3578
+
3579
+ #syncInputAttributes() {
3580
+ if (!this.#input) return;
3581
+ const inputAttrs = this.#getForwardedInputAttrNames();
3582
+ const defaultEnabledAttrs = ["multiline", "autoresize"];
3583
+ const nextManaged = new Set([...inputAttrs, ...defaultEnabledAttrs, "type"]);
3584
+
3585
+ for (const attrName of this.#managedInputAttrs) {
3586
+ if (!nextManaged.has(attrName)) this.#input.removeAttribute(attrName);
3587
+ }
3588
+ for (const attrName of inputAttrs) {
3589
+ this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
3590
+ }
3591
+ for (const attrName of defaultEnabledAttrs) {
3592
+ this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
3593
+ }
3594
+ this.#input.setAttribute("type", "text");
3595
+
3596
+ this.#managedInputAttrs = nextManaged;
3597
+ }
3598
+
3599
+ #bindInputEvents() {
3600
+ if (!this.#input) return;
3601
+ this.#boundHandleInput ??= this.#forwardInputEvent.bind(this, "input");
3602
+ this.#boundHandleChange ??= this.#forwardInputEvent.bind(this, "change");
3603
+ this.#input.addEventListener("input", this.#boundHandleInput);
3604
+ this.#input.addEventListener("change", this.#boundHandleChange);
3605
+ }
3606
+
3607
+ #unbindInputEvents() {
3608
+ if (!this.#input) return;
3609
+ if (this.#boundHandleInput) {
3610
+ this.#input.removeEventListener("input", this.#boundHandleInput);
3611
+ }
3612
+ if (this.#boundHandleChange) {
3613
+ this.#input.removeEventListener("change", this.#boundHandleChange);
3614
+ }
3615
+ }
3616
+
3617
+ #forwardInputEvent(type, event) {
3618
+ event.stopImmediatePropagation();
3619
+ if (figLabBooleanAttribute(this, "disabled")) return;
3620
+ const value = this.#input?.value ?? "";
3621
+ this.setAttribute("value", String(value));
3622
+ figLabDispatchPropskitEvent(this, type);
3623
+ }
3624
+
3625
+ #handleClick(event) {
3626
+ if (figLabBooleanAttribute(this, "disabled")) return;
3627
+ if (
3628
+ event.target instanceof Element &&
3629
+ event.target.closest("fig-input-text, fig-menu")
3630
+ ) {
3631
+ return;
3632
+ }
3633
+ this.focus();
3634
+ }
3635
+
3636
+ get value() {
3637
+ return this.#input?.value ?? this.getAttribute("value") ?? "";
3638
+ }
3639
+
3640
+ set value(nextValue) {
3641
+ if (nextValue === null || nextValue === undefined) {
3642
+ this.removeAttribute("value");
3643
+ if (this.#input) this.#input.value = "";
3644
+ } else {
3645
+ const next = String(nextValue);
3646
+ this.setAttribute("value", next);
3647
+ if (this.#input) this.#input.value = next;
3648
+ }
2779
3649
  }
2780
3650
 
2781
3651
  get defaultValue() {
@@ -4186,6 +5056,455 @@ figLabDefineElement("propskit-easing", PropskitEasing);
4186
5056
  class PropskitSpring extends PropskitCurve {}
4187
5057
  figLabDefineElement("propskit-spring", PropskitSpring);
4188
5058
 
5059
+ /**
5060
+ * Labeled image chooser with built-in upload and per-image removal actions.
5061
+ *
5062
+ * @attr {string} options - JSON array of image URLs.
5063
+ * @attr {string} value - Selected image URL.
5064
+ * @attr {string} default - Reset image URL.
5065
+ * @attr {string} label - Surface label. Omitted values use "Label"; empty hides it.
5066
+ * @attr {boolean|string} disabled - Disables uploading and image selection.
5067
+ * @fires input - Shared PropsKit event with the selected image URL.
5068
+ * @fires change - Shared PropsKit event with the selected image URL.
5069
+ */
5070
+ class PropskitImage extends FigLabPropskitElement {
5071
+ static observedAttributes = [
5072
+ "options",
5073
+ "value",
5074
+ "default",
5075
+ "label",
5076
+ "aria-label",
5077
+ "disabled",
5078
+ ];
5079
+
5080
+ #surface = null;
5081
+ #header = null;
5082
+ #label = null;
5083
+ #hasCustomLabel = false;
5084
+ #uploadButton = null;
5085
+ #fileInput = null;
5086
+ #chooser = null;
5087
+ #chooserObserver = null;
5088
+ #initialValue = "";
5089
+ #reflecting = false;
5090
+ #blobUrls = new Set();
5091
+ #uploadLabels = new Map();
5092
+ #boundChooserInput = this.#handleChooserEvent.bind(this, "input");
5093
+ #boundChooserChange = this.#handleChooserEvent.bind(this, "change");
5094
+ #boundFileInput = (event) => event.stopImmediatePropagation();
5095
+ #boundFileChange = this.#handleFileChange.bind(this);
5096
+
5097
+ connectedCallback() {
5098
+ if (!this.#surface) {
5099
+ this.#initialValue = this.#resolveValue(this.getAttribute("value"));
5100
+ this.#reflectValue(this.#initialValue);
5101
+ this.#render();
5102
+ }
5103
+ this.#syncLabel();
5104
+ this.#syncChoices();
5105
+ this.#syncDisabled();
5106
+ this.#bindEvents();
5107
+ this.#chooserObserver?.observe(this.#chooser, { childList: true });
5108
+ this.#syncNavigationAccessibility();
5109
+ figLabConnectPropskitResetMenu(this);
5110
+ }
5111
+
5112
+ disconnectedCallback() {
5113
+ this.#unbindEvents();
5114
+ this.#chooserObserver?.disconnect();
5115
+ figLabDisconnectPropskitResetMenu(this);
5116
+ }
5117
+
5118
+ attributeChangedCallback(name, oldValue, newValue) {
5119
+ if (oldValue === newValue || !this.#surface) return;
5120
+ if (name === "options") {
5121
+ this.#releaseRemovedBlobUrls();
5122
+ this.#syncChoices();
5123
+ } else if (name === "value" && !this.#reflecting) {
5124
+ this.#reflectValue(newValue);
5125
+ } else if (name === "label" || name === "aria-label") {
5126
+ this.#syncLabel();
5127
+ } else if (name === "disabled") {
5128
+ this.#syncDisabled();
5129
+ }
5130
+ }
5131
+
5132
+ #parseOptions(value = this.getAttribute("options")) {
5133
+ let parsed = value;
5134
+ if (typeof value === "string") {
5135
+ try {
5136
+ parsed = JSON.parse(value || "[]");
5137
+ } catch {
5138
+ return [];
5139
+ }
5140
+ }
5141
+ if (!Array.isArray(parsed)) return [];
5142
+ const unique = new Set();
5143
+ for (const option of parsed) {
5144
+ if (typeof option !== "string") continue;
5145
+ const url = option.trim();
5146
+ if (url) unique.add(url);
5147
+ }
5148
+ return [...unique];
5149
+ }
5150
+
5151
+ #resolveValue(value) {
5152
+ const options = this.#parseOptions();
5153
+ const requested = String(value ?? "").trim();
5154
+ if (requested && options.includes(requested)) return requested;
5155
+ return options[0] || "";
5156
+ }
5157
+
5158
+ #reflectValue(value) {
5159
+ const resolved = this.#resolveValue(value);
5160
+ const current = this.getAttribute("value");
5161
+ if (resolved) {
5162
+ if (current !== resolved) {
5163
+ this.#reflecting = true;
5164
+ this.setAttribute("value", resolved);
5165
+ this.#reflecting = false;
5166
+ }
5167
+ } else if (current !== null) {
5168
+ this.#reflecting = true;
5169
+ this.removeAttribute("value");
5170
+ this.#reflecting = false;
5171
+ }
5172
+ if (this.#chooser) this.#chooser.value = resolved;
5173
+ return resolved;
5174
+ }
5175
+
5176
+ #render() {
5177
+ const customLabel = this.querySelector(":scope > label");
5178
+ const surface = figLabCreateElement("div", {
5179
+ className: "propskit-image-surface",
5180
+ role: "group",
5181
+ });
5182
+ const header = figLabCreateElement("div", {
5183
+ className: "propskit-image-header",
5184
+ });
5185
+ const label = customLabel || document.createElement("label");
5186
+ const uploadTooltip = figLabCreateElement("fig-tooltip", {
5187
+ className: "propskit-image-upload-tooltip",
5188
+ text: "Upload image",
5189
+ });
5190
+ const uploadButton = figLabCreateElement("fig-button", {
5191
+ className: "propskit-image-upload",
5192
+ variant: "ghost",
5193
+ type: "upload",
5194
+ icon: true,
5195
+ "aria-label": "Upload images",
5196
+ });
5197
+ const uploadIcon = figLabCreateElement("fig-icon", {
5198
+ name: "upload",
5199
+ "aria-hidden": "true",
5200
+ });
5201
+ const fileInput = figLabCreateElement("input", {
5202
+ type: "file",
5203
+ accept: "image/*",
5204
+ multiple: true,
5205
+ "aria-label": "Upload images",
5206
+ });
5207
+ const chooser = figLabCreateElement("fig-chooser", {
5208
+ className: "propskit-image-chooser",
5209
+ layout: "grid",
5210
+ columns: "2",
5211
+ overflow: "buttons",
5212
+ full: true,
5213
+ });
5214
+ uploadButton.append(uploadIcon, fileInput);
5215
+ uploadTooltip.append(uploadButton);
5216
+ header.append(label, uploadTooltip);
5217
+ surface.append(header, chooser);
5218
+ this.#surface = surface;
5219
+ this.#header = header;
5220
+ this.#label = label;
5221
+ this.#hasCustomLabel = Boolean(customLabel);
5222
+ this.#uploadButton = uploadButton;
5223
+ this.#fileInput = fileInput;
5224
+ this.#chooser = chooser;
5225
+ this.#chooserObserver = new MutationObserver(() =>
5226
+ this.#syncNavigationAccessibility(),
5227
+ );
5228
+ this.replaceChildren(surface);
5229
+ }
5230
+
5231
+ #syncLabel() {
5232
+ if (!this.#header || !this.#label || !this.#chooser || !this.#surface) {
5233
+ return;
5234
+ }
5235
+ const labelId = figLabSyncPropskitLabel(
5236
+ this,
5237
+ this.#header,
5238
+ this.#label,
5239
+ this.#hasCustomLabel,
5240
+ );
5241
+ figLabSyncPropskitControlLabel(this, this.#chooser, labelId, "Images");
5242
+ const explicitLabel = this.getAttribute("aria-label")?.trim();
5243
+ if (explicitLabel) {
5244
+ this.#surface.setAttribute("aria-label", explicitLabel);
5245
+ this.#surface.removeAttribute("aria-labelledby");
5246
+ } else if (labelId) {
5247
+ this.#surface.setAttribute("aria-labelledby", labelId);
5248
+ this.#surface.removeAttribute("aria-label");
5249
+ } else {
5250
+ this.#surface.setAttribute("aria-label", "Images");
5251
+ this.#surface.removeAttribute("aria-labelledby");
5252
+ }
5253
+ }
5254
+
5255
+ #imageLabel(url, index) {
5256
+ const uploadedLabel = this.#uploadLabels.get(url);
5257
+ if (uploadedLabel) return uploadedLabel;
5258
+ try {
5259
+ const filename = new URL(url, document.baseURI).pathname
5260
+ .split("/")
5261
+ .filter(Boolean)
5262
+ .pop();
5263
+ if (filename) return decodeURIComponent(filename);
5264
+ } catch {}
5265
+ return `Image ${index + 1}`;
5266
+ }
5267
+
5268
+ #syncChoices() {
5269
+ if (!this.#chooser) return;
5270
+ const options = this.#parseOptions();
5271
+ const selected = this.#resolveValue(this.getAttribute("value"));
5272
+ const choices = options.map((url, index) => {
5273
+ const label = this.#imageLabel(url, index);
5274
+ const choice = figLabCreateElement("fig-choice", {
5275
+ value: url,
5276
+ "aria-label": label,
5277
+ selected: url === selected,
5278
+ });
5279
+ const image = figLabCreateElement("fig-image", {
5280
+ src: url,
5281
+ alt: "",
5282
+ full: true,
5283
+ "aspect-ratio": "1 / 1",
5284
+ fit: "cover",
5285
+ });
5286
+ const removeTooltip = figLabCreateElement("fig-tooltip", {
5287
+ className: "propskit-image-remove-tooltip",
5288
+ text: "Remove image",
5289
+ });
5290
+ const removeButton = figLabCreateElement("span", {
5291
+ className: "propskit-image-remove",
5292
+ "aria-hidden": "true",
5293
+ "data-propskit-image-remove": "",
5294
+ });
5295
+ const removeIcon = figLabCreateElement("fig-icon", {
5296
+ name: "close",
5297
+ size: "small",
5298
+ "aria-hidden": "true",
5299
+ });
5300
+ removeButton.append(removeIcon);
5301
+ removeButton.addEventListener("click", (event) =>
5302
+ this.#removeOption(url, index, event),
5303
+ );
5304
+ choice.addEventListener("keydown", (event) => {
5305
+ if (event.key !== "Delete" && event.key !== "Backspace") return;
5306
+ this.#removeOption(url, index, event);
5307
+ });
5308
+ removeTooltip.append(removeButton);
5309
+ choice.dataset.propskitImageLabel = label;
5310
+ choice.append(image, removeTooltip);
5311
+ return choice;
5312
+ });
5313
+ this.#chooser.replaceChildren(...choices);
5314
+ this.#chooser.setAttribute("layout", "grid");
5315
+ this.#chooser.setAttribute("columns", "2");
5316
+ this.#chooser.setAttribute("overflow", "buttons");
5317
+ this.#chooser.toggleAttribute("hidden", choices.length === 0);
5318
+ this.#reflectValue(selected);
5319
+ this.#syncChoiceRemovalState();
5320
+ queueMicrotask(() => this.#syncNavigationAccessibility());
5321
+ }
5322
+
5323
+ #syncChoiceRemovalState() {
5324
+ const disabled = figLabBooleanAttribute(this, "disabled");
5325
+ for (const choice of this.#chooser?.querySelectorAll(
5326
+ ":scope > fig-choice",
5327
+ ) || []) {
5328
+ if (disabled) {
5329
+ choice.removeAttribute("aria-description");
5330
+ choice.removeAttribute("aria-keyshortcuts");
5331
+ } else {
5332
+ choice.setAttribute(
5333
+ "aria-description",
5334
+ "Press Delete or Backspace to remove this image.",
5335
+ );
5336
+ choice.setAttribute("aria-keyshortcuts", "Delete Backspace");
5337
+ }
5338
+ const removeButton = choice.querySelector(
5339
+ ":scope > .propskit-image-remove-tooltip > .propskit-image-remove",
5340
+ );
5341
+ removeButton?.setAttribute("aria-hidden", "true");
5342
+ }
5343
+ }
5344
+
5345
+ #syncNavigationAccessibility() {
5346
+ for (const button of this.#chooser?.querySelectorAll(
5347
+ ":scope > [data-fig-chooser-nav]",
5348
+ ) || []) {
5349
+ button.setAttribute("aria-hidden", "true");
5350
+ }
5351
+ }
5352
+
5353
+ #syncDisabled() {
5354
+ if (
5355
+ !this.#surface ||
5356
+ !this.#uploadButton ||
5357
+ !this.#fileInput ||
5358
+ !this.#chooser
5359
+ ) {
5360
+ return;
5361
+ }
5362
+ const disabled = figLabBooleanAttribute(this, "disabled");
5363
+ this.#surface.setAttribute("aria-disabled", String(disabled));
5364
+ this.#uploadButton.toggleAttribute("disabled", disabled);
5365
+ this.#fileInput.disabled = disabled;
5366
+ this.#chooser.toggleAttribute("disabled", disabled);
5367
+ this.#chooser.inert = disabled;
5368
+ this.#syncChoiceRemovalState();
5369
+ }
5370
+
5371
+ #bindEvents() {
5372
+ this.#unbindEvents();
5373
+ this.#chooser?.addEventListener("input", this.#boundChooserInput);
5374
+ this.#chooser?.addEventListener("change", this.#boundChooserChange);
5375
+ this.#fileInput?.addEventListener("input", this.#boundFileInput);
5376
+ this.#fileInput?.addEventListener("change", this.#boundFileChange);
5377
+ }
5378
+
5379
+ #unbindEvents() {
5380
+ this.#chooser?.removeEventListener("input", this.#boundChooserInput);
5381
+ this.#chooser?.removeEventListener("change", this.#boundChooserChange);
5382
+ this.#fileInput?.removeEventListener("input", this.#boundFileInput);
5383
+ this.#fileInput?.removeEventListener("change", this.#boundFileChange);
5384
+ }
5385
+
5386
+ #handleChooserEvent(type, event) {
5387
+ if (event.target !== this.#chooser) return;
5388
+ event.stopImmediatePropagation();
5389
+ if (figLabBooleanAttribute(this, "disabled")) return;
5390
+ const value = this.#reflectValue(this.#chooser.value);
5391
+ figLabDispatchPropskitEvent(this, type, value);
5392
+ }
5393
+
5394
+ #handleFileChange(event) {
5395
+ if (event.target !== this.#fileInput) return;
5396
+ event.stopImmediatePropagation();
5397
+ if (figLabBooleanAttribute(this, "disabled")) return;
5398
+ const files = [...(this.#fileInput.files || [])].filter(
5399
+ (file) => !file.type || file.type.startsWith("image/"),
5400
+ );
5401
+ if (!files.length) return;
5402
+ const urls = files.map((file) => {
5403
+ const url = URL.createObjectURL(file);
5404
+ this.#blobUrls.add(url);
5405
+ this.#uploadLabels.set(url, file.name);
5406
+ return url;
5407
+ });
5408
+ this.options = [...this.options, ...urls];
5409
+ this.value = urls[0];
5410
+ this.#fileInput.value = "";
5411
+ for (const type of ["input", "change"]) {
5412
+ figLabDispatchPropskitEvent(this, type, this.value);
5413
+ }
5414
+ }
5415
+
5416
+ #removeOption(url, index, event) {
5417
+ event.preventDefault();
5418
+ event.stopImmediatePropagation();
5419
+ if (figLabBooleanAttribute(this, "disabled")) return;
5420
+ const options = this.options;
5421
+ if (!options.includes(url)) return;
5422
+ const previousValue = this.value;
5423
+ const removedSelected = previousValue === url;
5424
+ this.options = options.filter((option) => option !== url);
5425
+ const value = this.value;
5426
+ if (value !== previousValue) {
5427
+ for (const type of ["input", "change"]) {
5428
+ figLabDispatchPropskitEvent(this, type, value);
5429
+ }
5430
+ }
5431
+ queueMicrotask(() => {
5432
+ const choices = [
5433
+ ...(this.#chooser?.querySelectorAll(":scope > fig-choice") || []),
5434
+ ];
5435
+ const target = removedSelected
5436
+ ? this.#chooser?.selectedChoice
5437
+ : choices[Math.min(index, choices.length - 1)];
5438
+ if (target instanceof HTMLElement) {
5439
+ target.focus();
5440
+ } else {
5441
+ this.#fileInput?.focus();
5442
+ }
5443
+ });
5444
+ }
5445
+
5446
+ #releaseRemovedBlobUrls() {
5447
+ const options = new Set(this.#parseOptions());
5448
+ for (const url of this.#blobUrls) {
5449
+ if (options.has(url)) continue;
5450
+ URL.revokeObjectURL(url);
5451
+ this.#blobUrls.delete(url);
5452
+ this.#uploadLabels.delete(url);
5453
+ }
5454
+ }
5455
+
5456
+ get options() {
5457
+ return this.#parseOptions();
5458
+ }
5459
+
5460
+ set options(value) {
5461
+ if (value === null || value === undefined) {
5462
+ this.removeAttribute("options");
5463
+ return;
5464
+ }
5465
+ this.setAttribute("options", JSON.stringify(this.#parseOptions(value)));
5466
+ }
5467
+
5468
+ get value() {
5469
+ return this.#resolveValue(
5470
+ this.#chooser?.value ?? this.getAttribute("value"),
5471
+ );
5472
+ }
5473
+
5474
+ set value(value) {
5475
+ this.#reflectValue(value);
5476
+ }
5477
+
5478
+ get defaultValue() {
5479
+ const fallback = this.#initialValue || this.#resolveValue(null);
5480
+ return this.#resolveValue(
5481
+ this.hasAttribute("default") ? this.getAttribute("default") : fallback,
5482
+ );
5483
+ }
5484
+
5485
+ get isDefault() {
5486
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
5487
+ }
5488
+
5489
+ resetToDefault() {
5490
+ this.value = this.defaultValue;
5491
+ figLabEmitPropskitReset(this);
5492
+ }
5493
+
5494
+ focus(options) {
5495
+ if (figLabBooleanAttribute(this, "disabled")) return;
5496
+ const choice =
5497
+ this.#chooser?.selectedChoice ||
5498
+ this.#chooser?.querySelector("fig-choice");
5499
+ if (choice instanceof HTMLElement) {
5500
+ choice.focus(options);
5501
+ } else {
5502
+ this.#fileInput?.focus(options);
5503
+ }
5504
+ }
5505
+ }
5506
+ figLabDefineElement("propskit-image", PropskitImage);
5507
+
4189
5508
  /**
4190
5509
  * Collapsible color-point group composed from color and position controls.
4191
5510
  *
@@ -5330,6 +6649,7 @@ class PropskitGroup extends FigLabPropskitElement {
5330
6649
  "propskit-fill",
5331
6650
  "propskit-gradient",
5332
6651
  "propskit-easing",
6652
+ "propskit-image",
5333
6653
  "propskit-joystick",
5334
6654
  "propskit-number",
5335
6655
  "propskit-origin",
@@ -5338,6 +6658,7 @@ class PropskitGroup extends FigLabPropskitElement {
5338
6658
  "propskit-point-radius",
5339
6659
  "propskit-point-radius-angle",
5340
6660
  "propskit-point-point",
6661
+ "propskit-editable-select",
5341
6662
  "propskit-select",
5342
6663
  "propskit-slider",
5343
6664
  "propskit-spring",
@@ -11614,13 +12935,16 @@ class FigReorder extends HTMLElement {
11614
12935
  "fig-input-wheel",
11615
12936
  "fig-joystick",
11616
12937
  "fig-origin-grid",
12938
+ "fig-chooser",
11617
12939
  "fig-canvas-control",
11618
12940
  "propskit-color-point",
12941
+ "propskit-image",
11619
12942
  "propskit-number",
11620
12943
  "propskit-point-point",
11621
12944
  "propskit-point-radius",
11622
12945
  "propskit-point-radius-angle",
11623
12946
  "propskit-position",
12947
+ "propskit-editable-select",
11624
12948
  "propskit-slider",
11625
12949
  "propskit-oscillator",
11626
12950
  ];