@jxsuite/studio 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,651 @@
1
+ /**
2
+ * Style panel — CSS property editor with media breakpoint tabs, selector dropdown, section
3
+ * accordion, shorthand expand/compress, and filter.
4
+ */
5
+
6
+ import { html, nothing } from "lit-html";
7
+ import { live } from "lit-html/directives/live.js";
8
+ import { ifDefined } from "lit-html/directives/if-defined.js";
9
+ import {
10
+ getState,
11
+ update,
12
+ updateUi,
13
+ getNodeAtPath,
14
+ updateStyle,
15
+ updateMediaStyle,
16
+ updateNestedStyle,
17
+ updateMediaNestedStyle,
18
+ COMMON_SELECTORS,
19
+ isNestedSelector,
20
+ debouncedStyleCommit,
21
+ } from "../store.js";
22
+ import { inferInputType, propLabel } from "../utils/studio-utils.js";
23
+ import { renderFieldRow } from "../ui/field-row.js";
24
+ import { parseMediaEntries } from "../utils/canvas-media.js";
25
+ import { getEffectiveMedia } from "../site-context.js";
26
+ import { computeInheritedStyle } from "../utils/inherited-style.js";
27
+ import { mediaDisplayName } from "./shared.js";
28
+ import {
29
+ cssMeta,
30
+ getCssInitialMap,
31
+ allConditionsPass,
32
+ autoOpenSections,
33
+ getLonghands,
34
+ expandShorthand,
35
+ compressShorthand,
36
+ expandBorderSide,
37
+ compressBorderSide,
38
+ } from "./style-utils.js";
39
+ import { widgetForType } from "./style-inputs.js";
40
+
41
+ // ─── Row renderers ──────────────────────────────────────────────────────────
42
+
43
+ function renderStyleRow(
44
+ /** @type {any} */ entry,
45
+ /** @type {any} */ prop,
46
+ /** @type {any} */ value,
47
+ /** @type {any} */ onCommit,
48
+ /** @type {any} */ onDelete,
49
+ /** @type {any} */ isWarning,
50
+ /** @type {any} */ gridMode,
51
+ /** @type {any} */ inheritedValue,
52
+ ) {
53
+ const type = inferInputType(entry);
54
+ const hasVal = value !== undefined && value !== "";
55
+ const placeholder = !hasVal && inheritedValue ? String(inheritedValue) : "";
56
+ return renderFieldRow({
57
+ prop,
58
+ label: propLabel(entry, prop),
59
+ hasValue: hasVal,
60
+ onClear: onDelete,
61
+ widget: widgetForType(type, entry, prop, value, onCommit, { placeholder }),
62
+ span: gridMode && entry.$span === 2 ? 2 : undefined,
63
+ warning: isWarning,
64
+ });
65
+ }
66
+
67
+ /**
68
+ * @param {any} shortProp @param {any} entry @param {any} style @param {any} commitFn
69
+ * @param {any} _deleteFn @param {Record<string, any>} inherited
70
+ */
71
+ function renderShorthandRow(shortProp, entry, style, commitFn, _deleteFn, inherited = {}) {
72
+ const S = getState();
73
+ const longhands = getLonghands(shortProp);
74
+ const shortVal = style[shortProp];
75
+ const hasLonghands = longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
76
+ const isExpanded = S.ui.styleShorthands[shortProp] ?? hasLonghands;
77
+ const hasAnyVal =
78
+ shortVal !== undefined || longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
79
+
80
+ return html`
81
+ <div class="style-row" data-prop=${shortProp}>
82
+ <div class="style-row-label">
83
+ ${hasAnyVal
84
+ ? html`<span
85
+ class="set-dot"
86
+ title="Clear ${shortProp}"
87
+ @click=${(/** @type {any} */ e) => {
88
+ e.stopPropagation();
89
+ let s = getState();
90
+ if (shortVal !== undefined) s = commitFn(s, shortProp, undefined);
91
+ for (const l of longhands) {
92
+ if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
93
+ }
94
+ update(s);
95
+ }}
96
+ ></span>`
97
+ : nothing}
98
+ <sp-field-label size="s" title=${shortProp}>${propLabel(entry, shortProp)}</sp-field-label>
99
+ </div>
100
+ <div class="style-shorthand-header">
101
+ <sp-textfield
102
+ size="s"
103
+ .value=${live(shortVal || "")}
104
+ placeholder=${!shortVal && hasLonghands
105
+ ? longhands.map((/** @type {any} */ l) => style[l.name] || "0").join(" ")
106
+ : !shortVal && inherited[shortProp]
107
+ ? inherited[shortProp]
108
+ : !shortVal && longhands.some((/** @type {any} */ l) => inherited[l.name])
109
+ ? longhands.map((/** @type {any} */ l) => inherited[l.name] || "0").join(" ")
110
+ : ""}
111
+ @input=${debouncedStyleCommit(`short:${shortProp}`, 400, (/** @type {any} */ e) => {
112
+ let s = getState();
113
+ for (const l of longhands) {
114
+ if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
115
+ }
116
+ s = commitFn(s, shortProp, e.target.value || undefined);
117
+ update(s);
118
+ })}
119
+ ></sp-textfield>
120
+ <sp-action-button
121
+ size="xs"
122
+ quiet
123
+ @click=${(/** @type {any} */ e) => {
124
+ e.stopPropagation();
125
+ updateUi("styleShorthands", {
126
+ ...getState().ui.styleShorthands,
127
+ [shortProp]: !isExpanded,
128
+ });
129
+ }}
130
+ >
131
+ ${isExpanded
132
+ ? html`<sp-icon-chevron-down slot="icon"></sp-icon-chevron-down>`
133
+ : html`<sp-icon-chevron-right slot="icon"></sp-icon-chevron-right>`}
134
+ </sp-action-button>
135
+ </div>
136
+ </div>
137
+ ${isExpanded
138
+ ? (() => {
139
+ const isBorderSide = entry.$shorthandType === "border-side";
140
+ const expanded = shortVal
141
+ ? isBorderSide
142
+ ? expandBorderSide(shortVal)
143
+ : expandShorthand(shortVal, longhands.length)
144
+ : null;
145
+ const compress = isBorderSide ? compressBorderSide : compressShorthand;
146
+ const emptyVal = isBorderSide ? "" : "0";
147
+ return longhands.map(
148
+ (/** @type {any} */ { name, entry: lEntry }, /** @type {any} */ idx) => {
149
+ const lVal = style[name] ?? (expanded ? expanded[idx] : "");
150
+ return html`
151
+ <div class="style-row style-row--child" data-prop=${name}>
152
+ <div class="style-row-label">
153
+ ${lVal !== undefined && lVal !== ""
154
+ ? html`<span
155
+ class="set-dot"
156
+ title="Clear ${name}"
157
+ @click=${(/** @type {any} */ e) => {
158
+ e.stopPropagation();
159
+ const vals = longhands.map(
160
+ (/** @type {any} */ l, /** @type {any} */ i) =>
161
+ i === idx
162
+ ? emptyVal
163
+ : (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
164
+ );
165
+ let s = getState();
166
+ for (const l of longhands) {
167
+ if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
168
+ }
169
+ s = commitFn(s, shortProp, compress(vals));
170
+ update(s);
171
+ }}
172
+ ></span>`
173
+ : nothing}
174
+ <sp-field-label size="s" title=${name}
175
+ >${propLabel(lEntry, name)}</sp-field-label
176
+ >
177
+ </div>
178
+ ${widgetForType(
179
+ inferInputType(lEntry),
180
+ lEntry,
181
+ name,
182
+ lVal,
183
+ (/** @type {any} */ newVal) => {
184
+ const vals = longhands.map((/** @type {any} */ l, /** @type {any} */ i) =>
185
+ i === idx
186
+ ? newVal || emptyVal
187
+ : (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
188
+ );
189
+ let s = getState();
190
+ for (const l of longhands) {
191
+ if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
192
+ }
193
+ s = commitFn(s, shortProp, compress(vals));
194
+ update(s);
195
+ },
196
+ { placeholder: !lVal && inherited[name] ? String(inherited[name]) : "" },
197
+ )}
198
+ </div>
199
+ `;
200
+ },
201
+ );
202
+ })()
203
+ : nothing}
204
+ `;
205
+ }
206
+
207
+ // ─── Main template ──────────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * @param {any} node
211
+ * @param {any} activeMediaTab
212
+ * @param {any} activeSelector
213
+ */
214
+ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
215
+ const S = getState();
216
+ const style = node.style || {};
217
+ const { sizeBreakpoints } = parseMediaEntries(getEffectiveMedia(S.document.$media));
218
+ const mediaNames = sizeBreakpoints.map((bp) => bp.name);
219
+ const activeTab = activeMediaTab;
220
+
221
+ // ── Media tabs template ──────────────────────────────────────────────────
222
+ const mediaTabsT =
223
+ mediaNames.length > 0
224
+ ? html`
225
+ <sp-tabs
226
+ size="s"
227
+ selected=${activeTab || "base"}
228
+ @change=${(/** @type {any} */ e) => {
229
+ const val = e.target.selected;
230
+ const newMedia = val === "base" ? null : val;
231
+ if (newMedia !== S.ui.activeMedia) {
232
+ updateUi("activeMedia", newMedia);
233
+ }
234
+ }}
235
+ >
236
+ <sp-tab label="Base" value="base"></sp-tab>
237
+ ${mediaNames.map(
238
+ (name) => html` <sp-tab label=${mediaDisplayName(name)} value=${name}></sp-tab> `,
239
+ )}
240
+ </sp-tabs>
241
+ `
242
+ : nothing;
243
+
244
+ // ── Selector dropdown ──────────────────────────────────────────────────────
245
+ const contextStyle = activeTab ? style[`@${activeTab}`] || {} : style;
246
+ const existingSelectors = Object.keys(contextStyle).filter(isNestedSelector);
247
+ const existingSet = new Set(existingSelectors);
248
+ const commonSet = new Set(COMMON_SELECTORS);
249
+ const extraSelectors = existingSelectors.filter((s) => !commonSet.has(s));
250
+ if (activeSelector && !commonSet.has(activeSelector) && !existingSet.has(activeSelector)) {
251
+ extraSelectors.unshift(activeSelector);
252
+ }
253
+
254
+ const _selectorVal = activeSelector || "__base__";
255
+ const selectorT = html`
256
+ <sp-picker
257
+ size="s"
258
+ class="selector-select"
259
+ quiet
260
+ .value=${live(_selectorVal)}
261
+ @change=${(/** @type {any} */ e) => {
262
+ const val = e.target.value;
263
+ if (val === "__add_custom__") {
264
+ requestAnimationFrame(() => {
265
+ e.target.value = activeSelector || "__base__";
266
+ });
267
+ const picker = e.target;
268
+ const bar = picker.closest(".style-toolbar");
269
+ picker.style.display = "none";
270
+ const inp = document.createElement("input");
271
+ inp.type = "text";
272
+ inp.className = "selector-custom-input";
273
+ inp.placeholder = ":hover, .child, &.active, [attr]";
274
+ bar.appendChild(inp);
275
+ inp.focus();
276
+ let done = false;
277
+ const finish = (/** @type {any} */ accept) => {
278
+ if (done) return;
279
+ done = true;
280
+ const v = inp.value.trim();
281
+ inp.remove();
282
+ picker.style.display = "";
283
+ if (accept && v && isNestedSelector(v)) {
284
+ updateUi("activeSelector", v);
285
+ }
286
+ };
287
+ inp.addEventListener("keydown", (ev) => {
288
+ if (ev.key === "Enter") finish(true);
289
+ else if (ev.key === "Escape") finish(false);
290
+ });
291
+ inp.addEventListener("blur", () => finish(inp.value.trim().length > 0));
292
+ return;
293
+ }
294
+ const newSelector = val === "__base__" ? null : val;
295
+ updateUi("activeSelector", newSelector);
296
+ }}
297
+ >
298
+ <sp-menu-item value="__base__">(base)</sp-menu-item>
299
+ <sp-menu-divider></sp-menu-divider>
300
+ ${COMMON_SELECTORS.map(
301
+ (s) => html`
302
+ <sp-menu-item value=${s}>${existingSet.has(s) ? `${s} \u25CF` : s}</sp-menu-item>
303
+ `,
304
+ )}
305
+ ${extraSelectors.length > 0
306
+ ? html`
307
+ <sp-menu-divider></sp-menu-divider>
308
+ ${extraSelectors.map((s) => html` <sp-menu-item value=${s}>${s} ●</sp-menu-item> `)}
309
+ `
310
+ : nothing}
311
+ <sp-menu-divider></sp-menu-divider>
312
+ <sp-menu-item value="__add_custom__">+ Add custom…</sp-menu-item>
313
+ </sp-picker>
314
+ `;
315
+
316
+ // ── Combined toolbar (media tabs + selector) ───────────────────────────────
317
+ const toolbarT = html`
318
+ <div class="style-toolbar">
319
+ <div class="style-toolbar-tabs">${mediaTabsT}</div>
320
+ ${selectorT}
321
+ </div>
322
+ `;
323
+
324
+ // ── Filter bar ─────────────────────────────────────────────────────────────
325
+ const filterBarT = html`
326
+ <div class="style-filter-bar">
327
+ <sp-textfield
328
+ size="s"
329
+ class="style-filter-input"
330
+ placeholder="Filter properties…"
331
+ .value=${live(S.ui.styleFilter || "")}
332
+ @input=${(/** @type {any} */ e) => updateUi("styleFilter", e.target.value)}
333
+ ></sp-textfield>
334
+ <sp-action-button
335
+ size="xs"
336
+ class="style-filter-toggle"
337
+ ?selected=${S.ui.styleFilterActive}
338
+ @click=${() => updateUi("styleFilterActive", !S.ui.styleFilterActive)}
339
+ >
340
+ Active
341
+ </sp-action-button>
342
+ </div>
343
+ `;
344
+
345
+ // ── Determine the active style object ──────────────────────────────────────
346
+ /** @type {Record<string, any>} */
347
+ let activeStyle;
348
+ /** @type {any} */
349
+ let commitStyle;
350
+ if (activeSelector && activeTab && mediaNames.length > 0) {
351
+ activeStyle = (style[`@${activeTab}`] || {})[activeSelector] || {};
352
+ commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
353
+ updateMediaNestedStyle(s, S.selection, activeTab, activeSelector, prop, val);
354
+ } else if (activeSelector) {
355
+ activeStyle = style[activeSelector] || {};
356
+ commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
357
+ updateNestedStyle(s, S.selection, activeSelector, prop, val);
358
+ } else if (activeTab !== null && mediaNames.length > 0) {
359
+ activeStyle = {};
360
+ for (const [p, v] of Object.entries(style[`@${activeTab}`] || {})) {
361
+ if (typeof v !== "object") activeStyle[p] = v;
362
+ }
363
+ commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
364
+ updateMediaStyle(s, S.selection, activeTab, prop, val);
365
+ } else {
366
+ activeStyle = {};
367
+ for (const [p, v] of Object.entries(style)) {
368
+ if (typeof v !== "object") activeStyle[p] = v;
369
+ }
370
+ commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
371
+ updateStyle(s, S.selection, prop, val);
372
+ }
373
+
374
+ // ── Compute inherited style from higher breakpoints ──────────────────────
375
+ /** @type {Record<string, any>} */
376
+ const inheritedStyle = computeInheritedStyle(style, mediaNames, activeTab, activeSelector);
377
+
378
+ // Auto-open sections that have properties
379
+ const newSections = autoOpenSections({ style: activeStyle }, S.ui.styleSections);
380
+ if (JSON.stringify(newSections) !== JSON.stringify(S.ui.styleSections)) {
381
+ updateUi("styleSections", newSections);
382
+ }
383
+
384
+ // Partition properties into sections
385
+ const sectionProps = /** @type {Record<string, any[]>} */ ({});
386
+ for (const sec of cssMeta.$sections) sectionProps[sec.key] = [];
387
+
388
+ for (const [prop, entry] of /** @type {[string, any][]} */ (Object.entries(cssMeta.$defs))) {
389
+ if (typeof entry.$shorthand === "string") continue;
390
+ const sec = entry.$section || "other";
391
+ sectionProps[sec].push({ prop, entry });
392
+ }
393
+ for (const sec of cssMeta.$sections) {
394
+ sectionProps[sec.key].sort(
395
+ (/** @type {any} */ a, /** @type {any} */ b) => a.entry.$order - b.entry.$order,
396
+ );
397
+ }
398
+
399
+ const otherProps = [];
400
+ for (const prop of Object.keys(activeStyle)) {
401
+ if (!(/** @type {Record<string, any>} */ (cssMeta.$defs)[prop])) otherProps.push(prop);
402
+ }
403
+
404
+ // ── Filter state ─────────────────────────────────────────────────────────
405
+ const filterText = (S.ui.styleFilter || "").toLowerCase();
406
+ const filterActive = S.ui.styleFilterActive;
407
+ const isFiltering = filterText.length > 0 || filterActive;
408
+
409
+ // ── Section templates ────────────────────────────────────────────────────
410
+ const sectionTemplates = cssMeta.$sections
411
+ .filter((sec) => sec.key !== "other")
412
+ .map((sec) => {
413
+ const entries = sectionProps[sec.key];
414
+
415
+ const sectionActiveProps = entries.filter((/** @type {any} */ { prop, entry }) => {
416
+ if (activeStyle[prop] !== undefined) return true;
417
+ if (inferInputType(entry) === "shorthand") {
418
+ return getLonghands(prop).some(
419
+ (/** @type {any} */ l) => activeStyle[l.name] !== undefined,
420
+ );
421
+ }
422
+ return false;
423
+ });
424
+
425
+ const rows = [];
426
+ for (const { prop, entry } of entries) {
427
+ const val = activeStyle[prop];
428
+ const hasVal = val !== undefined;
429
+ const condMet = allConditionsPass(entry, activeStyle);
430
+ const type = inferInputType(entry);
431
+ if (!hasVal && !condMet) continue;
432
+
433
+ if (filterText) {
434
+ const label = propLabel(entry, prop).toLowerCase();
435
+ if (!prop.includes(filterText) && !label.includes(filterText)) continue;
436
+ }
437
+ if (filterActive) {
438
+ if (type === "shorthand") {
439
+ const longhands = getLonghands(prop);
440
+ const hasAnySet =
441
+ hasVal || longhands.some((/** @type {any} */ l) => activeStyle[l.name] !== undefined);
442
+ if (!hasAnySet) continue;
443
+ } else if (!hasVal) continue;
444
+ }
445
+
446
+ if (type === "shorthand") {
447
+ const longhands = getLonghands(prop);
448
+ const hasAny =
449
+ hasVal || longhands.some((/** @type {any} */ l) => activeStyle[l.name] !== undefined);
450
+ if (!hasAny && !condMet) continue;
451
+ rows.push(
452
+ renderShorthandRow(prop, entry, activeStyle, commitStyle, () => {}, inheritedStyle),
453
+ );
454
+ } else {
455
+ const isWarning = hasVal && !condMet;
456
+ if (hasVal || condMet) {
457
+ rows.push(
458
+ renderStyleRow(
459
+ entry,
460
+ prop,
461
+ val ?? "",
462
+ (/** @type {any} */ newVal) =>
463
+ update(commitStyle(getState(), prop, newVal || undefined)),
464
+ () => update(commitStyle(getState(), prop, undefined)),
465
+ isWarning,
466
+ sec.$layout === "grid",
467
+ inheritedStyle[prop],
468
+ ),
469
+ );
470
+ }
471
+ }
472
+ }
473
+
474
+ if (isFiltering && rows.length === 0) return nothing;
475
+ const isOpen = isFiltering ? true : (S.ui.styleSections[sec.key] ?? false);
476
+
477
+ return html`
478
+ <sp-accordion-item
479
+ label=${sec.label}
480
+ .open=${isOpen}
481
+ @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
482
+ updateUi("styleSections", { ...getState().ui.styleSections, [sec.key]: e.target.open });
483
+ }}
484
+ >
485
+ ${sectionActiveProps.length > 0
486
+ ? html`
487
+ <span slot="heading" style="display:flex;align-items:center;gap:6px">
488
+ ${sec.label}
489
+ <span
490
+ class="set-dot set-dot--section"
491
+ title="Clear all ${sec.label.toLowerCase()} properties"
492
+ @click=${(/** @type {any} */ e) => {
493
+ e.stopPropagation();
494
+ e.preventDefault();
495
+ let s = getState();
496
+ for (const { prop, entry } of sectionActiveProps) {
497
+ if (activeStyle[prop] !== undefined) s = commitStyle(s, prop, undefined);
498
+ if (inferInputType(entry) === "shorthand") {
499
+ for (const l of getLonghands(prop)) {
500
+ if (activeStyle[l.name] !== undefined)
501
+ s = commitStyle(s, l.name, undefined);
502
+ }
503
+ }
504
+ }
505
+ update(s);
506
+ }}
507
+ ></span>
508
+ </span>
509
+ `
510
+ : nothing}
511
+ <div class=${sec.$layout === "grid" ? "style-section-body--grid" : ""}>${rows}</div>
512
+ </sp-accordion-item>
513
+ `;
514
+ });
515
+
516
+ // ── Custom section ─────────────────────────────────────────────────────────
517
+ const cssInitialMap = getCssInitialMap();
518
+ const customIsOpen = S.ui.styleSections.other ?? otherProps.length > 0;
519
+ const customSectionT = html`
520
+ <sp-accordion-item
521
+ label="Custom"
522
+ .open=${customIsOpen}
523
+ @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
524
+ updateUi("styleSections", { ...getState().ui.styleSections, other: e.target.open });
525
+ }}
526
+ >
527
+ <div>
528
+ ${otherProps.map(
529
+ (prop) => html`
530
+ <div class="kv-row">
531
+ <sp-textfield
532
+ size="s"
533
+ class="kv-key"
534
+ .value=${live(prop)}
535
+ @change=${(/** @type {any} */ e) => {
536
+ const newProp = e.target.value.trim();
537
+ if (newProp && newProp !== prop) {
538
+ let s = commitStyle(getState(), prop, undefined);
539
+ s = commitStyle(s, newProp, String(activeStyle[prop]));
540
+ update(s);
541
+ }
542
+ }}
543
+ ></sp-textfield>
544
+ <sp-textfield
545
+ size="s"
546
+ class="kv-val"
547
+ .value=${live(String(activeStyle[prop]))}
548
+ placeholder=${ifDefined(cssInitialMap.get(prop))}
549
+ @input=${debouncedStyleCommit(`custom:${prop}`, 400, (/** @type {any} */ e) => {
550
+ update(commitStyle(getState(), prop, e.target.value));
551
+ })}
552
+ ></sp-textfield>
553
+ <sp-action-button
554
+ size="xs"
555
+ quiet
556
+ @click=${() => update(commitStyle(getState(), prop, undefined))}
557
+ >
558
+ <sp-icon-close slot="icon"></sp-icon-close>
559
+ </sp-action-button>
560
+ </div>
561
+ `,
562
+ )}
563
+ <div style="display:flex;gap:4px;padding-top:4px">
564
+ <sp-textfield
565
+ size="s"
566
+ placeholder="Property name…"
567
+ style="flex:1"
568
+ @keydown=${(/** @type {any} */ e) => {
569
+ if (e.key === "Enter") {
570
+ e.preventDefault();
571
+ const prop = e.target.value.trim();
572
+ if (prop) {
573
+ const initial = cssInitialMap.get(prop) || "";
574
+ update(commitStyle(getState(), prop, initial || ""));
575
+ e.target.value = "";
576
+ }
577
+ }
578
+ }}
579
+ ></sp-textfield>
580
+ </div>
581
+ </div>
582
+ </sp-accordion-item>
583
+ `;
584
+
585
+ return html`
586
+ <div class="style-sidebar">
587
+ ${toolbarT} ${filterBarT}
588
+ <sp-accordion allow-multiple size="s"> ${sectionTemplates} ${customSectionT} </sp-accordion>
589
+ </div>
590
+ `;
591
+ }
592
+
593
+ // ─── Entry point ────────────────────────────────────────────────────────────
594
+
595
+ /**
596
+ * Top-level Style panel — returns a lit-html template.
597
+ *
598
+ * @param {{ getCanvasMode: () => string }} ctx
599
+ * @returns {import("lit-html").TemplateResult}
600
+ */
601
+ export function renderStylePanelTemplate(ctx) {
602
+ const S = getState();
603
+ if (ctx.getCanvasMode() === "settings" && S.ui.stylebookSelection) {
604
+ const node = S.document;
605
+ if (!node) return html`<div class="empty-state">No document loaded</div>`;
606
+ return html`
607
+ <div class="stylebook-style-header">Styling: &lt;${S.ui.stylebookSelection}&gt;</div>
608
+ ${styleSidebarTemplate(node, S.ui.activeMedia, S.ui.activeSelector)}
609
+ `;
610
+ }
611
+ if (!S.selection) return html`<div class="empty-state">Select an element to style</div>`;
612
+ const node = getNodeAtPath(S.document, S.selection);
613
+ if (!node) return html`<div class="empty-state">Select an element to style</div>`;
614
+ return styleSidebarTemplate(node, S.ui.activeMedia, S.ui.activeSelector);
615
+ }
616
+
617
+ /** Single property input row (generic field row helper) */
618
+ export function _fieldRow(
619
+ /** @type {any} */ label,
620
+ /** @type {any} */ type,
621
+ /** @type {any} */ value,
622
+ /** @type {any} */ onChange,
623
+ /** @type {any} */ _datalistId,
624
+ ) {
625
+ /** @type {any} */
626
+ let debounceTimer;
627
+ const onInput = (/** @type {any} */ e) => {
628
+ clearTimeout(debounceTimer);
629
+ debounceTimer = setTimeout(() => onChange(e.target.value), 400);
630
+ };
631
+ const inputTpl =
632
+ type === "textarea"
633
+ ? html`<sp-textfield
634
+ multiline
635
+ size="s"
636
+ value=${value ?? ""}
637
+ @input=${onInput}
638
+ ></sp-textfield>`
639
+ : type === "checkbox"
640
+ ? html`<sp-checkbox
641
+ ?checked=${!!value}
642
+ @change=${(/** @type {any} */ e) => onChange(e.target.checked)}
643
+ ></sp-checkbox>`
644
+ : html`<sp-textfield size="s" value=${value ?? ""} @input=${onInput}></sp-textfield>`;
645
+ return html`
646
+ <div class="field-row">
647
+ <sp-field-label size="s">${label}</sp-field-label>
648
+ ${inputTpl}
649
+ </div>
650
+ `;
651
+ }