@jxsuite/studio 0.0.1 → 0.5.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.
Files changed (38) hide show
  1. package/dist/studio.js +47638 -33445
  2. package/dist/studio.js.map +449 -344
  3. package/package.json +44 -33
  4. package/src/browse/browse.js +414 -0
  5. package/src/editor/context-menu.js +48 -1
  6. package/src/editor/convert-to-component.js +208 -0
  7. package/src/editor/inline-edit.js +33 -6
  8. package/src/editor/shortcuts.js +6 -1
  9. package/src/files/components.js +4 -2
  10. package/src/files/file-ops.js +102 -54
  11. package/src/files/files.js +22 -8
  12. package/src/markdown/md-convert.js +309 -11
  13. package/src/panels/activity-bar.js +3 -0
  14. package/src/panels/head-panel.js +576 -0
  15. package/src/panels/overlays.js +125 -0
  16. package/src/panels/right-panel.js +104 -0
  17. package/src/panels/shared.js +41 -0
  18. package/src/panels/signals-panel.js +95 -94
  19. package/src/panels/toolbar.js +217 -0
  20. package/src/platforms/devserver.js +58 -16
  21. package/src/settings/collections-editor.js +428 -0
  22. package/src/settings/defs-editor.js +418 -0
  23. package/src/settings/schema-field-ui.js +329 -0
  24. package/src/state.js +99 -2
  25. package/src/store.js +77 -41
  26. package/src/studio.js +1523 -1375
  27. package/src/ui/button-group.js +91 -0
  28. package/src/ui/color-selector.js +299 -0
  29. package/src/ui/field-row.js +47 -0
  30. package/src/ui/media-picker.js +172 -0
  31. package/src/ui/panel-resize.js +96 -0
  32. package/src/ui/spectrum.js +36 -2
  33. package/src/ui/unit-selector.js +106 -0
  34. package/src/ui/{jx-styled-combobox.js → value-selector.js} +7 -7
  35. package/src/ui/widgets.js +106 -0
  36. package/src/utils/inherited-style.js +54 -0
  37. package/src/utils/studio-utils.js +32 -0
  38. package/src/view.js +45 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Button-group.js — Action group + overflow picker widget.
