@kubex/zinc 1.1.67 → 1.1.69
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/dist/custom-elements.json +1288 -20
- package/dist/vscode.html-custom-data.json +167 -11
- package/dist/web-types.json +360 -17
- package/dist/zn.d.ts +368 -20
- package/dist/zn.min.js +764 -645
- package/docs/pages/components/collapsible.md +6 -2
- package/docs/pages/components/preview-frame-demo.njk +33 -4
- package/docs/pages/components/preview-frame.md +20 -0
- package/docs/pages/components/theme-editor.md +269 -0
- package/docs/superpowers/plans/2026-08-03-theme-editor.md +1536 -0
- package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +327 -0
- package/package.json +1 -1
- package/src/components/collapsible/collapsible.component.ts +21 -23
- package/src/components/collapsible/collapsible.scss +10 -0
- package/src/components/data-table/data-table.component.ts +49 -44
- package/src/components/data-table/data-table.scss +4 -0
- package/src/components/data-table/data-table.test.ts +42 -0
- package/src/components/defined-label/defined-label.component.ts +118 -95
- package/src/components/defined-label/defined-label.scss +40 -28
- package/src/components/dropdown/dropdown.component.ts +9 -0
- package/src/components/editor/editor.component.ts +20 -21
- package/src/components/file/file.component.ts +70 -0
- package/src/components/page-builder/page-builder.scss +1 -1
- package/src/components/preview-frame/preview-frame.component.ts +126 -16
- package/src/components/preview-frame/preview-frame.scss +27 -0
- package/src/components/preview-frame/preview-frame.test.ts +253 -0
- package/src/components/theme-editor/index.ts +12 -0
- package/src/components/theme-editor/theme-editor.component.ts +761 -0
- package/src/components/theme-editor/theme-editor.scss +309 -0
- package/src/components/theme-editor/theme-editor.test.ts +1584 -0
- package/src/events/events.ts +2 -0
- package/src/events/zn-theme-change.ts +13 -0
- package/src/events/zn-theme-submit.ts +9 -0
- package/src/types/web-test-runner-commands.d.ts +6 -0
- package/src/zinc.ts +1 -0
- package/tsconfig.json +7 -1
- package/web-test-runner.config.js +3 -1
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import { type CSSResultGroup, html, nothing, unsafeCSS } from 'lit';
|
|
2
|
+
import { HasSlotController } from '../../internal/slot';
|
|
3
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
4
|
+
import { MutationController } from '@lit-labs/observers/mutation-controller.js';
|
|
5
|
+
import { property, query, queryAll, state } from 'lit/decorators.js';
|
|
6
|
+
import ZincElement from '../../internal/zinc-element';
|
|
7
|
+
import ZnButton from '../button';
|
|
8
|
+
import ZnCollapsible from '../collapsible';
|
|
9
|
+
import ZnIcon from '../icon';
|
|
10
|
+
import ZnNavbar from '../navbar';
|
|
11
|
+
import ZnOption from '../option';
|
|
12
|
+
import ZnPreviewFrame from '../preview-frame';
|
|
13
|
+
import ZnSelect from '../select';
|
|
14
|
+
import ZnTabs from '../tabs';
|
|
15
|
+
import type { ZnErrorEvent } from '../../events/zn-error';
|
|
16
|
+
|
|
17
|
+
import styles from './theme-editor.scss';
|
|
18
|
+
|
|
19
|
+
export type ThemeEditorMode = 'light' | 'dark';
|
|
20
|
+
export type ThemeEditorDevice = 'desktop' | 'tablet' | 'mobile';
|
|
21
|
+
|
|
22
|
+
export interface ThemeEditorGroup {
|
|
23
|
+
/** The slot name controls are assigned to with `slot="<name>"`. */
|
|
24
|
+
name: string;
|
|
25
|
+
caption: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
/** Renders expanded initially. */
|
|
28
|
+
open?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ThemeEditorSection extends ThemeEditorGroup {
|
|
32
|
+
/**
|
|
33
|
+
* Nests a collapsible per group inside this section's tab instead of the
|
|
34
|
+
* section's own controls directly. A non-empty `groups` on ANY section
|
|
35
|
+
* switches every section to `zn-tabs`, regardless of `section-layout`.
|
|
36
|
+
*/
|
|
37
|
+
groups?: ThemeEditorGroup[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ThemeEditorSource {
|
|
41
|
+
label: string;
|
|
42
|
+
src: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Controls whose state lives on `checked` rather than `value`.
|
|
46
|
+
const BOOLEAN_CONTROLS = new Set(['zn-checkbox', 'zn-toggle']);
|
|
47
|
+
|
|
48
|
+
// Matches theme-editor.scss's stacked breakpoint - keep both in sync.
|
|
49
|
+
const STACKED_QUERY = '(max-width: 768px)';
|
|
50
|
+
|
|
51
|
+
const DEVICES: { id: ThemeEditorDevice; icon: string; label: string }[] = [
|
|
52
|
+
{ id: 'desktop', icon: 'monitor', label: 'Desktop' },
|
|
53
|
+
{ id: 'tablet', icon: 'tablet', label: 'Tablet' },
|
|
54
|
+
{ id: 'mobile', icon: 'smartphone', label: 'Mobile' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
interface HarvestableControl extends HTMLElement {
|
|
58
|
+
name?: string;
|
|
59
|
+
value?: unknown;
|
|
60
|
+
checked?: boolean;
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
type?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @summary A theme editor: slotted form controls drive a live preview frame,
|
|
67
|
+
* with a toolbar for the preview's light/dark mode and device width.
|
|
68
|
+
* @documentation https://zinc.style/components/theme-editor
|
|
69
|
+
* @status experimental
|
|
70
|
+
* @since 1.0
|
|
71
|
+
*
|
|
72
|
+
* @dependency zn-collapsible
|
|
73
|
+
* @dependency zn-preview-frame
|
|
74
|
+
* @dependency zn-icon
|
|
75
|
+
* @dependency zn-button
|
|
76
|
+
* @dependency zn-tabs
|
|
77
|
+
* @dependency zn-navbar
|
|
78
|
+
* @dependency zn-select
|
|
79
|
+
*
|
|
80
|
+
* @event zn-theme-change - Emitted when the values, mode or device change.
|
|
81
|
+
* @event zn-theme-submit - Emitted on submit (button click), carrying the
|
|
82
|
+
* current values. With `action` set, only fires after a successful save.
|
|
83
|
+
* @event zn-error - Emitted when a save fails. Also seen for preview render
|
|
84
|
+
* failures: the frame's zn-error is composed and not stopped, so it bubbles
|
|
85
|
+
* out through the editor too.
|
|
86
|
+
*
|
|
87
|
+
* @slot - Ungrouped theme controls, rendered above any sections. Controls
|
|
88
|
+
* assigned `slot="<name>"` matching a `sections` entry (or, when nested, a
|
|
89
|
+
* `groups` entry) render inside that section/group instead. Harvesting and
|
|
90
|
+
* change detection walk every slot's full assigned subtree, not just direct
|
|
91
|
+
* children.
|
|
92
|
+
* @slot toolbar - Actions in the toolbar, right-aligned beside the device
|
|
93
|
+
* controls. Where a save button belongs.
|
|
94
|
+
* @slot footer - Actions pinned beneath the controls. The built-in submit button
|
|
95
|
+
* lives in the toolbar, not here.
|
|
96
|
+
*
|
|
97
|
+
* @csspart base - The component's base wrapper.
|
|
98
|
+
* @csspart controls - The left-hand controls column, full height.
|
|
99
|
+
* @csspart controls-header - The controls column's header row: `controls-caption` on the left, the light/dark mode toggle on the right.
|
|
100
|
+
* @csspart toolbar - The preview column's header row: `preview-caption` on the left, the device switcher (and sources/submit) on the right. Spans the preview column only.
|
|
101
|
+
* @csspart section - A rendered section's or group's collapsible (`section-layout="collapsible"`, or any nested group).
|
|
102
|
+
* @csspart footer - The footer wrapper beneath the controls.
|
|
103
|
+
* @csspart preview - The preview column.
|
|
104
|
+
* @csspart error - The inline error strip.
|
|
105
|
+
* @csspart preview__base - The frame's base wrapper (forwarded from zn-preview-frame).
|
|
106
|
+
* @csspart preview__stage - The frame's device-width wrapper (forwarded from zn-preview-frame).
|
|
107
|
+
* @csspart preview__iframe - The frame's iframe (forwarded from zn-preview-frame).
|
|
108
|
+
* @csspart preview__error - The frame's own error overlay (forwarded from zn-preview-frame).
|
|
109
|
+
*
|
|
110
|
+
* @cssproperty --zn-theme-editor-controls-width - Width of the controls column.
|
|
111
|
+
*/
|
|
112
|
+
export default class ZnThemeEditor extends ZincElement {
|
|
113
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
114
|
+
static dependencies = {
|
|
115
|
+
'zn-collapsible': ZnCollapsible,
|
|
116
|
+
'zn-preview-frame': ZnPreviewFrame,
|
|
117
|
+
'zn-icon': ZnIcon,
|
|
118
|
+
'zn-button': ZnButton,
|
|
119
|
+
'zn-tabs': ZnTabs,
|
|
120
|
+
'zn-navbar': ZnNavbar,
|
|
121
|
+
'zn-select': ZnSelect,
|
|
122
|
+
'zn-option': ZnOption,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** URL of the preview shell page; forwarded to the frame. */
|
|
126
|
+
@property() src = '';
|
|
127
|
+
|
|
128
|
+
/** Expected origin of the iframe; forwarded to the frame. */
|
|
129
|
+
@property({ attribute: 'frame-origin' }) frameOrigin = '';
|
|
130
|
+
|
|
131
|
+
/** Optional endpoint returning the base hp-preview:config payload. */
|
|
132
|
+
@property({ attribute: 'data-uri' }) dataUri = '';
|
|
133
|
+
|
|
134
|
+
/** Which mode the preview renders in. Travels in the theme payload. */
|
|
135
|
+
@property({ reflect: true }) mode: ThemeEditorMode = 'light';
|
|
136
|
+
|
|
137
|
+
/** Preview viewport width. Resizes the frame only; not part of the payload. */
|
|
138
|
+
@property({ reflect: true }) device: ThemeEditorDevice = 'desktop';
|
|
139
|
+
|
|
140
|
+
/** Minimum height of the preview row, in pixels; forwarded to the frame as its own floor. */
|
|
141
|
+
@property({ type: Number, attribute: 'min-height' }) minHeight = 480;
|
|
142
|
+
|
|
143
|
+
/** Debounce in ms between a control change and the push to the preview. */
|
|
144
|
+
@property({ type: Number }) debounce = 150;
|
|
145
|
+
|
|
146
|
+
/** Optional endpoint the values are POSTed to. Empty = no persistence. */
|
|
147
|
+
@property() action = '';
|
|
148
|
+
|
|
149
|
+
/** Debounce in ms between a control change and the save POST. */
|
|
150
|
+
@property({ type: Number, attribute: 'save-debounce' }) saveDebounce = 1000;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Groups controls into named sections. Empty/unset renders one ungrouped
|
|
154
|
+
* column. A section with a non-empty `groups` nests a collapsible per
|
|
155
|
+
* group inside a `zn-tabs` tab for that section - see `groups` on
|
|
156
|
+
* `ThemeEditorSection`.
|
|
157
|
+
*/
|
|
158
|
+
@property({ type: Array }) sections: ThemeEditorSection[] = [];
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Presentation for flat, group-less `sections`: stacked `zn-collapsible`s
|
|
162
|
+
* (default) or a `zn-tabs` strip. Ignored once any section has `groups` -
|
|
163
|
+
* nested sections always render as tabs.
|
|
164
|
+
*/
|
|
165
|
+
@property({ attribute: 'section-layout' }) sectionLayout: 'collapsible' | 'tabs' = 'collapsible';
|
|
166
|
+
|
|
167
|
+
/** Dropdown of preview sources, `{label, src}`, rendered in the toolbar. Empty/unset renders no dropdown; the first entry wins over an explicit `src` when non-empty. */
|
|
168
|
+
@property({ type: Array }) sources: ThemeEditorSource[] = [];
|
|
169
|
+
|
|
170
|
+
/** Collapses the controls column. */
|
|
171
|
+
@property({ type: Boolean, reflect: true, attribute: 'controls-collapsed' }) controlsCollapsed = false;
|
|
172
|
+
|
|
173
|
+
/** Presents the editor as its own bordered, rounded panel with a plain preview backdrop, rather than embedded in a dotted canvas. */
|
|
174
|
+
@property({ type: Boolean, reflect: true }) standalone = false;
|
|
175
|
+
|
|
176
|
+
/** Caption in the controls column's header row. Empty (default) renders no text; the row itself always renders. */
|
|
177
|
+
@property({ attribute: 'controls-caption' }) controlsCaption = '';
|
|
178
|
+
|
|
179
|
+
/** Caption at the left of the toolbar, opposite the device and mode controls. Empty (default) renders no text. */
|
|
180
|
+
@property({ attribute: 'preview-caption' }) previewCaption = '';
|
|
181
|
+
|
|
182
|
+
/** Label for the built-in submit button. Empty (default) renders no button. */
|
|
183
|
+
@property({ attribute: 'submit-label' }) submitLabel = '';
|
|
184
|
+
|
|
185
|
+
/** Disables the debounced auto-save; saving then happens only via submit. Preview pushes are unaffected. */
|
|
186
|
+
@property({ type: Boolean }) manual = false;
|
|
187
|
+
|
|
188
|
+
@query('zn-preview-frame') frame: ZnPreviewFrame;
|
|
189
|
+
|
|
190
|
+
@query('slot:not([name])') private controlsSlot: HTMLSlotElement;
|
|
191
|
+
|
|
192
|
+
@queryAll('.editor__section-slot') private sectionSlots: NodeListOf<HTMLSlotElement>;
|
|
193
|
+
|
|
194
|
+
@state() protected error = '';
|
|
195
|
+
|
|
196
|
+
@state() private _submitting = false;
|
|
197
|
+
|
|
198
|
+
// Which `sources` entry drives the frame's src. Purely a view toggle -
|
|
199
|
+
// never read by harvesting, seeding or the push/save pipeline.
|
|
200
|
+
@state() private _sourceIndex = 0;
|
|
201
|
+
|
|
202
|
+
private readonly hasSlotController = new HasSlotController(this, 'footer', '[default]');
|
|
203
|
+
|
|
204
|
+
private _pushTimer?: number;
|
|
205
|
+
private _saveTimer?: number;
|
|
206
|
+
private _saving = false;
|
|
207
|
+
private _saveQueued = false;
|
|
208
|
+
private _saveWaiters: ((ok: boolean) => void)[] = [];
|
|
209
|
+
|
|
210
|
+
// Mirrors page-builder's one-shot auto-collapse on crossing into narrow.
|
|
211
|
+
private readonly _narrowQuery = window.matchMedia(STACKED_QUERY);
|
|
212
|
+
private _wasNarrow = false;
|
|
213
|
+
|
|
214
|
+
// firstUpdated(), slotchange and the mutation observer below all race to
|
|
215
|
+
// report the same initial state. Track the deep set of named controls (not
|
|
216
|
+
// their values) so any redundant echo is a no-op while a genuine change -
|
|
217
|
+
// including one nested inside an already-assigned section - still pushes.
|
|
218
|
+
// Never gates _push() itself: any real control value change always pushes.
|
|
219
|
+
private _mounted = false;
|
|
220
|
+
private _lastControls: HarvestableControl[] = [];
|
|
221
|
+
|
|
222
|
+
// childList+subtree only, NEVER attributes - zn-checkbox reflects `checked`
|
|
223
|
+
// to an attribute, and write-back assigns .checked on mode toggle, which
|
|
224
|
+
// would otherwise feed straight back into this guard. Exposed as a field
|
|
225
|
+
// (rather than inlined) so a test can pin the config directly.
|
|
226
|
+
private readonly _controlsObserverConfig: MutationObserverInit = { childList: true, subtree: true };
|
|
227
|
+
|
|
228
|
+
private readonly _controlsObserver = new MutationController(this, {
|
|
229
|
+
target: null,
|
|
230
|
+
config: this._controlsObserverConfig,
|
|
231
|
+
callback: () => this._pushIfControlsChanged(),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Per-mode value sets. Seeded once per control name (never re-seeded, so a
|
|
235
|
+
// user's edits survive later controls being added) and otherwise updated by
|
|
236
|
+
// harvesting the DOM into the active mode only.
|
|
237
|
+
private _modeValues: Record<ThemeEditorMode, Record<string, unknown>> = { light: {}, dark: {} };
|
|
238
|
+
|
|
239
|
+
// Suppresses _onControlChange while write-back assigns .value/.checked
|
|
240
|
+
// programmatically. This IS load-bearing: zn-input's color-format watcher
|
|
241
|
+
// can emit zn-change from inside a later Lit update() - after the
|
|
242
|
+
// microtasks of this call stack, not during it - e.g. when a dark-value is
|
|
243
|
+
// authored in a different colour-string representation than value. A depth
|
|
244
|
+
// counter (not a boolean) so overlapping write-backs (e.g. a slot change
|
|
245
|
+
// and a mode toggle landing close together) don't let one's cleanup clear
|
|
246
|
+
// the other's guard early.
|
|
247
|
+
private _suppressDepth = 0;
|
|
248
|
+
|
|
249
|
+
/** The current per-mode value sets. Returns copies. */
|
|
250
|
+
get values(): { light: Record<string, unknown>; dark: Record<string, unknown> } {
|
|
251
|
+
return { light: { ...this._modeValues.light }, dark: { ...this._modeValues.dark } };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The active mode's values - what gets pushed to the preview frame. */
|
|
255
|
+
get activeValues(): Record<string, unknown> {
|
|
256
|
+
return { ...this._modeValues[this.mode] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The default slot plus every rendered section slot. */
|
|
260
|
+
private _controlSlots(): HTMLSlotElement[] {
|
|
261
|
+
return [this.controlsSlot, ...Array.from(this.sectionSlots ?? [])]
|
|
262
|
+
.filter((slot): slot is HTMLSlotElement => !!slot);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Walks every control slot (default and sections) for every enabled, named control. */
|
|
266
|
+
private _harvestNamed(): { name: string; control: HarvestableControl }[] {
|
|
267
|
+
const found: { name: string; control: HarvestableControl }[] = [];
|
|
268
|
+
|
|
269
|
+
for (const slot of this._controlSlots()) {
|
|
270
|
+
const roots = slot.assignedElements({ flatten: true });
|
|
271
|
+
for (const root of roots) {
|
|
272
|
+
const candidates = [root, ...Array.from(root.querySelectorAll('[name]'))];
|
|
273
|
+
for (const candidate of candidates) {
|
|
274
|
+
const control = candidate as HarvestableControl;
|
|
275
|
+
if (!control.getAttribute?.('name') || control.disabled) continue;
|
|
276
|
+
found.push({ name: control.getAttribute('name')!, control });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return found;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Whether a direct child is assigned to the named slot — an empty section renders no chrome. */
|
|
285
|
+
private _hasAssignedControls(slotName: string): boolean {
|
|
286
|
+
return Array.from(this.children).some(el => el.getAttribute('slot') === slotName);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private _isBooleanControl(control: HarvestableControl): boolean {
|
|
290
|
+
return BOOLEAN_CONTROLS.has(control.tagName.toLowerCase()) || control.type === 'checkbox';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private _readControlValue(control: HarvestableControl): unknown {
|
|
294
|
+
return this._isBooleanControl(control) ? !!control.checked : control.value;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Seeds light/dark entries for any control name not already present. */
|
|
298
|
+
private _seed() {
|
|
299
|
+
for (const { name, control } of this._harvestNamed()) {
|
|
300
|
+
if (name in this._modeValues.light) continue;
|
|
301
|
+
|
|
302
|
+
const light = this._readControlValue(control);
|
|
303
|
+
this._modeValues.light[name] = light;
|
|
304
|
+
|
|
305
|
+
const darkAttr = control.getAttribute('dark-value');
|
|
306
|
+
this._modeValues.dark[name] = darkAttr === null
|
|
307
|
+
? light
|
|
308
|
+
: this._isBooleanControl(control)
|
|
309
|
+
? (darkAttr === '1' || darkAttr === 'true')
|
|
310
|
+
: darkAttr;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Writes a mode's value set back into the controls so they display it. */
|
|
315
|
+
private _writeBack(mode: ThemeEditorMode = this.mode) {
|
|
316
|
+
// Only assign - and only suppress - controls whose displayed value is
|
|
317
|
+
// actually about to change. Most write-backs (e.g. mount seeding a
|
|
318
|
+
// control from its own current value) are true no-ops; skipping the
|
|
319
|
+
// assignment entirely means no Lit update is triggered for them, so
|
|
320
|
+
// there is nothing to suppress and no window during which an unrelated,
|
|
321
|
+
// genuinely new edit to that same control could be wrongly swallowed.
|
|
322
|
+
let wrote = false;
|
|
323
|
+
for (const { name, control } of this._harvestNamed()) {
|
|
324
|
+
if (!(name in this._modeValues[mode])) continue;
|
|
325
|
+
const value = this._modeValues[mode][name];
|
|
326
|
+
const isBoolean = this._isBooleanControl(control);
|
|
327
|
+
const current = isBoolean ? !!control.checked : control.value;
|
|
328
|
+
if (isBoolean ? current === value : String(current) === String(value)) continue;
|
|
329
|
+
|
|
330
|
+
wrote = true;
|
|
331
|
+
if (isBoolean) {
|
|
332
|
+
control.checked = !!value;
|
|
333
|
+
} else {
|
|
334
|
+
control.value = value;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!wrote) return;
|
|
339
|
+
|
|
340
|
+
// Lit's update cycle is never synchronous with the assignments above, and
|
|
341
|
+
// a watched-property handler can emit after several microtask hops of
|
|
342
|
+
// its own (e.g. an internal `await this.updateComplete`). A macrotask
|
|
343
|
+
// boundary is cruder than awaiting updateComplete directly, but it
|
|
344
|
+
// reliably outlasts however many microtask hops that chain takes,
|
|
345
|
+
// without this method needing to know the shape of that chain.
|
|
346
|
+
this._suppressDepth++;
|
|
347
|
+
window.setTimeout(() => {
|
|
348
|
+
this._suppressDepth--;
|
|
349
|
+
}, 0);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Harvests the controls' current displayed values into a mode's set. */
|
|
353
|
+
private _harvestInto(mode: ThemeEditorMode) {
|
|
354
|
+
for (const { name, control } of this._harvestNamed()) {
|
|
355
|
+
this._modeValues[mode][name] = this._readControlValue(control);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
connectedCallback() {
|
|
360
|
+
super.connectedCallback();
|
|
361
|
+
this._controlsObserver.observe(this);
|
|
362
|
+
this._narrowQuery.addEventListener('change', this._onNarrowChange);
|
|
363
|
+
this._onNarrowChange(this._narrowQuery);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
disconnectedCallback() {
|
|
367
|
+
if (this._pushTimer) window.clearTimeout(this._pushTimer);
|
|
368
|
+
if (this._saveTimer) window.clearTimeout(this._saveTimer);
|
|
369
|
+
this._narrowQuery.removeEventListener('change', this._onNarrowChange);
|
|
370
|
+
super.disconnectedCallback();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private readonly _onNarrowChange = (e: MediaQueryList | MediaQueryListEvent) => {
|
|
374
|
+
if (e.matches && !this._wasNarrow) this.controlsCollapsed = true;
|
|
375
|
+
this._wasNarrow = e.matches;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
protected firstUpdated() {
|
|
379
|
+
// Push the authored defaults immediately so the preview never renders
|
|
380
|
+
// un-themed and then snaps to the real values. The frame retains the
|
|
381
|
+
// payload and replays it after its ready handshake. Routed through the
|
|
382
|
+
// same assignment-change gate as _onSlotChange so that whichever of the
|
|
383
|
+
// two fires first performs the mount push and the other — seeing an
|
|
384
|
+
// unchanged control set — is a no-op, regardless of firing order.
|
|
385
|
+
this._pushIfControlsChanged();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Pushes the active mode's values into the preview and announces it. */
|
|
389
|
+
private _push() {
|
|
390
|
+
// Optimistically clear a frame-sourced error: the frame clears its own
|
|
391
|
+
// overlay on hp-preview:rendered, and a failing save re-sets this on its
|
|
392
|
+
// own (longer) debounce.
|
|
393
|
+
this.error = '';
|
|
394
|
+
this.frame?.setTheme({ mode: this.mode, values: this.activeValues });
|
|
395
|
+
this._announce();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private _queueSave() {
|
|
399
|
+
if (!this.action || this.manual) return;
|
|
400
|
+
if (this._saveTimer) window.clearTimeout(this._saveTimer);
|
|
401
|
+
this._saveTimer = window.setTimeout(() => {
|
|
402
|
+
this._saveTimer = undefined;
|
|
403
|
+
void this._save();
|
|
404
|
+
}, this.saveDebounce);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Saves serialize through a single slot: changes arriving mid-flight collapse
|
|
408
|
+
// into exactly one follow-up save, so overlapping POSTs can't land out of
|
|
409
|
+
// order and persist a stale value. Waiters queued via _awaitSave() are only
|
|
410
|
+
// resolved by whichever run finishes with nothing further queued behind it -
|
|
411
|
+
// the run that actually carries their values, not necessarily this call.
|
|
412
|
+
private async _save() {
|
|
413
|
+
if (this._saving) {
|
|
414
|
+
this._saveQueued = true;
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
this._saving = true;
|
|
418
|
+
let ok = false;
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
const body = new FormData();
|
|
422
|
+
for (const mode of ['light', 'dark'] as ThemeEditorMode[]) {
|
|
423
|
+
for (const [name, value] of Object.entries(this._modeValues[mode])) {
|
|
424
|
+
body.append(`${mode}[${name}]`, typeof value === 'boolean' ? (value ? '1' : '') : String(value ?? ''));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const response = await fetch(this.action, {
|
|
428
|
+
method: 'POST',
|
|
429
|
+
credentials: 'same-origin',
|
|
430
|
+
body,
|
|
431
|
+
});
|
|
432
|
+
if (!response.ok) {
|
|
433
|
+
throw new Error(await response.text() || response.statusText);
|
|
434
|
+
}
|
|
435
|
+
this.error = '';
|
|
436
|
+
ok = true;
|
|
437
|
+
} catch (err) {
|
|
438
|
+
this._fail(err instanceof Error ? err.message : String(err));
|
|
439
|
+
} finally {
|
|
440
|
+
this._saving = false;
|
|
441
|
+
if (this._saveQueued) {
|
|
442
|
+
this._saveQueued = false;
|
|
443
|
+
void this._save();
|
|
444
|
+
} else {
|
|
445
|
+
const waiters = this._saveWaiters;
|
|
446
|
+
this._saveWaiters = [];
|
|
447
|
+
waiters.forEach(resolve => resolve(ok));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Resolves once a save actually carrying the current values has settled. */
|
|
453
|
+
private _awaitSave(): Promise<boolean> {
|
|
454
|
+
return new Promise(resolve => {
|
|
455
|
+
this._saveWaiters.push(resolve);
|
|
456
|
+
void this._save();
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Pushes only if the deep set of named controls (the same set harvesting
|
|
462
|
+
* walks, so it includes controls nested inside sections) has changed since
|
|
463
|
+
* the last push. Comparison is by element identity only, never by value.
|
|
464
|
+
*/
|
|
465
|
+
private _pushIfControlsChanged() {
|
|
466
|
+
// A section's rendered chrome depends on live slot assignment - recompute every time.
|
|
467
|
+
this.requestUpdate();
|
|
468
|
+
|
|
469
|
+
const current = this._harvestNamed().map(({ control }) => control);
|
|
470
|
+
const changed = !this._mounted
|
|
471
|
+
|| current.length !== this._lastControls.length
|
|
472
|
+
|| current.some((el, i) => el !== this._lastControls[i]);
|
|
473
|
+
|
|
474
|
+
if (!changed) return;
|
|
475
|
+
|
|
476
|
+
this._flushPendingEdit();
|
|
477
|
+
this._mounted = true;
|
|
478
|
+
this._lastControls = current;
|
|
479
|
+
this._seed();
|
|
480
|
+
// Write back so an editor authored with mode="dark" displays its dark
|
|
481
|
+
// values on first render rather than the light defaults just seeded.
|
|
482
|
+
this._writeBack();
|
|
483
|
+
this._push();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Harvests and queues a save for a pending debounced edit, then cancels its timer. */
|
|
487
|
+
private _flushPendingEdit() {
|
|
488
|
+
if (!this._pushTimer) return;
|
|
489
|
+
window.clearTimeout(this._pushTimer);
|
|
490
|
+
this._pushTimer = undefined;
|
|
491
|
+
this._harvestInto(this.mode);
|
|
492
|
+
this._queueSave();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Reuses _save()'s single-slot queue rather than POSTing directly, so a
|
|
496
|
+
// submit mid-flight can't land as a second concurrent request.
|
|
497
|
+
private readonly _onSubmit = () => {
|
|
498
|
+
if (this._submitting) return;
|
|
499
|
+
|
|
500
|
+
this._flushPendingEdit();
|
|
501
|
+
if (this._saveTimer) {
|
|
502
|
+
window.clearTimeout(this._saveTimer);
|
|
503
|
+
this._saveTimer = undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!this.action) {
|
|
507
|
+
this.emit('zn-theme-submit', { detail: { values: this.values } });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
this._submitting = true;
|
|
512
|
+
void this._awaitSave()
|
|
513
|
+
.then(ok => {
|
|
514
|
+
if (ok) this.emit('zn-theme-submit', { detail: { values: this.values } });
|
|
515
|
+
})
|
|
516
|
+
.finally(() => {
|
|
517
|
+
this._submitting = false;
|
|
518
|
+
});
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
private _announce() {
|
|
522
|
+
this.emit('zn-theme-change', { detail: { values: this.values, mode: this.mode, device: this.device } });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private _fail(message: string) {
|
|
526
|
+
this.error = message;
|
|
527
|
+
this.emit('zn-error', { detail: { message } });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private readonly _onControlChange = () => {
|
|
531
|
+
if (this._suppressDepth > 0) return;
|
|
532
|
+
if (this._pushTimer) window.clearTimeout(this._pushTimer);
|
|
533
|
+
this._pushTimer = window.setTimeout(() => {
|
|
534
|
+
this._pushTimer = undefined;
|
|
535
|
+
this._harvestInto(this.mode);
|
|
536
|
+
this._push();
|
|
537
|
+
this._queueSave();
|
|
538
|
+
}, this.debounce);
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
private readonly _onSlotChange = () => {
|
|
542
|
+
this._pushIfControlsChanged();
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
private readonly _setDevice = (device: ThemeEditorDevice) => {
|
|
546
|
+
if (this.device === device) return;
|
|
547
|
+
this.device = device;
|
|
548
|
+
// device only resizes the frame — the embed reads its width from the
|
|
549
|
+
// iframe box, so there's nothing new to push
|
|
550
|
+
this._announce();
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
private readonly _toggleMode = () => {
|
|
554
|
+
// A pending debounced edit reads this.mode at fire time, not schedule
|
|
555
|
+
// time - if it fired after the flip below, it would harvest the value
|
|
556
|
+
// write-back is about to overwrite, into the wrong mode's bucket, and
|
|
557
|
+
// the edit would land nowhere. Flush it into the mode it was actually
|
|
558
|
+
// made in before switching.
|
|
559
|
+
this._flushPendingEdit();
|
|
560
|
+
|
|
561
|
+
this.mode = this.mode === 'dark' ? 'light' : 'dark';
|
|
562
|
+
this._writeBack();
|
|
563
|
+
this._push();
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
private readonly _onFrameError = (e: ZnErrorEvent) => {
|
|
567
|
+
// zn-error already bubbles and composes out to the host; just display it.
|
|
568
|
+
this.error = e.detail.message ?? 'Preview failed to render';
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
private _sourcesSafe(): ThemeEditorSource[] {
|
|
572
|
+
return Array.isArray(this.sources) ? this.sources : [];
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// The first source wins over an explicit `src` when sources is non-empty.
|
|
576
|
+
// The frame reloads on switch; setTheme()'s retained payload replays after
|
|
577
|
+
// its next hp-preview:ready, so nothing further is needed here.
|
|
578
|
+
private _frameSrc(): string {
|
|
579
|
+
const sources = this._sourcesSafe();
|
|
580
|
+
return sources.length > 0 ? (sources[this._sourceIndex] ?? sources[0]).src : this.src;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private readonly _onSourceChange = (e: Event) => {
|
|
584
|
+
const index = Number((e.target as HTMLElement & { value: string }).value);
|
|
585
|
+
if (!Number.isNaN(index)) this._sourceIndex = index;
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
private _sectionsSafe(): ThemeEditorSection[] {
|
|
589
|
+
// Lit's default converter falls back to null on bad JSON, and does nothing
|
|
590
|
+
// to coerce valid-but-non-array JSON - both would otherwise crash render().
|
|
591
|
+
return Array.isArray(this.sections) ? this.sections : [];
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private _groupsFor(section: ThemeEditorSection): ThemeEditorGroup[] {
|
|
595
|
+
return Array.isArray(section?.groups) ? section.groups : [];
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Whether any section has a populated `groups` - the switch to nested tabs+collapsibles. */
|
|
599
|
+
private _hasNestedGroups(): boolean {
|
|
600
|
+
return this._sectionsSafe().some(section => this._groupsFor(section).length > 0);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private _visibleGroups(section: ThemeEditorSection): ThemeEditorGroup[] {
|
|
604
|
+
return this._groupsFor(section).filter(group => this._hasAssignedControls(group.name));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Configured sections that have an assigned control, or (nested) a populated group - shared by every presentation. */
|
|
608
|
+
private _visibleSections(): ThemeEditorSection[] {
|
|
609
|
+
const sections = this._sectionsSafe();
|
|
610
|
+
return this._hasNestedGroups()
|
|
611
|
+
? sections.filter(section => this._visibleGroups(section).length > 0)
|
|
612
|
+
: sections.filter(section => this._hasAssignedControls(section.name));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private _renderSections() {
|
|
616
|
+
return this._visibleSections().map(section => html`
|
|
617
|
+
<zn-collapsible
|
|
618
|
+
class="editor__section"
|
|
619
|
+
part="section"
|
|
620
|
+
caption="${section.caption}"
|
|
621
|
+
description="${ifDefined(section.description)}"
|
|
622
|
+
default="${section.open ? 'open' : 'closed'}">
|
|
623
|
+
<div class="editor__section-fields">
|
|
624
|
+
<slot name="${section.name}" class="editor__section-slot" @slotchange="${this._onSlotChange}"></slot>
|
|
625
|
+
</div>
|
|
626
|
+
</zn-collapsible>`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private _renderGroups(section: ThemeEditorSection) {
|
|
630
|
+
return this._visibleGroups(section).map(group => html`
|
|
631
|
+
<zn-collapsible
|
|
632
|
+
class="editor__section"
|
|
633
|
+
part="section"
|
|
634
|
+
caption="${group.caption}"
|
|
635
|
+
description="${ifDefined(group.description)}"
|
|
636
|
+
default="${group.open ? 'open' : 'closed'}">
|
|
637
|
+
<div class="editor__section-fields">
|
|
638
|
+
<slot name="${group.name}" class="editor__section-slot" @slotchange="${this._onSlotChange}"></slot>
|
|
639
|
+
</div>
|
|
640
|
+
</zn-collapsible>`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// zn-tabs never removes a panel - it toggles `selected` on it and hides the
|
|
644
|
+
// rest via its own shadow stylesheet - so every section's slot(s) stay
|
|
645
|
+
// assigned and switching tabs can never drop a control's value from the
|
|
646
|
+
// theme. The first visible section is the initial `active` tab; once set,
|
|
647
|
+
// Lit only re-touches the attribute (resetting zn-tabs' own tracked
|
|
648
|
+
// selection) if that name actually changes between renders.
|
|
649
|
+
private _renderTabs(panel: (section: ThemeEditorSection) => unknown) {
|
|
650
|
+
const sections = this._visibleSections();
|
|
651
|
+
if (sections.length === 0) return nothing;
|
|
652
|
+
|
|
653
|
+
return html`
|
|
654
|
+
<zn-tabs class="editor__tabs" flush active="${sections[0].name}">
|
|
655
|
+
<zn-navbar slot="top" border>
|
|
656
|
+
${sections.map(section => html`<li tab="${section.name}">${section.caption}</li>`)}
|
|
657
|
+
</zn-navbar>
|
|
658
|
+
${sections.map(section => html`
|
|
659
|
+
<div id="${section.name}" class="editor__tab-panel">${panel(section)}</div>`)}
|
|
660
|
+
</zn-tabs>`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
render() {
|
|
664
|
+
return html`
|
|
665
|
+
<div part="base" class="editor ${this.controlsCollapsed ? 'editor--controls-collapsed' : ''}"
|
|
666
|
+
style="min-height: ${this.minHeight}px">
|
|
667
|
+
<div part="controls" class="editor__controls">
|
|
668
|
+
<div part="controls-header" class="editor__controls-header">
|
|
669
|
+
${this.controlsCaption ? html`<span class="editor__caption">${this.controlsCaption}</span>` : nothing}
|
|
670
|
+
<button type="button"
|
|
671
|
+
class="editor__mode"
|
|
672
|
+
data-mode-toggle
|
|
673
|
+
aria-label="${this.mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}"
|
|
674
|
+
@click="${this._toggleMode}">
|
|
675
|
+
<zn-icon src="${this.mode === 'dark' ? 'sun' : 'moon'}" library="lucide" size="16"></zn-icon>
|
|
676
|
+
</button>
|
|
677
|
+
</div>
|
|
678
|
+
<div class="editor__controls-body">
|
|
679
|
+
<div
|
|
680
|
+
class="editor__fields ${this.hasSlotController.test('[default]') ? '' : 'editor__fields--sections-only'}"
|
|
681
|
+
@zn-change="${this._onControlChange}"
|
|
682
|
+
@zn-input="${this._onControlChange}"
|
|
683
|
+
@change="${this._onControlChange}"
|
|
684
|
+
@input="${this._onControlChange}">
|
|
685
|
+
<slot @slotchange="${this._onSlotChange}"></slot>
|
|
686
|
+
${this._hasNestedGroups()
|
|
687
|
+
? this._renderTabs(section => this._renderGroups(section))
|
|
688
|
+
: this.sectionLayout === 'tabs'
|
|
689
|
+
? this._renderTabs(section => html`
|
|
690
|
+
<slot name="${section.name}" class="editor__section-slot" @slotchange="${this._onSlotChange}"></slot>`)
|
|
691
|
+
: this._renderSections()}
|
|
692
|
+
</div>
|
|
693
|
+
${this.hasSlotController.test('footer') ? html`
|
|
694
|
+
<div part="footer" class="editor__footer">
|
|
695
|
+
<slot name="footer"></slot>
|
|
696
|
+
</div>` : nothing}
|
|
697
|
+
</div>
|
|
698
|
+
</div>
|
|
699
|
+
|
|
700
|
+
<div class="editor__main">
|
|
701
|
+
<button type="button"
|
|
702
|
+
class="panel-toggle panel-toggle--left ${this.controlsCollapsed ? 'panel-toggle--tucked' : ''}"
|
|
703
|
+
title="${this.controlsCollapsed ? 'Show controls' : 'Hide controls'}"
|
|
704
|
+
aria-label="${this.controlsCollapsed ? 'Show controls' : 'Hide controls'}"
|
|
705
|
+
@click="${() => (this.controlsCollapsed = !this.controlsCollapsed)}">
|
|
706
|
+
<zn-icon src="${this.controlsCollapsed ? 'chevron-right@lu' : 'chevron-left@lu'}" size="16"></zn-icon>
|
|
707
|
+
</button>
|
|
708
|
+
|
|
709
|
+
<div part="toolbar" class="editor__toolbar">
|
|
710
|
+
${this.previewCaption ? html`<span class="editor__caption">${this.previewCaption}</span>` : nothing}
|
|
711
|
+
<div class="editor__toolbar-actions">
|
|
712
|
+
<div class="editor__devices" role="group" aria-label="Preview width">
|
|
713
|
+
${DEVICES.map(d => html`
|
|
714
|
+
<button type="button"
|
|
715
|
+
class="editor__device"
|
|
716
|
+
data-device="${d.id}"
|
|
717
|
+
aria-label="${d.label}"
|
|
718
|
+
aria-pressed="${this.device === d.id ? 'true' : 'false'}"
|
|
719
|
+
@click="${() => this._setDevice(d.id)}">
|
|
720
|
+
<zn-icon src="${d.icon}" library="lucide" size="16"></zn-icon>
|
|
721
|
+
</button>`)}
|
|
722
|
+
</div>
|
|
723
|
+
${this._sourcesSafe().length > 0 ? html`
|
|
724
|
+
<zn-select
|
|
725
|
+
class="editor__sources"
|
|
726
|
+
size="small"
|
|
727
|
+
label="Preview source"
|
|
728
|
+
hoist
|
|
729
|
+
.value="${String(this._sourceIndex)}"
|
|
730
|
+
@zn-change="${this._onSourceChange}">
|
|
731
|
+
${this._sourcesSafe().map((source, i) => html`
|
|
732
|
+
<zn-option value="${i}">${source.label}</zn-option>`)}
|
|
733
|
+
</zn-select>` : nothing}
|
|
734
|
+
<slot name="toolbar"></slot>
|
|
735
|
+
${this.submitLabel ? html`
|
|
736
|
+
<zn-button class="editor__submit"
|
|
737
|
+
color="primary"
|
|
738
|
+
@click="${this._onSubmit}"
|
|
739
|
+
?loading="${this._submitting}">${this.submitLabel}
|
|
740
|
+
</zn-button>` : nothing}
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
|
|
744
|
+
<div part="preview" class="editor__preview">
|
|
745
|
+
${this.error ? html`
|
|
746
|
+
<div part="error" class="editor__error">${this.error}</div>` : nothing}
|
|
747
|
+
<zn-preview-frame
|
|
748
|
+
src="${this._frameSrc()}"
|
|
749
|
+
frame-origin="${this.frameOrigin}"
|
|
750
|
+
data-uri="${this.dataUri}"
|
|
751
|
+
device="${this.device}"
|
|
752
|
+
min-height="${this.minHeight}"
|
|
753
|
+
fill
|
|
754
|
+
backdrop="${this.standalone ? 'panel' : 'dots'}"
|
|
755
|
+
exportparts="base:preview__base,stage:preview__stage,iframe:preview__iframe,error:preview__error"
|
|
756
|
+
@zn-error="${this._onFrameError}"></zn-preview-frame>
|
|
757
|
+
</div>
|
|
758
|
+
</div>
|
|
759
|
+
</div>`;
|
|
760
|
+
}
|
|
761
|
+
}
|