3
+ *
4
+ * Renders a compact button group for enum values with an optional overflow picker for additional
5
+ * options that don't fit in the button bar.
6
+ */
7
+
8
+ import { html, nothing } from "lit-html";
9
+ import { abbreviateValue, kebabToLabel } from "../utils/studio-utils.js";
10
+ import icons from "./icons.js";
11
+
12
+ /**
13
+ * Render a button group widget with optional overflow menu.
14
+ *
15
+ * @param {any} entry — css-meta entry with $buttonValues, enum, $icons
16
+ * @param {string} prop — property key (for menu ID namespace)
17
+ * @param {any} value — current value
18
+ * @param {(val: string) => void} onChange — commit callback
19
+ * @returns {any}
20
+ */
21
+ export function renderButtonGroup(
22
+ /** @type {any} */ entry,
23
+ /** @type {any} */ prop,
24
+ /** @type {any} */ value,
25
+ /** @type {any} */ onChange,
26
+ ) {
27
+ const values = entry.$buttonValues || entry.enum || [];
28
+ /** @type {Record<string, any>} */
29
+ const iconMap = entry.$icons || {};
30
+ const extra =
31
+ entry.$buttonValues && entry.enum && entry.enum.length > entry.$buttonValues.length
32
+ ? entry.enum.filter((/** @type {any} */ v) => !entry.$buttonValues.includes(v))
33
+ : [];
34
+
35
+ const menuId = `style-btngrp-${prop}`;
36
+ const hasExtra = extra.length > 0;
37
+ const extraSelected = hasExtra && extra.includes(value);
38
+
39
+ return html`
40
+ <div class="button-group-combo ${hasExtra ? "has-overflow" : ""}">
41
+ <sp-action-group size="s" compact>
42
+ ${values.map(
43
+ (/** @type {any} */ v) => html`
44
+ <sp-action-button
45
+ size="s"
46
+ value=${v}
47
+ title=${v}
48
+ ?selected=${v === value}
49
+ @click=${() => onChange(v === value ? "" : v)}
50
+ >
51
+ ${
52
+ /** @type {any} */ (iconMap)[v] &&
53
+ /** @type {any} */ (icons)[/** @type {any} */ (iconMap)[v]]
54
+ ? /** @type {any} */ (icons)[/** @type {any} */ (iconMap)[v]]
55
+ : abbreviateValue(v)
56
+ }
57
+ </sp-action-button>
58
+ `,
59
+ )}
60
+ </sp-action-group>
61
+ ${hasExtra
62
+ ? html`
63
+ <sp-picker-button
64
+ size="s"
65
+ id=${menuId}
66
+ class=${extraSelected ? "has-selection" : ""}
67
+ ></sp-picker-button>
68
+ <sp-overlay trigger="${menuId}@click" placement="bottom-end" type="auto">
69
+ <sp-popover>
70
+ <sp-menu
71
+ @change=${(/** @type {any} */ e) => {
72
+ if (e.target.value) onChange(e.target.value);
73
+ }}
74
+ >
75
+ <sp-menu-item value="__none__">—</sp-menu-item>
76
+ ${extra.map((/** @type {any} */ v) => {
77
+ const label = v.includes("-")
78
+ ? kebabToLabel(v)
79
+ : v.replace(/^./, (/** @type {any} */ c) => c.toUpperCase());
80
+ return html`<sp-menu-item value=${v} ?selected=${v === value}
81
+ >${label}</sp-menu-item
82
+ >`;
83
+ })}
84
+ </sp-menu>
85
+ </sp-popover>
86
+ </sp-overlay>
87
+ `
88
+ : nothing}
89
+ </div>
90
+ `;
91
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Color-selector.js — Color input widget with swatch, text field, and popover picker.
3
+ *
4
+ * Uses sp-overlay trigger pattern for positioning (same as unit-selector and value-selector). The
5
+ * popover content is a JxColorPopover LitElement that reactively syncs color between the area,
6
+ * slider, and text field via a single `color` property.
7
+ *
8
+ * When the value is a var(--color-*) reference matching a defined color variable, the input
9
+ * switches to picker mode showing the title-cased variable name (e.g. "Primary Blue") with a
10
+ * swatch, similar to jx-value-selector's dual-mode behavior.
11
+ */
12
+
13
+ import { LitElement, html, nothing } from "lit";
14
+ import { html as litHtml } from "lit-html";
15
+ import { live } from "lit-html/directives/live.js";
16
+ import { getState, debouncedStyleCommit } from "../store.js";
17
+ import { getEffectiveStyle } from "../site-context.js";
18
+ import { kebabToLabel } from "../utils/studio-utils.js";
19
+
20
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
21
+
22
+ /** Extract --color-* CSS custom properties from the effective (site + document) style. */
23
+ function getColorVars() {
24
+ const S = getState();
25
+ const style = getEffectiveStyle(S?.document?.style);
26
+ if (!style) return [];
27
+ const vars = [];
28
+ for (const [k, v] of Object.entries(style)) {
29
+ if (k.startsWith("--color") && (typeof v === "string" || typeof v === "number")) {
30
+ vars.push({ name: k, value: String(v) });
31
+ }
32
+ }
33
+ return vars;
34
+ }
35
+
36
+ /**
37
+ * Convert a color variable name to a title-case label. Strips the "--color-" prefix and converts
38
+ * kebab to title case. e.g. "--color-primary-blue" → "Primary Blue"
39
+ */
40
+ function varToLabel(/** @type {string} */ name) {
41
+ return kebabToLabel(name.replace(/^--color-?/, ""));
42
+ }
43
+
44
+ /** Resolve a color value for display — if it's a var() reference, look up the actual color. */
45
+ function resolveColorForDisplay(/** @type {any} */ val) {
46
+ if (!val) return "transparent";
47
+ const m = val.match(/^var\((--[^)]+)\)$/);
48
+ if (m) {
49
+ const S = getState();
50
+ const style = getEffectiveStyle(S?.document?.style);
51
+ const resolved = style?.[m[1]];
52
+ if (typeof resolved === "string") return resolved;
53
+ return "transparent";
54
+ }
55
+ return val;
56
+ }
57
+
58
+ function safeColor(/** @type {any} */ val) {
59
+ if (!val) return "transparent";
60
+ return resolveColorForDisplay(val);
61
+ }
62
+
63
+ /** Normalize a color string to include # prefix for hex values. */
64
+ function normalizeHex(/** @type {string} */ c) {
65
+ if (!c) return c;
66
+ if (c.startsWith("var(") || c.startsWith("rgb") || c.startsWith("hsl")) return c;
67
+ return c.replace(/^#?/, "#");
68
+ }
69
+
70
+ /**
71
+ * Check if a value is a var() reference that matches a defined color variable.
72
+ *
73
+ * @param {any} value
74
+ * @param {{ name: string; value: string }[]} colorVars
75
+ */
76
+ function matchesColorVar(value, colorVars) {
77
+ if (!value || typeof value !== "string") return null;
78
+ const m = value.match(/^var\((--[^)]+)\)$/);
79
+ if (!m) return null;
80
+ return colorVars.find((cv) => cv.name === m[1]) || null;
81
+ }
82
+
83
+ // ─── JxColorPopover LitElement ──────────────────────────────────────────────
84
+
85
+ /** @typedef {{ name: string; value: string }} ColorVar */
86
+
87
+ export class JxColorPopover extends LitElement {
88
+ static properties = {
89
+ color: { type: String },
90
+ displayColor: { type: String, attribute: false },
91
+ colorVars: { attribute: false },
92
+ };
93
+
94
+ constructor() {
95
+ super();
96
+ /** @type {string} */ this.color = "";
97
+ /** @type {string} */ this.displayColor = "#000000";
98
+ /** @type {ColorVar[]} */ this.colorVars = [];
99
+ }
100
+
101
+ /** No shadow DOM — render directly into light DOM for Spectrum theming */
102
+ createRenderRoot() {
103
+ return this;
104
+ }
105
+
106
+ /** @param {Map<string, any>} changed */
107
+ willUpdate(changed) {
108
+ if (changed.has("color")) {
109
+ const raw = resolveColorForDisplay(this.color);
110
+ if (!raw || raw === "transparent") {
111
+ this.displayColor = "#000000";
112
+ } else if (raw.startsWith("#") || raw.startsWith("rgb") || raw.startsWith("hsl")) {
113
+ this.displayColor = raw;
114
+ } else {
115
+ this.displayColor = `#${raw}`;
116
+ }
117
+ }
118
+ }
119
+
120
+ _handleArea(/** @type {any} */ e) {
121
+ const color = normalizeHex(String(e.target.color));
122
+ this.displayColor = color;
123
+ this.color = color;
124
+ this.dispatchEvent(new CustomEvent("color-change", { detail: color, bubbles: true }));
125
+ }
126
+
127
+ _handleSlider(/** @type {any} */ e) {
128
+ const color = normalizeHex(String(e.target.color));
129
+ this.displayColor = color;
130
+ this.color = color;
131
+ this.dispatchEvent(new CustomEvent("color-change", { detail: color, bubbles: true }));
132
+ }
133
+
134
+ _handleText(/** @type {any} */ e) {
135
+ const val = e.target.value.trim();
136
+ if (!val) return;
137
+ this.displayColor = val;
138
+ this.color = val;
139
+ this.dispatchEvent(new CustomEvent("color-change", { detail: val, bubbles: true }));
140
+ }
141
+
142
+ _handleSwatch(/** @type {any} */ e, /** @type {string} */ varName) {
143
+ e.stopPropagation();
144
+ const varRef = `var(${varName})`;
145
+ this.color = varRef;
146
+ this.dispatchEvent(new CustomEvent("color-change", { detail: varRef, bubbles: true }));
147
+ }
148
+
149
+ render() {
150
+ return html`
151
+ <div class="color-popover-inner">
152
+ <sp-color-area
153
+ style="width:200px; height:150px; --mod-colorarea-width:200px; --mod-colorarea-height:150px"
154
+ .color=${this.displayColor}
155
+ @input=${this._handleArea}
156
+ ></sp-color-area>
157
+ <sp-color-slider
158
+ style="width:200px; --mod-colorslider-length:200px"
159
+ .color=${this.displayColor}
160
+ @input=${this._handleSlider}
161
+ ></sp-color-slider>
162
+ <sp-textfield
163
+ size="s"
164
+ style="width:200px"
165
+ .value=${live(this.color || "")}
166
+ placeholder="#000000"
167
+ @change=${this._handleText}
168
+ ></sp-textfield>
169
+ ${this.colorVars.length > 0
170
+ ? html`
171
+ <sp-divider size="s"></sp-divider>
172
+ <span class="color-popover-swatches-label">Color Tokens</span>
173
+ <sp-swatch-group size="xs" border="light" rounding="none">
174
+ ${this.colorVars.map(
175
+ (cv) => html`
176
+ <sp-swatch
177
+ color=${cv.value}
178
+ .value=${cv.name}
179
+ title=${cv.name}
180
+ @click=${(/** @type {any} */ e) => this._handleSwatch(e, cv.name)}
181
+ ></sp-swatch>
182
+ `,
183
+ )}
184
+ </sp-swatch-group>
185
+ `
186
+ : nothing}
187
+ </div>
188
+ `;
189
+ }
190
+ }
191
+
192
+ // ─── Color input widget ─────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Render a color selector: swatch + text field + overlay popover. Uses sp-overlay trigger pattern
196
+ * (same as unit-selector and value-selector).
197
+ *
198
+ * When value is a var(--color-*) matching a defined variable, switches to picker mode showing
199
+ * title-cased label with swatch (e.g. "Primary Blue").
200
+ *
201
+ * @param {string} prop — property key (for debounce namespace)
202
+ * @param {any} value — current color value
203
+ * @param {(color: string) => void} onChange — commit callback
204
+ * @returns {any}
205
+ */
206
+ export function renderColorSelector(
207
+ /** @type {any} */ prop,
208
+ /** @type {any} */ value,
209
+ /** @type {any} */ onChange,
210
+ ) {
211
+ const colorVars = getColorVars();
212
+ const matchedVar = matchesColorVar(value, colorVars);
213
+ const triggerId = `color-trigger-${prop}`;
214
+ const pickerTriggerId = `color-picker-${prop}`;
215
+
216
+ // ─── Picker mode: value matches a defined color variable ───
217
+ if (matchedVar) {
218
+ return litHtml`
219
+ <div class="style-input-color">
220
+ <sp-swatch
221
+ size="s"
222
+ rounding="none"
223
+ border="light"
224
+ color=${matchedVar.value}
225
+ id=${triggerId}
226
+ ></sp-swatch>
227
+ <sp-overlay trigger="${triggerId}@click" placement="bottom-start" type="auto">
228
+ <sp-popover style="padding:12px">
229
+ <jx-color-popover
230
+ .color=${value || ""}
231
+ .colorVars=${colorVars}
232
+ @color-change=${(/** @type {CustomEvent} */ e) => onChange(e.detail)}
233
+ ></jx-color-popover>
234
+ </sp-popover>
235
+ </sp-overlay>
236
+ <sp-picker
237
+ id=${pickerTriggerId}
238
+ size="s"
239
+ style="flex:1; min-width:0"
240
+ .value=${`var(${matchedVar.name})`}
241
+ @change=${(/** @type {any} */ e) => {
242
+ e.stopPropagation();
243
+ onChange(e.target.value);
244
+ }}
245
+ >
246
+ ${colorVars.map(
247
+ (cv) => litHtml`
248
+ <sp-menu-item value=${`var(${cv.name})`}>
249
+ <sp-swatch
250
+ slot="icon"
251
+ size="xs"
252
+ rounding="none"
253
+ border="light"
254
+ color=${cv.value}
255
+ ></sp-swatch>
256
+ ${varToLabel(cv.name)}
257
+ </sp-menu-item>
258
+ `,
259
+ )}
260
+ </sp-picker>
261
+ </div>
262
+ `;
263
+ }
264
+
265
+ // ─── Text mode: custom color value or empty ───
266
+ return litHtml`
267
+ <div class="style-input-color" id=${triggerId}>
268
+ <sp-swatch
269
+ size="s"
270
+ rounding="none"
271
+ border="light"
272
+ color=${safeColor(value)}
273
+ ></sp-swatch>
274
+ <sp-textfield
275
+ size="s"
276
+ style="flex:1; min-width:0"
277
+ .value=${live(value || "")}
278
+ @click=${(/** @type {Event} */ e) => e.stopPropagation()}
279
+ @input=${debouncedStyleCommit(`color:${prop}`, 400, (/** @type {any} */ e) => {
280
+ onChange(e.target.value.trim());
281
+ })}
282
+ ></sp-textfield>
283
+ <sp-overlay trigger="${triggerId}@click" placement="bottom-start" type="auto">
284
+ <sp-popover style="padding:12px">
285
+ <jx-color-popover
286
+ .color=${value || ""}
287
+ .colorVars=${colorVars}
288
+ @color-change=${(/** @type {CustomEvent} */ e) => onChange(e.detail)}
289
+ ></jx-color-popover>
290
+ </sp-popover>
291
+ </sp-overlay>
292
+ </div>
293
+ `;
294
+ }
295
+
296
+ /** Whether any color popover is currently open. */
297
+ export function isColorPopoverOpen() {
298
+ return !!document.querySelector(".style-input-color sp-overlay[open]");
299
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Field-row.js — Universal field row layout for all Studio panels.
3
+ *
4
+ * Renders the consistent pattern: indicator dot + label + widget slot. Used by style panel,
5
+ * attributes panel, frontmatter panel, signals panel, etc.
6
+ */
7
+
8
+ import { html, nothing } from "lit-html";
9
+
10
+ /**
11
+ * Render a universal field row with indicator dot, label, and widget.
12
+ *
13
+ * @param {{
14
+ * prop: string;
15
+ * label: string;
16
+ * hasValue: boolean;
17
+ * onClear?: () => void;
18
+ * widget: any;
19
+ * span?: number;
20
+ * warning?: boolean;
21
+ * }} opts
22
+ * @returns {any}
23
+ */
24
+ export function renderFieldRow({ prop, label, hasValue, onClear, widget, span, warning }) {
25
+ return html`
26
+ <div
27
+ class=${"style-row" + (warning ? " style-row--warning" : "")}
28
+ data-prop=${prop}
29
+ style=${span === 2 ? "grid-column: 1 / -1" : ""}
30
+ >
31
+ <div class="style-row-label">
32
+ ${hasValue && onClear
33
+ ? html`<span
34
+ class="set-dot"
35
+ title="Clear ${prop}"
36
+ @click=${(/** @type {any} */ e) => {
37
+ e.stopPropagation();
38
+ onClear();
39
+ }}
40
+ ></span>`
41
+ : nothing}
42
+ <sp-field-label size="s" title=${prop}>${label}</sp-field-label>
43
+ </div>
44
+ ${widget}
45
+ </div>
46
+ `;
47
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Media Picker — combobox-style widget for selecting project media files.
3
+ *
4
+ * Shows an editable text input for manual URL entry combined with a dropdown of available media
5
+ * files from the project's public/ directory, with thumbnail previews for images.
6
+ */
7
+
8
+ import { html, nothing } from "lit-html";
9
+ import { live } from "lit-html/directives/live.js";
10
+ import { getPlatform } from "../platform.js";
11
+ import { debouncedStyleCommit } from "../store.js";
12
+
13
+ // ─── Media file cache ────────────────────────────────────────────────────────
14
+
15
+ const IMAGE_EXTENSIONS = new Set([
16
+ ".jpg",
17
+ ".jpeg",
18
+ ".png",
19
+ ".gif",
20
+ ".svg",
21
+ ".webp",
22
+ ".avif",
23
+ ".ico",
24
+ ]);
25
+
26
+ const MEDIA_EXTENSIONS = new Set([
27
+ ...IMAGE_EXTENSIONS,
28
+ ".mp4",
29
+ ".webm",
30
+ ".mp3",
31
+ ".wav",
32
+ ".ogg",
33
+ ".pdf",
34
+ ]);
35
+
36
+ /** @type {{ path: string; name: string; isImage: boolean }[]} */
37
+ let mediaCache = [];
38
+ let mediaCacheLoaded = false;
39
+
40
+ /**
41
+ * Recursively collect media files from a directory.
42
+ *
43
+ * @param {string} dir
44
+ * @param {ReturnType<typeof getPlatform>} platform
45
+ * @returns {Promise<{ path: string; name: string; isImage: boolean }[]>}
46
+ */
47
+ async function collectMedia(dir, platform) {
48
+ /** @type {{ path: string; name: string; isImage: boolean }[]} */
49
+ const results = [];
50
+ try {
51
+ const entries = await platform.listDirectory(dir);
52
+ for (const entry of entries) {
53
+ if (entry.type === "directory") {
54
+ const sub = await collectMedia(entry.path, platform);
55
+ results.push(...sub);
56
+ } else {
57
+ const dot = entry.name.lastIndexOf(".");
58
+ const ext = dot > 0 ? entry.name.slice(dot).toLowerCase() : "";
59
+ if (MEDIA_EXTENSIONS.has(ext)) {
60
+ results.push({
61
+ path: `/${entry.path}`,
62
+ name: entry.name,
63
+ isImage: IMAGE_EXTENSIONS.has(ext),
64
+ });
65
+ }
66
+ }
67
+ }
68
+ } catch {
69
+ // Directory may not exist
70
+ }
71
+ return results;
72
+ }
73
+
74
+ async function loadMediaCache() {
75
+ if (mediaCacheLoaded) return;
76
+ const platform = getPlatform();
77
+ mediaCache = await collectMedia("public", platform);
78
+ // Strip "public/" prefix so paths match production (public/ contents served at root)
79
+ for (const m of mediaCache) {
80
+ m.path = m.path.replace(/^\/public\//, "/");
81
+ }
82
+ mediaCacheLoaded = true;
83
+ }
84
+
85
+ /** Force media cache reload (e.g. after upload). */
86
+ export function invalidateMediaCache() {
87
+ mediaCache = [];
88
+ mediaCacheLoaded = false;
89
+ }
90
+
91
+ // ─── Render ──────────────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Render the media picker widget for src-type attributes.
95
+ *
96
+ * @param {string} prop — attribute name (e.g. "src")
97
+ * @param {any} value — current attribute value
98
+ * @param {(val: any) => void} onCommit — commit callback
99
+ * @returns {any}
100
+ */
101
+ export function renderMediaPicker(prop, value, onCommit) {
102
+ // Kick off async load (won't block render)
103
+ loadMediaCache();
104
+
105
+ const currentValue = value || "";
106
+ const isImage = IMAGE_EXTENSIONS.has(
107
+ currentValue.slice(currentValue.lastIndexOf(".")).toLowerCase(),
108
+ );
109
+
110
+ // Filter media options based on current input
111
+ const query = currentValue.toLowerCase();
112
+ const filtered = query
113
+ ? mediaCache.filter(
114
+ (m) => m.path.toLowerCase().includes(query) || m.name.toLowerCase().includes(query),
115
+ )
116
+ : mediaCache;
117
+
118
+ // Limit displayed options
119
+ const options = filtered.slice(0, 20);
120
+
121
+ return html`
122
+ <div class="media-picker">
123
+ ${isImage && currentValue
124
+ ? html`<img class="media-picker-thumb" src=${currentValue} alt="" />`
125
+ : nothing}
126
+ <sp-textfield
127
+ size="s"
128
+ placeholder="/image.jpg"
129
+ .value=${live(currentValue)}
130
+ @input=${debouncedStyleCommit(`media:${prop}`, 400, (/** @type {any} */ e) =>
131
+ onCommit(e.target.value),
132
+ )}
133
+ @focus=${() => loadMediaCache()}
134
+ ></sp-textfield>
135
+ ${mediaCache.length > 0
136
+ ? html`
137
+ <overlay-trigger placement="bottom-end">
138
+ <sp-action-button size="xs" quiet slot="trigger" title="Browse media">
139
+ <sp-icon-image slot="icon"></sp-icon-image>
140
+ </sp-action-button>
141
+ <sp-popover slot="click-content" class="media-picker-popover">
142
+ <sp-menu
143
+ @change=${(/** @type {any} */ e) => {
144
+ onCommit(e.target.value);
145
+ }}
146
+ >
147
+ ${options.map(
148
+ (m) => html`
149
+ <sp-menu-item value=${m.path}>
150
+ ${m.isImage
151
+ ? html`<img
152
+ slot="icon"
153
+ src=${m.path}
154
+ alt=""
155
+ style="width:24px;height:24px;object-fit:cover;border-radius:2px"
156
+ />`
157
+ : nothing}
158
+ ${m.name}
159
+ </sp-menu-item>
160
+ `,
161
+ )}
162
+ ${filtered.length > 20
163
+ ? html`<sp-menu-item disabled>...${filtered.length - 20} more</sp-menu-item>`
164
+ : nothing}
165
+ </sp-menu>
166
+ </sp-popover>
167
+ </overlay-trigger>
168
+ `
169
+ : nothing}
170
+ </div>
171
+ `;
172
+ }