@pixl-app/sdk 0.1.1 → 1.0.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,1919 @@
1
+ import {
2
+ fontFamily2,
3
+ loadFont2,
4
+ Pixl2
5
+ } from "../index-nb1y5rx1.js";
6
+
7
+ // src/ui/styles.ts
8
+ var TOKENS = `
9
+ --bg: var(--pixl-bg, #0f0f12);
10
+ --surface: var(--pixl-surface, #22222c);
11
+ --fg: var(--pixl-fg, #e8e8f0);
12
+ --muted: var(--pixl-muted, #9a9aa8);
13
+ --line: var(--pixl-line, rgb(255 255 255 / 0.1));
14
+ --line-strong: var(--pixl-line-strong, rgb(255 255 255 / 0.16));
15
+ --accent: var(--pixl-accent, #7c6dfa);
16
+ --danger: var(--pixl-danger, #f05252);
17
+ --radius: 10px;
18
+ `;
19
+ var CONTROLS = `
20
+ .btn {
21
+ height: 34px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--line);
22
+ background: transparent; color: var(--fg); font: inherit; font-size: 14px; cursor: pointer;
23
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap;
24
+ }
25
+ .btn:hover:not(:disabled) { border-color: color-mix(in oklch, var(--fg) 35%, transparent); }
26
+ .btn:disabled { opacity: 0.45; cursor: not-allowed; }
27
+ .btn.primary { background: var(--accent); border-color: transparent; color: white; font-weight: 600; }
28
+ .btn.danger { background: var(--danger); border-color: transparent; color: white; }
29
+ .btn.ghost { border-color: transparent; }
30
+ .btn.icon { width: 34px; padding: 0; }
31
+ .input {
32
+ height: 34px; width: 100%; box-sizing: border-box; border-radius: 8px; border: 1px solid var(--line);
33
+ background: rgb(0 0 0 / 0.2); color: var(--fg); padding: 0 10px; font: inherit; font-size: 14px;
34
+ }
35
+ textarea.input { height: auto; min-height: 80px; padding: 8px 10px; }
36
+ .btn:focus-visible, .input:focus-visible, .switch:focus-visible {
37
+ outline: 2px solid var(--accent); outline-offset: 1px;
38
+ }
39
+ @media (max-width: 640px) {
40
+ .btn { height: 40px; }
41
+ .btn.icon { width: 40px; }
42
+ .input { height: 40px; }
43
+ }
44
+ `;
45
+ var SHADOW_CSS = `
46
+ :host { ${TOKENS} display: block; color: var(--fg); font-family: var(--pixl-font, system-ui, sans-serif); font-size: 14px; }
47
+ :host([hidden]) { display: none; }
48
+ * { box-sizing: border-box; }
49
+ ${CONTROLS}
50
+ .field { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; padding: 12px 0; }
51
+ .field.wide { flex-direction: column; align-items: stretch; }
52
+ .label { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
53
+ .label-text { font-weight: 500; }
54
+ .desc { color: var(--muted); font-size: 12px; }
55
+ .control { display: flex; align-items: center; gap: 8px; min-width: 0; }
56
+ .field.wide .control { width: 100%; }
57
+ .error { color: var(--danger); font-size: 12px; width: 100%; }
58
+ .skeleton { display: block; border-radius: 8px; background: var(--surface); animation: pixl-pulse 2s cubic-bezier(.4, 0, .6, 1) infinite; }
59
+ @keyframes pixl-pulse { 50% { opacity: .5; } }
60
+ @media (prefers-reduced-motion: reduce) { .skeleton { animation: none; } }
61
+ .switch {
62
+ appearance: none; width: 40px; height: 22px; border-radius: 999px; border: 1px solid var(--line);
63
+ background: var(--surface); position: relative; cursor: pointer; padding: 0; flex: none; transition: background .2s;
64
+ }
65
+ .switch::after {
66
+ content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
67
+ background: var(--fg); transition: transform .2s;
68
+ }
69
+ .switch[aria-checked="true"] { background: var(--accent); border-color: transparent; }
70
+ .switch[aria-checked="true"]::after { transform: translateX(18px); }
71
+ .count { min-width: 3ch; text-align: center; font-variant-numeric: tabular-nums; font-size: 18px; font-family: ui-monospace, monospace; }
72
+ input[type="range"] { flex: 1; min-width: 120px; accent-color: var(--accent); }
73
+ .value { min-width: 3ch; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
74
+ input[type="color"] { width: 40px; height: 34px; padding: 2px; border-radius: 8px; border: 1px solid var(--line); background: transparent; cursor: pointer; flex: none; }
75
+ .thumb { width: 48px; height: 48px; border-radius: 8px; border: 1px solid var(--line); background: rgb(0 0 0 / 0.25); object-fit: contain; flex: none; }
76
+ .thumb.empty { display: grid; place-items: center; color: var(--muted); font-size: 11px; }
77
+ .stack { display: flex; flex-direction: column; gap: 8px; min-width: 0; flex: 1; }
78
+ .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
79
+ .field:not(.wide) .control pixl-dropdown { min-width: 160px; }
80
+ .secret-wrap { position: relative; width: 100%; }
81
+ .secret-wrap .input { padding-right: 40px; }
82
+ .secret-wrap .eye { position: absolute; top: 50%; right: 3px; transform: translateY(-50%); width: 28px; height: 28px; color: var(--muted); }
83
+ .secret-wrap .eye:hover:not(:disabled) { color: var(--fg); }
84
+ `;
85
+ var PAGE_CSS = `
86
+ :where(:root) { color-scheme: dark; }
87
+ :where(body) { font-family: var(--pixl-font, system-ui, sans-serif); color: var(--pixl-fg, #e8e8f0); }
88
+ :where(pixl-field, pixl-toggle, pixl-counter, pixl-range, pixl-select, pixl-text, pixl-color, pixl-file, pixl-button, pixl-list, pixl-section, pixl-controls) { display: block; }
89
+ :where(.pixl-btn, .pixl-input, .pixl-select, .pixl-checkbox, .pixl-card, .pixl-skeleton) { ${TOKENS} }
90
+ .pixl-btn {
91
+ height: 34px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--line);
92
+ background: transparent; color: var(--fg); font: inherit; cursor: pointer;
93
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap;
94
+ }
95
+ .pixl-btn:disabled { opacity: 0.45; cursor: not-allowed; }
96
+ .pixl-btn.primary { background: var(--accent); border-color: transparent; color: white; font-weight: 600; }
97
+ .pixl-btn.danger { background: var(--danger); border-color: transparent; color: white; }
98
+ .pixl-btn.ghost { border-color: transparent; }
99
+ .pixl-input {
100
+ height: 34px; width: 100%; box-sizing: border-box; border-radius: 8px; border: 1px solid var(--line);
101
+ background: rgb(0 0 0 / 0.2); color: var(--fg); padding: 0 10px; font: inherit;
102
+ }
103
+ textarea.pixl-input { height: auto; min-height: 80px; padding: 8px 10px; }
104
+ /* Rückfall für ein <select> des Systems: Feld wie <pixl-dropdown>, die Liste bleibt die des Systems */
105
+ select.pixl-select {
106
+ appearance: none; height: 36px; width: 100%; box-sizing: border-box; border-radius: 10px;
107
+ border: 1px solid var(--line-strong); color: var(--fg); padding: 0 32px 0 12px; font: inherit; font-size: 14px;
108
+ background: rgb(0 0 0 / 0.2) no-repeat right 8px center / 16px 16px;
109
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239a9aa8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
110
+ cursor: pointer; text-overflow: ellipsis;
111
+ }
112
+ select.pixl-select:hover:not(:disabled) { border-color: color-mix(in oklch, var(--fg) 30%, transparent); }
113
+ select.pixl-select:focus-visible {
114
+ outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 25%, transparent);
115
+ }
116
+ select.pixl-select:disabled { opacity: 0.5; cursor: not-allowed; }
117
+ select.pixl-select option { background: var(--surface); color: var(--fg); }
118
+ /* Häkchen für ein normales <input type="checkbox">, sieht aus wie <pixl-checkbox> */
119
+ input[type="checkbox"].pixl-checkbox {
120
+ appearance: none; width: 16px; height: 16px; margin: 0; flex: none; vertical-align: middle; cursor: pointer;
121
+ border-radius: 5px; border: 1px solid color-mix(in oklch, var(--fg) 30%, transparent); background: rgb(0 0 0 / 0.2);
122
+ background-position: center; background-repeat: no-repeat; background-size: 12px 12px;
123
+ transition: border-color .15s, box-shadow .15s;
124
+ }
125
+ input[type="checkbox"].pixl-checkbox:hover:not(:disabled) { border-color: color-mix(in oklch, var(--fg) 50%, transparent); }
126
+ input[type="checkbox"].pixl-checkbox:checked {
127
+ border-color: transparent;
128
+ background-color: var(--accent);
129
+ background-image:
130
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"),
131
+ linear-gradient(oklch(from var(--accent) calc(l + 0.04) calc(c * 0.92) h), var(--accent));
132
+ background-size: 12px 12px, 100% 100%;
133
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.22), 0 1px 2px rgb(0 0 0 / 0.35);
134
+ }
135
+ input[type="checkbox"].pixl-checkbox:disabled { opacity: 0.45; cursor: not-allowed; }
136
+ input[type="checkbox"].pixl-checkbox:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
137
+ .pixl-card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 16px; }
138
+ .pixl-skeleton { display: block; border-radius: 8px; background: var(--surface); animation: pixl-pulse 2s cubic-bezier(.4, 0, .6, 1) infinite; }
139
+ @keyframes pixl-pulse { 50% { opacity: .5; } }
140
+ @media (prefers-reduced-motion: reduce) { .pixl-skeleton { animation: none; } }
141
+ .pixl-btn:focus-visible, .pixl-input:focus-visible { outline: 2px solid var(--pixl-accent, #7c6dfa); outline-offset: 1px; }
142
+ @media (max-width: 640px) { .pixl-btn, .pixl-input { height: 40px; } }
143
+ @media (pointer: coarse) { select.pixl-select { height: 40px; } }
144
+ `;
145
+ var sheet = null;
146
+ function adoptStyles(root, extra = "") {
147
+ if (!sheet) {
148
+ sheet = new CSSStyleSheet;
149
+ sheet.replaceSync(SHADOW_CSS);
150
+ }
151
+ const sheets = [sheet];
152
+ if (extra) {
153
+ const own = new CSSStyleSheet;
154
+ own.replaceSync(extra);
155
+ sheets.push(own);
156
+ }
157
+ root.adoptedStyleSheets = sheets;
158
+ }
159
+ function injectPageStyles() {
160
+ if (typeof document === "undefined" || document.getElementById("pixl-ui-styles"))
161
+ return;
162
+ const style = document.createElement("style");
163
+ style.id = "pixl-ui-styles";
164
+ style.textContent = PAGE_CSS;
165
+ document.head.prepend(style);
166
+ }
167
+
168
+ // src/ui/checkbox.ts
169
+ var CHECK = `<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>`;
170
+ var CSS = `
171
+ :host { display: inline-flex; vertical-align: middle; }
172
+ .control {
173
+ display: inline-flex; align-items: center; gap: 8px; cursor: pointer; outline: none;
174
+ border-radius: 6px; -webkit-tap-highlight-color: transparent;
175
+ }
176
+ .box {
177
+ display: inline-flex; align-items: center; justify-content: center; flex: none;
178
+ width: 16px; height: 16px; border-radius: 5px; border: 1px solid color-mix(in oklch, var(--fg) 30%, transparent);
179
+ background: rgb(0 0 0 / 0.2); color: white; transition: border-color .15s, background .15s, box-shadow .15s;
180
+ }
181
+ .box svg { visibility: hidden; }
182
+ .control:hover .box { border-color: color-mix(in oklch, var(--fg) 50%, transparent); }
183
+ .control[aria-checked="true"] .box {
184
+ border-color: transparent;
185
+ background: var(--accent);
186
+ background-image: linear-gradient(oklch(from var(--accent) calc(l + 0.04) calc(c * 0.92) h), var(--accent));
187
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.22), 0 1px 2px rgb(0 0 0 / 0.35);
188
+ }
189
+ .control[aria-checked="true"] .box svg { visibility: visible; }
190
+ .control:focus-visible .box { outline: 2px solid var(--accent); outline-offset: 2px; }
191
+ .control[aria-disabled="true"] { cursor: not-allowed; opacity: 0.45; }
192
+ .text { display: inline; min-width: 0; }
193
+ .text.empty { display: none; }
194
+ `;
195
+
196
+ class PixlCheckbox extends HTMLElement {
197
+ static formAssociated = true;
198
+ static observedAttributes = ["checked", "disabled", "aria-label"];
199
+ root;
200
+ control;
201
+ internals;
202
+ formDisabled = false;
203
+ defaultChecked = null;
204
+ toggledThisTask = false;
205
+ constructor() {
206
+ super();
207
+ this.internals = typeof this.attachInternals === "function" ? this.attachInternals() : null;
208
+ this.root = this.attachShadow({ mode: "open", delegatesFocus: true });
209
+ adoptStyles(this.root, CSS);
210
+ this.root.innerHTML = `<span class="control" part="control" role="checkbox" tabindex="0"><span class="box" part="box">${CHECK}</span><span class="text empty" part="label"><slot></slot></span></span>`;
211
+ this.control = this.root.querySelector(".control");
212
+ this.root.querySelector("slot").addEventListener("slotchange", () => this.syncText());
213
+ this.addEventListener("click", (e) => {
214
+ const inner = e.composedPath()[0];
215
+ if (inner instanceof Element && inner !== this && inner.closest("a, button, input, select, textarea"))
216
+ return;
217
+ this.toggleByUser();
218
+ });
219
+ this.control.addEventListener("keydown", (e) => {
220
+ if (e.key !== " ")
221
+ return;
222
+ e.preventDefault();
223
+ if (!e.repeat)
224
+ this.toggleByUser();
225
+ });
226
+ }
227
+ connectedCallback() {
228
+ if (Object.prototype.hasOwnProperty.call(this, "checked")) {
229
+ const v = this.checked;
230
+ delete this.checked;
231
+ this.checked = v === true;
232
+ }
233
+ this.defaultChecked ??= this.hasAttribute("checked");
234
+ this.sync();
235
+ this.syncText();
236
+ }
237
+ syncText() {
238
+ const slot = this.root.querySelector("slot");
239
+ if (!slot)
240
+ return;
241
+ const filled = slot.assignedNodes().some((n) => n.nodeType === Node.ELEMENT_NODE || n.textContent?.trim());
242
+ this.root.querySelector(".text").classList.toggle("empty", !filled);
243
+ }
244
+ attributeChangedCallback() {
245
+ this.sync();
246
+ }
247
+ get checked() {
248
+ return this.hasAttribute("checked");
249
+ }
250
+ set checked(v) {
251
+ this.toggleAttribute("checked", Boolean(v));
252
+ }
253
+ get disabled() {
254
+ return this.hasAttribute("disabled");
255
+ }
256
+ set disabled(v) {
257
+ this.toggleAttribute("disabled", Boolean(v));
258
+ }
259
+ get name() {
260
+ return this.getAttribute("name") ?? "";
261
+ }
262
+ get value() {
263
+ return this.getAttribute("value") ?? "on";
264
+ }
265
+ get form() {
266
+ return this.internals?.form ?? null;
267
+ }
268
+ formResetCallback() {
269
+ this.checked = this.defaultChecked === true;
270
+ }
271
+ formDisabledCallback(disabled) {
272
+ this.formDisabled = disabled;
273
+ this.sync();
274
+ }
275
+ formStateRestoreCallback(state) {
276
+ this.checked = state !== null;
277
+ }
278
+ get isDisabled() {
279
+ return this.disabled || this.formDisabled;
280
+ }
281
+ toggleByUser() {
282
+ if (this.isDisabled || this.toggledThisTask)
283
+ return;
284
+ this.toggledThisTask = true;
285
+ setTimeout(() => this.toggledThisTask = false);
286
+ this.checked = !this.checked;
287
+ this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
288
+ this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
289
+ }
290
+ sync() {
291
+ if (!this.control)
292
+ return;
293
+ const disabled = this.isDisabled;
294
+ this.control.setAttribute("aria-checked", String(this.checked));
295
+ if (disabled)
296
+ this.control.setAttribute("aria-disabled", "true");
297
+ else
298
+ this.control.removeAttribute("aria-disabled");
299
+ this.control.tabIndex = disabled ? -1 : 0;
300
+ const fromLabels = this.textContent?.trim() ? "" : [...this.internals?.labels ?? []].map((l) => l.textContent?.trim() ?? "").filter(Boolean).join(" ");
301
+ const label = this.getAttribute("aria-label") ?? fromLabels;
302
+ if (label)
303
+ this.control.setAttribute("aria-label", label);
304
+ else
305
+ this.control.removeAttribute("aria-label");
306
+ this.internals?.setFormValue(this.checked ? this.value : null);
307
+ }
308
+ }
309
+
310
+ // src/ui/runtime.ts
311
+ var pixlClass = null;
312
+ function setPixlClass(cls) {
313
+ pixlClass = cls;
314
+ }
315
+ function sharedPixl() {
316
+ if (!pixlClass)
317
+ throw new Error("[pixl] pixl-ui braucht das SDK: pixl.js vor pixl-ui.js einbinden");
318
+ return pixlClass.shared();
319
+ }
320
+ var widgetPromise = null;
321
+ function sharedWidget() {
322
+ widgetPromise ??= (async () => {
323
+ const pixl = sharedPixl();
324
+ const now = pixl.widget();
325
+ if (now)
326
+ return now;
327
+ await pixl.ready.catch(() => {});
328
+ return pixl.widget();
329
+ })();
330
+ return widgetPromise;
331
+ }
332
+ var openDialogs = 0;
333
+ function requestFullscreen(on) {
334
+ if (typeof window === "undefined" || window.parent === window)
335
+ return;
336
+ window.parent.postMessage({ type: "pixl:fullscreen", on }, "*");
337
+ }
338
+ var overflowBefore = "";
339
+ function dialogOpened() {
340
+ if (openDialogs++ > 0)
341
+ return;
342
+ overflowBefore = document.documentElement.style.overflow;
343
+ document.documentElement.style.overflow = "hidden";
344
+ requestFullscreen(true);
345
+ }
346
+ function dialogClosed() {
347
+ if (openDialogs === 0 || --openDialogs > 0)
348
+ return;
349
+ document.documentElement.style.overflow = overflowBefore;
350
+ requestFullscreen(false);
351
+ }
352
+
353
+ // src/ui/dialog.ts
354
+ var CSS2 = `
355
+ dialog {
356
+ border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--fg);
357
+ padding: 0; width: min(560px, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow: hidden;
358
+ }
359
+ :host([size="large"]) dialog { width: min(960px, calc(100vw - 16px)); }
360
+ dialog[open] { display: flex; flex-direction: column; }
361
+ dialog::backdrop { background: rgb(0 0 0 / 0.6); }
362
+ header { padding: 18px 20px 6px; }
363
+ h2 { margin: 0 0 4px; font-size: 17px; }
364
+ header p { margin: 0; color: var(--muted); }
365
+ header p:empty { display: none; }
366
+ .body { padding: 12px 20px 16px; overflow: auto; flex: 1; min-height: 0; }
367
+ footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--line); background: rgb(0 0 0 / 0.15); }
368
+ footer.empty { display: none; }
369
+ @media (max-width: 640px) {
370
+ dialog { width: 100%; max-width: 100%; height: fit-content; max-height: 100%; margin: auto 0 0; border-radius: 14px 14px 0 0; }
371
+ :host([size="large"]) dialog { width: 100%; height: 100%; margin: 0; border: 0; border-radius: 0; }
372
+ footer { padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
373
+ }
374
+ `;
375
+
376
+ class PixlDialog extends HTMLElement {
377
+ static observedAttributes = ["heading", "description"];
378
+ root;
379
+ dialog;
380
+ isOpen = false;
381
+ constructor() {
382
+ super();
383
+ this.root = this.attachShadow({ mode: "open" });
384
+ adoptStyles(this.root, CSS2);
385
+ this.root.innerHTML = `
386
+ <dialog part="dialog">
387
+ <header><h2 part="heading"></h2><p part="description"></p></header>
388
+ <div class="body" part="body"><slot></slot></div>
389
+ <footer part="actions"><slot name="actions"></slot></footer>
390
+ </dialog>`;
391
+ this.dialog = this.root.querySelector("dialog");
392
+ this.dialog.addEventListener("close", () => this.closed());
393
+ const actions = this.root.querySelector('slot[name="actions"]');
394
+ const footer = this.root.querySelector("footer");
395
+ const syncFooter = () => footer.classList.toggle("empty", actions.assignedElements().length === 0);
396
+ actions.addEventListener("slotchange", syncFooter);
397
+ syncFooter();
398
+ this.sync();
399
+ }
400
+ attributeChangedCallback() {
401
+ this.sync();
402
+ }
403
+ sync() {
404
+ this.root.querySelector("h2").textContent = this.getAttribute("heading") ?? "";
405
+ this.root.querySelector("header p").textContent = this.getAttribute("description") ?? "";
406
+ }
407
+ get opened() {
408
+ return this.isOpen;
409
+ }
410
+ open() {
411
+ if (this.isOpen)
412
+ return;
413
+ this.isOpen = true;
414
+ dialogOpened();
415
+ this.dialog.showModal();
416
+ this.dispatchEvent(new Event("open"));
417
+ }
418
+ close() {
419
+ if (this.isOpen)
420
+ this.dialog.close();
421
+ }
422
+ closed() {
423
+ if (!this.isOpen)
424
+ return;
425
+ this.isOpen = false;
426
+ dialogClosed();
427
+ this.dispatchEvent(new Event("close"));
428
+ }
429
+ disconnectedCallback() {
430
+ this.close();
431
+ }
432
+ }
433
+
434
+ // src/ui/dropdown-helpers.ts
435
+ var ITEM_HEIGHT = 32;
436
+ var LIST_CHROME = 10;
437
+ var GAP = 6;
438
+ var MIN_WIDTH = 160;
439
+ var EDGE = 8;
440
+ var MAX_HEIGHT = 320;
441
+ function normalizeOptions(input) {
442
+ if (!Array.isArray(input))
443
+ return [];
444
+ const out = [];
445
+ for (const o of input) {
446
+ if (typeof o === "string" || typeof o === "number")
447
+ out.push({ value: String(o), label: String(o) });
448
+ else if (o && typeof o === "object" && "value" in o) {
449
+ const { value, label, disabled } = o;
450
+ out.push({
451
+ value: String(value),
452
+ label: label === undefined || label === null ? String(value) : String(label),
453
+ disabled: disabled === true
454
+ });
455
+ }
456
+ }
457
+ return out;
458
+ }
459
+ function nextEnabled(options, from, step) {
460
+ for (let i = from + step;i >= 0 && i < options.length; i += step)
461
+ if (!options[i].disabled)
462
+ return i;
463
+ return -1;
464
+ }
465
+ function firstEnabled(options) {
466
+ return nextEnabled(options, -1, 1);
467
+ }
468
+ function lastEnabled(options) {
469
+ return nextEnabled(options, options.length, -1);
470
+ }
471
+ function typeaheadIndex(options, query, current) {
472
+ if (!query || !options.length)
473
+ return -1;
474
+ const q = query.toLocaleLowerCase("de");
475
+ const same = [...q].every((c) => c === q[0]);
476
+ const needle = same ? q[0] : q;
477
+ const n = options.length;
478
+ const start = same ? current + 1 : Math.max(current, 0);
479
+ for (let k = 0;k < n; k++) {
480
+ const i = ((start + k) % n + n) % n;
481
+ const o = options[i];
482
+ if (!o.disabled && o.label.toLocaleLowerCase("de").startsWith(needle))
483
+ return i;
484
+ }
485
+ return -1;
486
+ }
487
+ function placeList(trigger, listHeight, viewport, maxHeight = MAX_HEIGHT) {
488
+ const below = viewport.height - trigger.bottom - GAP - EDGE;
489
+ const aboveSpace = trigger.top - GAP - EDGE;
490
+ const above = listHeight > below && aboveSpace > below;
491
+ const space = Math.max(above ? aboveSpace : below, ITEM_HEIGHT + LIST_CHROME);
492
+ const height = Math.min(listHeight, maxHeight, space);
493
+ const width = Math.min(Math.max(trigger.width, MIN_WIDTH), Math.max(viewport.width - 2 * EDGE, 0));
494
+ const left = Math.min(Math.max(trigger.left, EDGE), Math.max(viewport.width - EDGE - width, EDGE));
495
+ return {
496
+ top: above ? trigger.top - GAP - height : trigger.bottom + GAP,
497
+ left,
498
+ width,
499
+ maxHeight: Math.min(maxHeight, space),
500
+ above
501
+ };
502
+ }
503
+
504
+ // src/ui/dropdown.ts
505
+ var CHEVRON_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>`;
506
+ var CHECK_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>`;
507
+ var CSS3 = `
508
+ :host { display: inline-block; vertical-align: middle; width: 100%; min-width: 0; }
509
+ .trigger {
510
+ display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0; height: 36px;
511
+ padding: 0 8px 0 12px; border-radius: 10px; border: 1px solid var(--line-strong);
512
+ background: rgb(0 0 0 / 0.2); color: var(--fg); font: inherit; font-size: 14px; text-align: left;
513
+ cursor: pointer; outline: none; transition: border-color .15s, box-shadow .15s;
514
+ }
515
+ .trigger:hover:not(:disabled) { border-color: color-mix(in oklch, var(--fg) 30%, transparent); }
516
+ .trigger:focus-visible {
517
+ border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 25%, transparent);
518
+ }
519
+ .trigger[aria-expanded="true"] { border-color: var(--accent); }
520
+ .trigger:disabled { cursor: not-allowed; opacity: 0.5; }
521
+ .text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
522
+ .text.empty { color: color-mix(in oklch, var(--muted) 70%, transparent); }
523
+ .chevron { display: flex; flex: none; color: var(--muted); }
524
+ .list {
525
+ position: fixed; inset: auto; margin: 0; z-index: 2147483000; box-sizing: border-box;
526
+ overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain;
527
+ padding: 4px; border: 1px solid var(--line); border-radius: 10px;
528
+ background: var(--surface); color: var(--fg); font: inherit; font-size: 14px;
529
+ box-shadow: 0 16px 40px -12px rgb(0 0 0 / 0.7); outline: none;
530
+ scrollbar-width: thin; scrollbar-color: rgb(255 255 255 / 0.15) transparent;
531
+ }
532
+ .list.fallback:not(.open) { display: none; }
533
+ .list:popover-open, .list.fallback.open { animation: pixl-pop-in 140ms cubic-bezier(0.2, 0.9, 0.3, 1.2) both; }
534
+ .list.above { --pop-y: -4px; }
535
+ @keyframes pixl-pop-in { from { opacity: 0; transform: translateY(var(--pop-y, 4px)) scale(0.98); } }
536
+ @media (prefers-reduced-motion: reduce) { .list:popover-open, .list.fallback.open { animation: none; } }
537
+ .option {
538
+ display: flex; align-items: center; gap: 8px; height: ${ITEM_HEIGHT}px; padding: 0 8px;
539
+ border-radius: 6px; cursor: default; user-select: none;
540
+ }
541
+ .option[data-highlighted] { background: rgb(255 255 255 / 0.06); }
542
+ .option[aria-disabled="true"] { opacity: 0.45; }
543
+ .option .label { display: block; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
544
+ .option .check { display: flex; flex: none; color: var(--accent); }
545
+ .option:not([aria-selected="true"]) .check { visibility: hidden; }
546
+ .none { padding: 6px 8px; color: var(--muted); }
547
+ @media (pointer: coarse) { .trigger { height: 40px; } }
548
+ `;
549
+ var supportsPopover = typeof HTMLElement !== "undefined" && "showPopover" in HTMLElement.prototype;
550
+ var counter = 0;
551
+
552
+ class PixlDropdown extends HTMLElement {
553
+ static formAssociated = true;
554
+ static observedAttributes = ["value", "placeholder", "disabled", "aria-label"];
555
+ root;
556
+ trigger;
557
+ text;
558
+ list;
559
+ internals;
560
+ uid = `pixl-dropdown-${++counter}`;
561
+ propOptions = null;
562
+ childOptions = [];
563
+ observer = null;
564
+ highlighted = -1;
565
+ query = "";
566
+ queryTimer;
567
+ formDisabled = false;
568
+ defaultValue = null;
569
+ listening = false;
570
+ frame = 0;
571
+ lastRect = "";
572
+ constructor() {
573
+ super();
574
+ this.internals = typeof this.attachInternals === "function" ? this.attachInternals() : null;
575
+ this.root = this.attachShadow({ mode: "open", delegatesFocus: true });
576
+ adoptStyles(this.root, CSS3);
577
+ this.root.innerHTML = `
578
+ <button type="button" class="trigger" part="trigger" role="combobox" aria-haspopup="listbox" aria-expanded="false">
579
+ <span class="text"></span><span class="chevron">${CHEVRON_ICON}</span>
580
+ </button>
581
+ <div class="list" part="list" role="listbox" tabindex="-1"></div>`;
582
+ this.trigger = this.root.querySelector("button");
583
+ this.text = this.root.querySelector(".text");
584
+ this.list = this.root.querySelector(".list");
585
+ this.list.id = `${this.uid}-list`;
586
+ this.trigger.setAttribute("aria-controls", this.list.id);
587
+ if (supportsPopover) {
588
+ this.list.setAttribute("popover", "auto");
589
+ this.trigger.popoverTargetElement = this.list;
590
+ this.list.addEventListener("beforetoggle", (e) => {
591
+ if (e.newState === "open")
592
+ this.beforeOpen();
593
+ });
594
+ this.list.addEventListener("toggle", (e) => {
595
+ if (e.newState === "open")
596
+ this.afterOpen();
597
+ else
598
+ this.afterClose();
599
+ });
600
+ } else {
601
+ this.list.classList.add("fallback");
602
+ this.trigger.addEventListener("click", () => this.isOpen ? this.close() : this.open());
603
+ }
604
+ this.trigger.addEventListener("keydown", (e) => this.onKeydown(e));
605
+ this.trigger.addEventListener("keyup", (e) => e.key === " " && e.preventDefault());
606
+ this.list.addEventListener("mousedown", (e) => e.preventDefault());
607
+ this.list.addEventListener("click", (e) => {
608
+ const item = e.target.closest(".option");
609
+ if (item)
610
+ this.pick(Number(item.dataset.index));
611
+ });
612
+ this.list.addEventListener("mousemove", (e) => {
613
+ const item = e.target.closest(".option");
614
+ if (item && item.getAttribute("aria-disabled") !== "true")
615
+ this.highlight(Number(item.dataset.index), false);
616
+ });
617
+ this.addEventListener("click", (e) => {
618
+ if (e.composedPath()[0] === this)
619
+ this.trigger.focus();
620
+ });
621
+ }
622
+ connectedCallback() {
623
+ for (const prop of ["options", "value"]) {
624
+ if (Object.prototype.hasOwnProperty.call(this, prop)) {
625
+ const v = this[prop];
626
+ delete this[prop];
627
+ this[prop] = v;
628
+ }
629
+ }
630
+ this.defaultValue ??= this.getAttribute("value");
631
+ this.readChildren();
632
+ this.observer ??= new MutationObserver(() => this.readChildren());
633
+ this.observer.observe(this, {
634
+ childList: true,
635
+ subtree: true,
636
+ characterData: true,
637
+ attributes: true,
638
+ attributeFilter: ["value", "label", "disabled", "selected"]
639
+ });
640
+ this.render();
641
+ }
642
+ disconnectedCallback() {
643
+ this.observer?.disconnect();
644
+ if (supportsPopover) {
645
+ if (this.list.matches(":popover-open"))
646
+ this.list.hidePopover();
647
+ } else
648
+ this.list.classList.remove("open");
649
+ if (this.listening)
650
+ this.afterClose();
651
+ }
652
+ attributeChangedCallback() {
653
+ this.render();
654
+ }
655
+ get options() {
656
+ return this.propOptions ?? this.childOptions;
657
+ }
658
+ set options(value) {
659
+ this.propOptions = value === null || value === undefined ? null : normalizeOptions(value);
660
+ this.render();
661
+ }
662
+ get value() {
663
+ const attr = this.getAttribute("value");
664
+ if (attr !== null)
665
+ return attr;
666
+ if (this.hasAttribute("placeholder"))
667
+ return "";
668
+ const marked = this.propOptions ? null : this.querySelector("option[selected]");
669
+ if (marked)
670
+ return marked.value;
671
+ const first = firstEnabled(this.options);
672
+ return first >= 0 ? this.options[first].value : "";
673
+ }
674
+ set value(v) {
675
+ this.setAttribute("value", v === null || v === undefined ? "" : String(v));
676
+ }
677
+ get disabled() {
678
+ return this.hasAttribute("disabled");
679
+ }
680
+ set disabled(v) {
681
+ this.toggleAttribute("disabled", Boolean(v));
682
+ }
683
+ get name() {
684
+ return this.getAttribute("name") ?? "";
685
+ }
686
+ get selectedLabel() {
687
+ return this.options.find((o) => o.value === this.value)?.label ?? "";
688
+ }
689
+ get form() {
690
+ return this.internals?.form ?? null;
691
+ }
692
+ open() {
693
+ if (this.isOpen || this.trigger.disabled || !this.isConnected)
694
+ return;
695
+ if (supportsPopover)
696
+ this.list.showPopover();
697
+ else {
698
+ this.beforeOpen();
699
+ this.list.classList.add("open");
700
+ this.afterOpen();
701
+ }
702
+ }
703
+ close() {
704
+ if (!this.isOpen)
705
+ return;
706
+ if (supportsPopover)
707
+ this.list.hidePopover();
708
+ else {
709
+ this.list.classList.remove("open");
710
+ this.afterClose();
711
+ }
712
+ }
713
+ get isOpen() {
714
+ return supportsPopover ? this.list.matches(":popover-open") : this.list.classList.contains("open");
715
+ }
716
+ formResetCallback() {
717
+ if (this.defaultValue === null)
718
+ this.removeAttribute("value");
719
+ else
720
+ this.setAttribute("value", this.defaultValue);
721
+ }
722
+ formDisabledCallback(disabled) {
723
+ this.formDisabled = disabled;
724
+ this.render();
725
+ }
726
+ formStateRestoreCallback(state) {
727
+ if (typeof state === "string")
728
+ this.value = state;
729
+ }
730
+ readChildren() {
731
+ const next = [...this.querySelectorAll("option")].map((o) => ({
732
+ value: o.value,
733
+ label: o.label || o.textContent?.trim() || o.value,
734
+ disabled: o.disabled
735
+ }));
736
+ if (JSON.stringify(next) === JSON.stringify(this.childOptions))
737
+ return;
738
+ this.childOptions = next;
739
+ this.render();
740
+ }
741
+ render() {
742
+ if (!this.trigger)
743
+ return;
744
+ const options = this.options;
745
+ const value = this.value;
746
+ const selected = options.find((o) => o.value === value);
747
+ this.text.textContent = selected?.label ?? this.getAttribute("placeholder") ?? "Auswählen";
748
+ this.text.classList.toggle("empty", !selected);
749
+ this.trigger.disabled = this.disabled || this.formDisabled;
750
+ const label = this.getAttribute("aria-label") ?? this.labelText();
751
+ if (label)
752
+ this.trigger.setAttribute("aria-label", label);
753
+ else
754
+ this.trigger.removeAttribute("aria-label");
755
+ this.internals?.setFormValue(selected || value ? value : null);
756
+ if (this.trigger.disabled)
757
+ this.close();
758
+ this.renderList();
759
+ }
760
+ labelText() {
761
+ const labels = this.internals?.labels;
762
+ if (!labels?.length)
763
+ return "";
764
+ return [...labels].map((l) => l.textContent?.trim() ?? "").filter(Boolean).join(" ");
765
+ }
766
+ renderList() {
767
+ const options = this.options;
768
+ const value = this.value;
769
+ if (!options.length) {
770
+ this.list.innerHTML = `<div class="none">Keine Einträge</div>`;
771
+ this.highlight(-1, false);
772
+ return;
773
+ }
774
+ this.list.replaceChildren(...options.map((o, i) => {
775
+ const item = document.createElement("div");
776
+ item.className = "option";
777
+ item.id = `${this.uid}-o${i}`;
778
+ item.dataset.index = String(i);
779
+ item.setAttribute("role", "option");
780
+ item.setAttribute("aria-selected", String(o.value === value));
781
+ if (o.disabled)
782
+ item.setAttribute("aria-disabled", "true");
783
+ const label = document.createElement("span");
784
+ label.className = "label";
785
+ label.textContent = o.label;
786
+ const check = document.createElement("span");
787
+ check.className = "check";
788
+ check.innerHTML = CHECK_ICON;
789
+ item.append(label, check);
790
+ return item;
791
+ }));
792
+ if (this.highlighted >= options.length)
793
+ this.highlighted = -1;
794
+ this.highlight(this.highlighted, false);
795
+ }
796
+ beforeOpen() {
797
+ const options = this.options;
798
+ const selected = options.findIndex((o) => o.value === this.value && !o.disabled);
799
+ this.highlighted = selected >= 0 ? selected : firstEnabled(options);
800
+ this.renderList();
801
+ this.position();
802
+ }
803
+ afterOpen() {
804
+ this.trigger.setAttribute("aria-expanded", "true");
805
+ this.highlight(this.highlighted, true);
806
+ if (this.listening)
807
+ return;
808
+ this.listening = true;
809
+ addEventListener("resize", this.onViewportChange, { passive: true });
810
+ if (!supportsPopover)
811
+ document.addEventListener("pointerdown", this.onOutsidePointer, true);
812
+ this.lastRect = this.rectKey();
813
+ this.frame = requestAnimationFrame(this.follow);
814
+ }
815
+ afterClose() {
816
+ this.trigger.setAttribute("aria-expanded", "false");
817
+ this.trigger.removeAttribute("aria-activedescendant");
818
+ this.listening = false;
819
+ cancelAnimationFrame(this.frame);
820
+ removeEventListener("resize", this.onViewportChange);
821
+ if (!supportsPopover)
822
+ document.removeEventListener("pointerdown", this.onOutsidePointer, true);
823
+ }
824
+ onViewportChange = () => this.position();
825
+ rectKey() {
826
+ const r = this.trigger.getBoundingClientRect();
827
+ return `${r.top},${r.left},${r.width},${r.height}`;
828
+ }
829
+ follow = () => {
830
+ if (!this.listening)
831
+ return;
832
+ const key = this.rectKey();
833
+ if (key !== this.lastRect) {
834
+ this.lastRect = key;
835
+ this.position();
836
+ }
837
+ this.frame = requestAnimationFrame(this.follow);
838
+ };
839
+ onOutsidePointer = (e) => {
840
+ if (!e.composedPath().includes(this))
841
+ this.close();
842
+ };
843
+ position() {
844
+ const r = this.trigger.getBoundingClientRect();
845
+ const height = Math.max(this.options.length, 1) * ITEM_HEIGHT + LIST_CHROME;
846
+ const p = placeList(r, height, { width: innerWidth, height: innerHeight });
847
+ const s = this.list.style;
848
+ s.top = `${p.top}px`;
849
+ s.left = `${p.left}px`;
850
+ s.width = `${p.width}px`;
851
+ s.maxHeight = `${p.maxHeight}px`;
852
+ this.list.classList.toggle("above", p.above);
853
+ }
854
+ highlight(index, scroll) {
855
+ this.highlighted = index;
856
+ for (const item of this.list.querySelectorAll(".option"))
857
+ item.toggleAttribute("data-highlighted", Number(item.dataset.index) === index);
858
+ const item = index >= 0 ? this.list.querySelector(`#${this.uid}-o${index}`) : null;
859
+ if (item && this.isOpen) {
860
+ this.trigger.setAttribute("aria-activedescendant", item.id);
861
+ if (scroll)
862
+ item.scrollIntoView({ block: "nearest" });
863
+ } else
864
+ this.trigger.removeAttribute("aria-activedescendant");
865
+ }
866
+ pick(index) {
867
+ const option = this.options[index];
868
+ if (!option || option.disabled)
869
+ return;
870
+ const changed = option.value !== this.value;
871
+ this.value = option.value;
872
+ this.close();
873
+ this.trigger.focus();
874
+ if (!changed)
875
+ return;
876
+ this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
877
+ this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
878
+ }
879
+ onKeydown(e) {
880
+ if (e.altKey || e.ctrlKey || e.metaKey) {
881
+ if (e.altKey && e.key === "ArrowUp" && this.isOpen) {
882
+ e.preventDefault();
883
+ this.pick(this.highlighted);
884
+ }
885
+ return;
886
+ }
887
+ const options = this.options;
888
+ const open = this.isOpen;
889
+ const move = (index) => {
890
+ if (index >= 0)
891
+ this.highlight(index, true);
892
+ };
893
+ switch (e.key) {
894
+ case "ArrowDown":
895
+ case "ArrowUp":
896
+ e.preventDefault();
897
+ if (!open)
898
+ return this.open();
899
+ return move(nextEnabled(options, this.highlighted, e.key === "ArrowDown" ? 1 : -1));
900
+ case "Home":
901
+ case "PageUp":
902
+ if (!open)
903
+ return;
904
+ e.preventDefault();
905
+ return move(firstEnabled(options));
906
+ case "End":
907
+ case "PageDown":
908
+ if (!open)
909
+ return;
910
+ e.preventDefault();
911
+ return move(lastEnabled(options));
912
+ case "Enter":
913
+ e.preventDefault();
914
+ return open ? this.pick(this.highlighted) : this.open();
915
+ case " ":
916
+ e.preventDefault();
917
+ if (this.query)
918
+ return this.typeahead(" ");
919
+ return open ? this.pick(this.highlighted) : this.open();
920
+ case "Escape":
921
+ if (!open)
922
+ return;
923
+ e.preventDefault();
924
+ e.stopPropagation();
925
+ return this.close();
926
+ case "Tab":
927
+ if (open)
928
+ this.close();
929
+ return;
930
+ default:
931
+ if (e.key.length === 1) {
932
+ e.preventDefault();
933
+ this.typeahead(e.key);
934
+ }
935
+ }
936
+ }
937
+ typeahead(char) {
938
+ clearTimeout(this.queryTimer);
939
+ this.query += char;
940
+ this.queryTimer = setTimeout(() => this.query = "", 500);
941
+ const options = this.options;
942
+ const from = this.isOpen ? this.highlighted : options.findIndex((o) => o.value === this.value);
943
+ const index = typeaheadIndex(options, this.query, from);
944
+ if (index < 0)
945
+ return;
946
+ if (this.isOpen)
947
+ this.highlight(index, true);
948
+ else if (options[index].value !== this.value) {
949
+ this.value = options[index].value;
950
+ this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
951
+ this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
952
+ }
953
+ }
954
+ }
955
+
956
+ // src/ui/inputs.ts
957
+ function h(tag, props = {}, ...children) {
958
+ const el = document.createElement(tag);
959
+ for (const [k, v] of Object.entries(props)) {
960
+ if (k.startsWith("aria-") || k === "role" || k === "type")
961
+ el.setAttribute(k, String(v));
962
+ else
963
+ el[k] = v;
964
+ }
965
+ el.append(...children);
966
+ return el;
967
+ }
968
+ function createInput(field, value, handlers, ctx) {
969
+ switch (field.type) {
970
+ case "boolean": {
971
+ const sw = h("button", { type: "button", className: "switch", role: "switch", "aria-label": ctx.label });
972
+ let current = value === true;
973
+ const sync = () => sw.setAttribute("aria-checked", String(current));
974
+ sw.onclick = () => {
975
+ current = !current;
976
+ sync();
977
+ handlers.set(current);
978
+ };
979
+ sync();
980
+ return {
981
+ el: sw,
982
+ update: (v) => {
983
+ current = v === true;
984
+ sync();
985
+ }
986
+ };
987
+ }
988
+ case "number": {
989
+ if (field.slider)
990
+ return sliderInput(field, value, handlers, ctx);
991
+ const step = field.step ?? 1;
992
+ const out = h("output", { className: "count" }, String(value ?? 0));
993
+ let current = typeof value === "number" ? value : 0;
994
+ const change = (d) => {
995
+ if (handlers.add)
996
+ handlers.add(d);
997
+ else {
998
+ current += d;
999
+ out.textContent = String(current);
1000
+ handlers.set(current);
1001
+ }
1002
+ bounds();
1003
+ };
1004
+ const minus = h("button", { type: "button", className: "btn icon", "aria-label": `${ctx.label} verringern` }, "−");
1005
+ const plus = h("button", { type: "button", className: "btn icon", "aria-label": `${ctx.label} erhöhen` }, "+");
1006
+ const bounds = () => {
1007
+ minus.disabled = field.min !== undefined && current <= field.min;
1008
+ plus.disabled = field.max !== undefined && current >= field.max;
1009
+ };
1010
+ minus.onclick = () => change(-step);
1011
+ plus.onclick = () => change(step);
1012
+ bounds();
1013
+ return {
1014
+ el: h("div", { className: "control" }, minus, out, plus),
1015
+ update: (v) => {
1016
+ current = typeof v === "number" ? v : 0;
1017
+ out.textContent = String(current);
1018
+ bounds();
1019
+ }
1020
+ };
1021
+ }
1022
+ case "enum": {
1023
+ const select = document.createElement("pixl-dropdown");
1024
+ select.setAttribute("aria-label", ctx.label);
1025
+ select.options = field.options.map((o) => ({ value: o, label: o }));
1026
+ select.value = String(value ?? field.options[0] ?? "");
1027
+ select.addEventListener("change", () => handlers.set(select.value));
1028
+ return { el: select, update: (v) => select.value = String(v ?? "") };
1029
+ }
1030
+ case "string": {
1031
+ if (field.sensitive)
1032
+ return secretInput(field, value, handlers, ctx);
1033
+ const input = field.multiline ? h("textarea", { className: "input", "aria-label": ctx.label }) : h("input", { className: "input", "aria-label": ctx.label });
1034
+ if (field.maxLength)
1035
+ input.maxLength = field.maxLength;
1036
+ input.value = typeof value === "string" ? value : "";
1037
+ let committed = input.value;
1038
+ const commit = () => {
1039
+ if (input.value === committed)
1040
+ return;
1041
+ committed = input.value;
1042
+ handlers.set(input.value);
1043
+ };
1044
+ input.onblur = commit;
1045
+ input.onkeydown = (e) => {
1046
+ if (e instanceof KeyboardEvent && e.key === "Enter" && !field.multiline) {
1047
+ e.preventDefault();
1048
+ commit();
1049
+ }
1050
+ };
1051
+ return {
1052
+ el: input,
1053
+ update: (v) => {
1054
+ if (document.activeElement === input)
1055
+ return;
1056
+ input.value = typeof v === "string" ? v : "";
1057
+ committed = input.value;
1058
+ }
1059
+ };
1060
+ }
1061
+ case "color": {
1062
+ const picker = h("input", { type: "color", "aria-label": ctx.label });
1063
+ const hex = h("input", { className: "input", "aria-label": `${ctx.label} als Hex`, maxLength: 9 });
1064
+ hex.style.fontFamily = "ui-monospace, monospace";
1065
+ const show = (v) => {
1066
+ const s = typeof v === "string" ? v : "#ffffff";
1067
+ picker.value = s.slice(0, 7);
1068
+ if (document.activeElement !== hex)
1069
+ hex.value = s;
1070
+ };
1071
+ show(value);
1072
+ picker.onchange = () => {
1073
+ hex.value = picker.value;
1074
+ handlers.set(picker.value);
1075
+ };
1076
+ hex.onchange = () => /^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(hex.value) && handlers.set(hex.value);
1077
+ return { el: h("div", { className: "control" }, picker, hex), update: show };
1078
+ }
1079
+ case "file":
1080
+ return fileInput(field, value, handlers, ctx);
1081
+ case "font":
1082
+ return fontInput(value, handlers, ctx);
1083
+ case "voice":
1084
+ return voiceInput(value, handlers, ctx);
1085
+ case "trigger": {
1086
+ const btn = h("button", { type: "button", className: "btn" }, ctx.label);
1087
+ btn.onclick = () => handlers.press?.();
1088
+ return { el: btn, update: () => {} };
1089
+ }
1090
+ }
1091
+ }
1092
+ var POPULAR_FONTS = ("Roboto,Open Sans,Inter,Montserrat,Poppins,Lato,Arimo,Roboto Condensed,Roboto Mono,Oswald,Noto Sans," + "DM Sans,Raleway,Nunito Sans,Playfair Display,Nunito,Manrope,Roboto Slab,Ubuntu,Plus Jakarta Sans," + "JetBrains Mono,Kanit,Mulish,Figtree,PT Sans,Rubik,Black Ops One,Archivo Black,Saira,Outfit," + "Source Sans 3,Jost,Quicksand,Source Code Pro,Inconsolata,Merriweather,Prompt,Share Tech,Fira Sans," + "Lora,Space Grotesk,Smooch Sans,Work Sans,Archivo,Karla,Bebas Neue,Noto Serif,Heebo,IBM Plex Sans," + "Press Start 2P").split(",");
1093
+ var fontLists = 0;
1094
+ function fontInput(value, handlers, ctx) {
1095
+ const listId = `pixl-fonts-${++fontLists}`;
1096
+ const input = h("input", {
1097
+ className: "input",
1098
+ "aria-label": ctx.label,
1099
+ placeholder: "Schrift des Widgets",
1100
+ autocomplete: "off",
1101
+ spellcheck: false,
1102
+ maxLength: 100
1103
+ });
1104
+ input.setAttribute("list", listId);
1105
+ const list = h("datalist", { id: listId }, ...POPULAR_FONTS.map((f) => h("option", { value: f })));
1106
+ let committed = "";
1107
+ let applied = "";
1108
+ const show = (v) => {
1109
+ const name = typeof v === "string" ? v : "";
1110
+ committed = name;
1111
+ if (document.activeElement !== input)
1112
+ input.value = name;
1113
+ if (applied === name)
1114
+ return;
1115
+ applied = "";
1116
+ input.style.fontFamily = "";
1117
+ if (!name)
1118
+ return;
1119
+ loadFont2(name).then(() => {
1120
+ if (committed !== name)
1121
+ return;
1122
+ applied = name;
1123
+ input.style.fontFamily = fontFamily2(name, "system-ui, sans-serif");
1124
+ });
1125
+ };
1126
+ const commit = () => {
1127
+ const next = (input.value.split(",")[0] ?? "").replace(/\s+/g, " ").replace(/[\p{Cc}"'`;{}<>\\]/gu, "").replace(/ {2,}/g, " ").trim().slice(0, 100).trim();
1128
+ input.value = next;
1129
+ if (next === committed)
1130
+ return;
1131
+ show(next);
1132
+ handlers.set(next);
1133
+ };
1134
+ input.onblur = commit;
1135
+ input.onkeydown = (e) => {
1136
+ if (e instanceof KeyboardEvent && e.key === "Enter") {
1137
+ e.preventDefault();
1138
+ commit();
1139
+ }
1140
+ };
1141
+ show(value);
1142
+ return { el: h("div", { className: "control", style: "width:100%" }, input, list), update: show };
1143
+ }
1144
+ function sliderInput(field, value, handlers, ctx) {
1145
+ const min = field.min ?? 0;
1146
+ const input = h("input", {
1147
+ type: "range",
1148
+ min: String(min),
1149
+ max: String(field.max ?? 100),
1150
+ step: String(field.step ?? 1),
1151
+ value: String(value ?? min),
1152
+ "aria-label": ctx.label
1153
+ });
1154
+ const shown = h("span", { className: "value" }, String(value ?? min));
1155
+ input.oninput = () => shown.textContent = input.value;
1156
+ input.onchange = () => handlers.set(Number(input.value));
1157
+ return {
1158
+ el: h("div", { className: "control" }, input, shown),
1159
+ update: (v) => {
1160
+ if (document.activeElement === input)
1161
+ return;
1162
+ input.value = String(v);
1163
+ shown.textContent = String(v);
1164
+ }
1165
+ };
1166
+ }
1167
+ function fileInput(field, value, handlers, ctx) {
1168
+ const widget = ctx.widget;
1169
+ const thumb = h("img", { className: "thumb", alt: "" });
1170
+ const url = h("input", { className: "input", placeholder: "oder https://…", "aria-label": `${ctx.label}, Adresse` });
1171
+ const picker = h("input", { type: "file", accept: field.accept ?? "", hidden: true });
1172
+ const upload = h("button", { type: "button", className: "btn" }, "Hochladen");
1173
+ const clear = h("button", { type: "button", className: "btn ghost" }, "Entfernen");
1174
+ let current = typeof value === "string" ? value : "";
1175
+ const show = () => {
1176
+ const src = current && widget ? widget.fileUrl(current) : current;
1177
+ thumb.hidden = !src;
1178
+ if (src)
1179
+ thumb.src = src;
1180
+ clear.hidden = !current;
1181
+ if (document.activeElement !== url)
1182
+ url.value = current.startsWith("https://") ? current : "";
1183
+ };
1184
+ const set = (v) => {
1185
+ current = v;
1186
+ show();
1187
+ handlers.set(v);
1188
+ };
1189
+ upload.onclick = () => picker.click();
1190
+ picker.onchange = async () => {
1191
+ const file = picker.files?.[0];
1192
+ picker.value = "";
1193
+ if (!file || !widget)
1194
+ return;
1195
+ upload.disabled = true;
1196
+ upload.textContent = "Lädt hoch …";
1197
+ try {
1198
+ set((await widget.files.upload(file)).path);
1199
+ } catch (e) {
1200
+ console.error("[pixl] Hochladen fehlgeschlagen:", e);
1201
+ alert("Hochladen fehlgeschlagen");
1202
+ } finally {
1203
+ upload.disabled = false;
1204
+ upload.textContent = "Hochladen";
1205
+ }
1206
+ };
1207
+ clear.onclick = () => set("");
1208
+ url.onchange = () => {
1209
+ const v = url.value.trim();
1210
+ if (!v || v.startsWith("https://"))
1211
+ set(v);
1212
+ };
1213
+ show();
1214
+ return {
1215
+ el: h("div", { className: "control", style: "align-items:flex-start;width:100%" }, thumb, h("div", { className: "stack" }, h("div", { className: "row" }, upload, clear, picker), url)),
1216
+ update: (v) => {
1217
+ current = typeof v === "string" ? v : "";
1218
+ show();
1219
+ }
1220
+ };
1221
+ }
1222
+ var EYE_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/></svg>`;
1223
+ var EYE_OFF_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.53 13.53 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="m2 2 20 20"/></svg>`;
1224
+ function secretInput(field, value, handlers, ctx) {
1225
+ const input = h("input", {
1226
+ type: "password",
1227
+ className: "input secret",
1228
+ "aria-label": ctx.label,
1229
+ autocomplete: "off",
1230
+ autocapitalize: "off",
1231
+ spellcheck: false
1232
+ });
1233
+ for (const attr of ["data-1p-ignore", "data-bwignore"])
1234
+ input.setAttribute(attr, "");
1235
+ input.setAttribute("data-lpignore", "true");
1236
+ input.setAttribute("data-form-type", "other");
1237
+ if (field.maxLength)
1238
+ input.maxLength = field.maxLength;
1239
+ const eye = h("button", { type: "button", className: "btn ghost icon eye" });
1240
+ const wrap = h("div", { className: "secret-wrap" }, input, eye);
1241
+ let revealed = false;
1242
+ const show = (on) => {
1243
+ revealed = on;
1244
+ input.type = on ? "text" : "password";
1245
+ eye.innerHTML = on ? EYE_OFF_ICON : EYE_ICON;
1246
+ eye.setAttribute("aria-label", on ? `${ctx.label} verbergen` : `${ctx.label} anzeigen`);
1247
+ eye.setAttribute("aria-pressed", String(on));
1248
+ };
1249
+ show(false);
1250
+ eye.onclick = () => show(!revealed);
1251
+ wrap.addEventListener("focusout", (e) => {
1252
+ if (!wrap.contains(e.relatedTarget))
1253
+ show(false);
1254
+ });
1255
+ input.value = typeof value === "string" ? value : "";
1256
+ let committed = input.value;
1257
+ const commit = () => {
1258
+ const next = input.value.trim();
1259
+ input.value = next;
1260
+ if (next === committed)
1261
+ return;
1262
+ committed = next;
1263
+ handlers.set(next);
1264
+ };
1265
+ input.onblur = commit;
1266
+ input.onkeydown = (e) => {
1267
+ if (e instanceof KeyboardEvent && e.key === "Enter") {
1268
+ e.preventDefault();
1269
+ commit();
1270
+ }
1271
+ };
1272
+ return {
1273
+ el: wrap,
1274
+ update: (v) => {
1275
+ if (document.activeElement === input)
1276
+ return;
1277
+ input.value = typeof v === "string" ? v : "";
1278
+ committed = input.value;
1279
+ }
1280
+ };
1281
+ }
1282
+ var TIER_SUFFIX = { ai: " (KI)", custom: " (eigene)" };
1283
+ var TIER_ORDER = { standard: 0, ai: 1, custom: 2 };
1284
+ function voiceInput(value, handlers, ctx) {
1285
+ const select = document.createElement("pixl-dropdown");
1286
+ select.setAttribute("aria-label", ctx.label);
1287
+ select.setAttribute("disabled", "");
1288
+ const hint = h("span", { className: "desc", hidden: true });
1289
+ let current = typeof value === "string" ? value : "";
1290
+ let known = new Map;
1291
+ let channelName = "";
1292
+ const fill = () => {
1293
+ const options = [
1294
+ { value: "", label: channelName ? `Stimme des Kanals (${channelName})` : "Stimme des Kanals" },
1295
+ ...[...known].map(([id, label]) => ({ value: id, label }))
1296
+ ];
1297
+ if (current && !known.has(current))
1298
+ options.push({ value: current, label: "Unbekannte Stimme" });
1299
+ select.options = options;
1300
+ select.value = current;
1301
+ };
1302
+ fill();
1303
+ select.addEventListener("change", () => {
1304
+ current = select.value;
1305
+ handlers.set(current);
1306
+ });
1307
+ const widget = ctx.widget;
1308
+ if (!widget)
1309
+ hint.textContent = "Stimmen gibt es nur in gehosteten Widgets";
1310
+ else
1311
+ widget.voices().then((data) => {
1312
+ const sorted = [...data.voices].sort((a, b) => (TIER_ORDER[a.tier ?? "standard"] ?? 3) - (TIER_ORDER[b.tier ?? "standard"] ?? 3) || a.name.localeCompare(b.name, "de"));
1313
+ known = new Map(sorted.map((v) => [v.id, `${v.name}${TIER_SUFFIX[v.tier ?? ""] ?? ""}`]));
1314
+ channelName = data.voices.find((v) => v.id === data.default)?.name ?? "";
1315
+ fill();
1316
+ if (data.enabled)
1317
+ select.removeAttribute("disabled");
1318
+ else {
1319
+ hint.textContent = "TTS ist für diesen Kanal ausgeschaltet.";
1320
+ hint.hidden = false;
1321
+ }
1322
+ }).catch(() => {
1323
+ hint.textContent = "Stimmen konnten nicht geladen werden.";
1324
+ hint.hidden = false;
1325
+ });
1326
+ if (!widget)
1327
+ hint.hidden = false;
1328
+ return {
1329
+ el: h("div", { className: "stack" }, select, hint),
1330
+ update: (v) => {
1331
+ current = typeof v === "string" ? v : "";
1332
+ fill();
1333
+ }
1334
+ };
1335
+ }
1336
+
1337
+ // src/ui/field.ts
1338
+ var isWide = (def) => def.type === "string" || def.type === "file" || def.type === "font" || def.type === "voice" || def.type === "number" && !!def.slider;
1339
+ var PLACEHOLDER = {
1340
+ "pixl-toggle": [40, 22, 999],
1341
+ "pixl-counter": [116, 34, 999],
1342
+ "pixl-button": [120, 34, 999],
1343
+ "pixl-color": [120, 34, 8]
1344
+ };
1345
+
1346
+ class WidgetBoundElement extends HTMLElement {
1347
+ widget = null;
1348
+ root;
1349
+ unsubscribe = null;
1350
+ connected = false;
1351
+ constructor() {
1352
+ super();
1353
+ this.root = this.attachShadow({ mode: "open" });
1354
+ }
1355
+ get fieldName() {
1356
+ return this.getAttribute("field") ?? "";
1357
+ }
1358
+ get scope() {
1359
+ const attr = this.getAttribute("scope");
1360
+ if (attr === "settings" || attr === "controls")
1361
+ return attr;
1362
+ return this.widget?.manifest?.settings?.[this.fieldName] ? "settings" : "controls";
1363
+ }
1364
+ get definition() {
1365
+ const m = this.widget?.manifest;
1366
+ const name = this.fieldName;
1367
+ return (this.scope === "settings" ? m?.settings?.[name] : m?.controls?.[name]) ?? null;
1368
+ }
1369
+ connectedCallback() {
1370
+ this.connected = true;
1371
+ if (!this.widget) {
1372
+ this.setAttribute("aria-busy", "true");
1373
+ this.renderPlaceholder();
1374
+ }
1375
+ sharedWidget().then((widget) => {
1376
+ if (!this.connected)
1377
+ return;
1378
+ this.removeAttribute("aria-busy");
1379
+ this.widget = widget;
1380
+ if (!widget)
1381
+ return this.renderMessage("Nur in gehosteten Widgets verfügbar");
1382
+ const live = this.scope === "settings" ? widget.settings[this.fieldName] : widget.values[this.fieldName];
1383
+ this.render();
1384
+ this.unsubscribe = live?.subscribe((v) => this.onValue(v)) ?? null;
1385
+ });
1386
+ }
1387
+ disconnectedCallback() {
1388
+ this.connected = false;
1389
+ this.unsubscribe?.();
1390
+ this.unsubscribe = null;
1391
+ }
1392
+ async write(op) {
1393
+ if (!this.widget)
1394
+ return false;
1395
+ try {
1396
+ await this.widget.batch([{ ...op, name: this.fieldName, scope: this.scope }]);
1397
+ this.showError(null);
1398
+ return true;
1399
+ } catch (e) {
1400
+ this.showError(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
1401
+ return false;
1402
+ }
1403
+ }
1404
+ showError(message) {
1405
+ let el = this.root.querySelector(".error");
1406
+ if (!message)
1407
+ return el?.remove();
1408
+ if (!el) {
1409
+ el = document.createElement("p");
1410
+ el.className = "error";
1411
+ el.setAttribute("role", "alert");
1412
+ this.root.querySelector(".field, .list")?.append(el);
1413
+ }
1414
+ el.textContent = message;
1415
+ }
1416
+ renderPlaceholder() {
1417
+ adoptStyles(this.root);
1418
+ const [width, height, radius] = PLACEHOLDER[this.localName] ?? [96, 34, 8];
1419
+ const field = document.createElement("div");
1420
+ field.className = "field";
1421
+ const text = this.getAttribute("label") ?? this.textContent?.trim();
1422
+ if (this.localName !== "pixl-button") {
1423
+ const label = document.createElement("span");
1424
+ if (text) {
1425
+ label.className = "label-text";
1426
+ label.textContent = text;
1427
+ } else {
1428
+ label.className = "skeleton";
1429
+ label.style.cssText = "height:14px;width:min(160px,45%)";
1430
+ label.setAttribute("aria-hidden", "true");
1431
+ }
1432
+ field.append(label);
1433
+ }
1434
+ const control = document.createElement("span");
1435
+ control.className = "skeleton";
1436
+ control.style.cssText = `width:${width}px;height:${height}px;border-radius:${radius}px`;
1437
+ control.setAttribute("aria-hidden", "true");
1438
+ field.append(control);
1439
+ this.root.replaceChildren(field);
1440
+ }
1441
+ renderMessage(message) {
1442
+ adoptStyles(this.root);
1443
+ this.root.innerHTML = `<p class="desc">${message}</p>`;
1444
+ }
1445
+ }
1446
+
1447
+ class PixlField extends WidgetBoundElement {
1448
+ static observedAttributes = ["field", "scope", "label", "description"];
1449
+ input = null;
1450
+ attributeChangedCallback() {
1451
+ if (this.widget)
1452
+ this.render();
1453
+ }
1454
+ render() {
1455
+ adoptStyles(this.root);
1456
+ const def = this.definition;
1457
+ if (!def)
1458
+ return this.renderMessage(`Feld „${this.fieldName}“ steht nicht in pixl.json`);
1459
+ if (def.type === "list")
1460
+ return this.renderMessage(`Für Listen <pixl-list field="${this.fieldName}"> nehmen`);
1461
+ const label = this.getAttribute("label") ?? (this.textContent?.trim() || def.label);
1462
+ const description = this.getAttribute("description") ?? def.description ?? "";
1463
+ const value = this.scope === "settings" ? this.widget?.settings[this.fieldName]?.value : this.widget?.values[this.fieldName]?.value;
1464
+ const field = document.createElement("div");
1465
+ field.className = `field${isWide(def) ? " wide" : ""}`;
1466
+ this.input = createInput(def, value, {
1467
+ set: (v) => void this.write({ set: v }),
1468
+ add: (d) => void this.write({ add: d }),
1469
+ press: () => void this.write({ press: true })
1470
+ }, { label, widget: this.widget });
1471
+ if (def.type !== "trigger") {
1472
+ const text = document.createElement("div");
1473
+ text.className = "label";
1474
+ text.innerHTML = `<span class="label-text"><slot></slot></span>`;
1475
+ text.querySelector("slot").textContent = label;
1476
+ if (description) {
1477
+ const desc = document.createElement("span");
1478
+ desc.className = "desc";
1479
+ desc.textContent = description;
1480
+ text.append(desc);
1481
+ }
1482
+ field.append(text);
1483
+ }
1484
+ const control = document.createElement("div");
1485
+ control.className = "control";
1486
+ control.append(this.input.el);
1487
+ field.append(control);
1488
+ this.root.replaceChildren(field);
1489
+ }
1490
+ onValue(value) {
1491
+ this.input?.update(value);
1492
+ }
1493
+ }
1494
+
1495
+ class PixlToggle extends PixlField {
1496
+ }
1497
+
1498
+ class PixlCounter extends PixlField {
1499
+ }
1500
+
1501
+ class PixlRange extends PixlField {
1502
+ }
1503
+
1504
+ class PixlSelect extends PixlField {
1505
+ }
1506
+
1507
+ class PixlText extends PixlField {
1508
+ }
1509
+
1510
+ class PixlColor extends PixlField {
1511
+ }
1512
+
1513
+ class PixlFile extends PixlField {
1514
+ }
1515
+
1516
+ class PixlButton extends PixlField {
1517
+ }
1518
+
1519
+ // src/ui/list.ts
1520
+ var CSS4 = `
1521
+ .list { display: flex; flex-direction: column; gap: 10px; }
1522
+ /* Einträge vertieft in der Karte des Felds, statt eines zweiten Rahmens */
1523
+ ul {
1524
+ list-style: none; margin: 0; padding: 0; border-radius: var(--radius);
1525
+ background: rgb(0 0 0 / 0.2); box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.25);
1526
+ }
1527
+ li.row-item { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 8px 12px; }
1528
+ li.row-item + li.row-item { border-top: 1px solid var(--line); }
1529
+ .name { flex: 1; min-width: 6rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1530
+ .actions { margin-left: auto; display: flex; gap: 2px; }
1531
+ .img { width: 36px; height: 36px; border-radius: 6px; border: 1px solid var(--line); object-fit: contain; background: rgb(0 0 0 / 0.25); flex: none; }
1532
+ .tiles {
1533
+ display: flex; flex-wrap: wrap; gap: 8px; padding: 12px; border-radius: var(--radius); min-height: 72px; align-items: center;
1534
+ background: rgb(0 0 0 / 0.2); box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.25);
1535
+ }
1536
+ .tile { border: 0; padding: 0; background: transparent; color: inherit; font: inherit; cursor: grab; touch-action: pan-y; user-select: none; border-radius: 8px; }
1537
+ .tile.dragging { opacity: 0.35; }
1538
+ .tile.before { box-shadow: -4px 0 0 var(--accent); }
1539
+ .tile.after { box-shadow: 4px 0 0 var(--accent); }
1540
+ .empty { color: var(--muted); padding: 16px; text-align: center; border-radius: var(--radius); background: rgb(0 0 0 / 0.15); }
1541
+ .tools { display: flex; flex-wrap: wrap; gap: 8px; }
1542
+ .form { display: flex; flex-direction: column; gap: 14px; }
1543
+ .form label { display: flex; flex-direction: column; gap: 6px; font-weight: 500; }
1544
+ `;
1545
+
1546
+ class PixlList extends WidgetBoundElement {
1547
+ static observedAttributes = ["field", "scope", "layout"];
1548
+ renderItem = null;
1549
+ items = [];
1550
+ dialog = null;
1551
+ attributeChangedCallback() {
1552
+ if (this.widget)
1553
+ this.render();
1554
+ }
1555
+ get listField() {
1556
+ const def = this.definition;
1557
+ return def?.type === "list" ? def : null;
1558
+ }
1559
+ onValue(value) {
1560
+ this.items = Array.isArray(value) ? value : [];
1561
+ this.renderItems();
1562
+ }
1563
+ renderPlaceholder() {
1564
+ adoptStyles(this.root, CSS4);
1565
+ const tile = `<span class="skeleton" style="width:72px;height:72px"></span>`;
1566
+ const row = `<li class="row-item"><span class="skeleton" style="height:14px;width:45%;margin:11px 0"></span></li>`;
1567
+ this.root.innerHTML = this.getAttribute("layout") === "tiles" ? `<div class="tiles" aria-hidden="true">${tile.repeat(3)}</div>` : `<ul aria-hidden="true">${row.repeat(3)}</ul>`;
1568
+ }
1569
+ render() {
1570
+ adoptStyles(this.root, CSS4);
1571
+ const def = this.listField;
1572
+ if (!def)
1573
+ return this.renderMessage(`„${this.fieldName}“ ist in pixl.json kein list-Feld`);
1574
+ this.root.innerHTML = `
1575
+ <div class="list">
1576
+ <div class="items"></div>
1577
+ <div class="tools">
1578
+ <button type="button" class="btn add">+ Hinzufügen</button>
1579
+ </div>
1580
+ </div>
1581
+ <pixl-dialog size="large"></pixl-dialog>`;
1582
+ this.dialog = this.root.querySelector("pixl-dialog");
1583
+ this.root.querySelector(".add").onclick = () => this.edit(null);
1584
+ this.renderItems();
1585
+ }
1586
+ labelOf(item) {
1587
+ const def = this.listField;
1588
+ const preferred = def?.itemLabel ? item[def.itemLabel] : null;
1589
+ if (typeof preferred === "string" && preferred)
1590
+ return preferred;
1591
+ for (const [key, f] of Object.entries(def?.item ?? {}))
1592
+ if ((f.type === "string" || f.type === "enum") && typeof item[key] === "string" && item[key])
1593
+ return item[key];
1594
+ return "Ohne Namen";
1595
+ }
1596
+ renderItems() {
1597
+ const host = this.root.querySelector(".items");
1598
+ const def = this.listField;
1599
+ if (!host || !def)
1600
+ return;
1601
+ const add = this.root.querySelector(".add");
1602
+ if (add)
1603
+ add.disabled = this.items.length >= (def.max ?? 200);
1604
+ if (!this.items.length) {
1605
+ host.innerHTML = `<p class="empty">Noch keine Einträge</p>`;
1606
+ return;
1607
+ }
1608
+ if (this.getAttribute("layout") === "tiles")
1609
+ return this.renderTiles(host);
1610
+ const ul = document.createElement("ul");
1611
+ this.items.forEach((item, i) => {
1612
+ const li = document.createElement("li");
1613
+ li.className = "row-item";
1614
+ const custom = this.renderItem?.(item, i);
1615
+ if (custom !== undefined && custom !== null) {
1616
+ const wrap = document.createElement("div");
1617
+ wrap.className = "name";
1618
+ wrap.append(custom);
1619
+ li.append(wrap);
1620
+ } else {
1621
+ const image = def.itemImage ? item[def.itemImage] : null;
1622
+ if (def.itemImage) {
1623
+ const img = document.createElement("img");
1624
+ img.className = "img";
1625
+ img.alt = "";
1626
+ if (typeof image === "string" && image)
1627
+ img.src = this.widget?.fileUrl(image) ?? image;
1628
+ li.append(img);
1629
+ }
1630
+ const name = document.createElement("span");
1631
+ name.className = "name";
1632
+ name.textContent = this.labelOf(item);
1633
+ li.append(name);
1634
+ }
1635
+ for (const [key, f] of Object.entries(def.item).filter(([, f]) => f.type === "boolean").slice(0, 3)) {
1636
+ const sw = document.createElement("button");
1637
+ sw.type = "button";
1638
+ sw.className = "switch";
1639
+ sw.setAttribute("role", "switch");
1640
+ sw.setAttribute("aria-checked", String(item[key] === true));
1641
+ sw.setAttribute("aria-label", `${f.label}: ${this.labelOf(item)}`);
1642
+ sw.onclick = () => this.op({ update: item.id, field: key, toggle: true });
1643
+ li.append(sw);
1644
+ }
1645
+ const actions = document.createElement("div");
1646
+ actions.className = "actions";
1647
+ actions.append(this.iconButton("↑", `${this.labelOf(item)} nach oben`, i === 0, () => this.op({ move: item.id, to: i - 1 })), this.iconButton("↓", `${this.labelOf(item)} nach unten`, i === this.items.length - 1, () => this.op({ move: item.id, to: i + 1 })), this.iconButton("✎", `${this.labelOf(item)} bearbeiten`, false, () => this.edit(i)), this.iconButton("✕", `${this.labelOf(item)} löschen`, false, () => this.op({ remove: item.id })));
1648
+ li.append(actions);
1649
+ ul.append(li);
1650
+ });
1651
+ host.replaceChildren(ul);
1652
+ }
1653
+ renderTiles(host) {
1654
+ const wrap = document.createElement("div");
1655
+ wrap.className = "tiles";
1656
+ this.items.forEach((item, i) => {
1657
+ const tile = document.createElement("button");
1658
+ tile.type = "button";
1659
+ tile.className = "tile";
1660
+ tile.setAttribute("aria-label", `${this.labelOf(item)} bearbeiten`);
1661
+ const custom = this.renderItem?.(item, i);
1662
+ tile.append(custom ?? this.labelOf(item));
1663
+ this.attachDrag(tile, i, wrap);
1664
+ wrap.append(tile);
1665
+ });
1666
+ host.replaceChildren(wrap);
1667
+ }
1668
+ attachDrag(tile, index, wrap) {
1669
+ let start = null;
1670
+ let moved = false;
1671
+ let target = null;
1672
+ tile.addEventListener("pointerdown", (e) => {
1673
+ if (e.button !== 0)
1674
+ return;
1675
+ start = { x: e.clientX, y: e.clientY };
1676
+ moved = false;
1677
+ target = null;
1678
+ tile.setPointerCapture(e.pointerId);
1679
+ });
1680
+ tile.addEventListener("pointermove", (e) => {
1681
+ if (!start)
1682
+ return;
1683
+ if (!moved && Math.hypot(e.clientX - start.x, e.clientY - start.y) < 8)
1684
+ return;
1685
+ moved = true;
1686
+ tile.classList.add("dragging");
1687
+ const tiles = [...wrap.querySelectorAll(".tile")];
1688
+ let best = null;
1689
+ tiles.forEach((t, i) => {
1690
+ t.classList.remove("before", "after");
1691
+ const r = t.getBoundingClientRect();
1692
+ const d = Math.hypot(e.clientX - (r.left + r.width / 2), e.clientY - (r.top + r.height / 2));
1693
+ if (!best || d < best.d)
1694
+ best = { i, d, after: e.clientX > r.left + r.width / 2 };
1695
+ });
1696
+ if (!best)
1697
+ return;
1698
+ const b = best;
1699
+ tiles[b.i].classList.add(b.after ? "after" : "before");
1700
+ target = b.after ? b.i + 1 : b.i;
1701
+ });
1702
+ const end = (cancelled) => {
1703
+ if (!start)
1704
+ return;
1705
+ start = null;
1706
+ if (cancelled)
1707
+ return this.renderItems();
1708
+ if (!moved)
1709
+ return this.edit(index);
1710
+ if (target === null)
1711
+ return this.renderItems();
1712
+ const to = Math.max(0, Math.min(target > index ? target - 1 : target, this.items.length - 1));
1713
+ if (to === index)
1714
+ return this.renderItems();
1715
+ this.op({ move: this.items[index].id, to });
1716
+ };
1717
+ tile.addEventListener("pointerup", () => end(false));
1718
+ tile.addEventListener("pointercancel", () => end(true));
1719
+ }
1720
+ iconButton(text, label, disabled, onClick) {
1721
+ const b = document.createElement("button");
1722
+ b.type = "button";
1723
+ b.className = "btn ghost icon";
1724
+ b.textContent = text;
1725
+ b.disabled = disabled;
1726
+ b.setAttribute("aria-label", label);
1727
+ b.onclick = onClick;
1728
+ return b;
1729
+ }
1730
+ op(op) {
1731
+ return this.write(op);
1732
+ }
1733
+ edit(index) {
1734
+ const def = this.listField;
1735
+ if (!def)
1736
+ return;
1737
+ const item = index === null ? null : this.items[index] ?? null;
1738
+ const save = async (values) => {
1739
+ if (item)
1740
+ return this.op({ update: item.id, patch: values });
1741
+ return this.op({ insert: values });
1742
+ };
1743
+ const event = new CustomEvent("pixl-edit", {
1744
+ cancelable: true,
1745
+ bubbles: true,
1746
+ composed: true,
1747
+ detail: { item, index, save }
1748
+ });
1749
+ if (!this.dispatchEvent(event))
1750
+ return;
1751
+ this.openBuiltInEditor(def, item, save);
1752
+ }
1753
+ openBuiltInEditor(def, item, save) {
1754
+ const dialog = this.dialog;
1755
+ if (!dialog)
1756
+ return;
1757
+ const values = {};
1758
+ const form = document.createElement("form");
1759
+ form.className = "form";
1760
+ for (const [key, f] of Object.entries(def.item)) {
1761
+ values[key] = item ? item[key] : f.default;
1762
+ const label = document.createElement("label");
1763
+ label.append(f.label);
1764
+ const input = createInput(f, values[key], { set: (v) => values[key] = v }, { label: f.label, widget: this.widget });
1765
+ label.append(input.el);
1766
+ form.append(label);
1767
+ }
1768
+ const cancel = document.createElement("button");
1769
+ cancel.type = "button";
1770
+ cancel.className = "btn";
1771
+ cancel.textContent = "Abbrechen";
1772
+ cancel.slot = "actions";
1773
+ cancel.onclick = () => dialog.close();
1774
+ const ok = document.createElement("button");
1775
+ ok.type = "button";
1776
+ ok.className = "btn primary";
1777
+ ok.textContent = item ? "Speichern" : "Hinzufügen";
1778
+ ok.slot = "actions";
1779
+ ok.onclick = async () => {
1780
+ ok.disabled = true;
1781
+ if (await save(values))
1782
+ dialog.close();
1783
+ ok.disabled = false;
1784
+ };
1785
+ form.onsubmit = (e) => {
1786
+ e.preventDefault();
1787
+ ok.click();
1788
+ };
1789
+ dialog.setAttribute("heading", item ? "Eintrag bearbeiten" : "Neuer Eintrag");
1790
+ dialog.replaceChildren(form, cancel, ok);
1791
+ dialog.open();
1792
+ }
1793
+ }
1794
+
1795
+ // src/ui/section.ts
1796
+ var SECTION_CSS = `
1797
+ h3 {
1798
+ margin: 0 0 10px; padding: 0 4px; font-size: 12px; font-weight: 600; color: var(--muted);
1799
+ text-transform: uppercase; letter-spacing: 0.04em;
1800
+ }
1801
+ .box { display: flex; flex-direction: column; gap: 10px; }
1802
+ ::slotted(*) {
1803
+ border: 1px solid var(--line); border-radius: 12px; background: var(--surface); padding: 0 16px;
1804
+ }
1805
+ `;
1806
+
1807
+ class PixlSection extends HTMLElement {
1808
+ static observedAttributes = ["heading"];
1809
+ root;
1810
+ constructor() {
1811
+ super();
1812
+ this.root = this.attachShadow({ mode: "open" });
1813
+ adoptStyles(this.root, SECTION_CSS);
1814
+ this.root.innerHTML = `<h3 part="heading"></h3><div class="box" part="box"><slot></slot></div>`;
1815
+ this.sync();
1816
+ }
1817
+ attributeChangedCallback() {
1818
+ this.sync();
1819
+ }
1820
+ sync() {
1821
+ const h3 = this.root.querySelector("h3");
1822
+ h3.textContent = this.getAttribute("heading") ?? "";
1823
+ h3.hidden = !h3.textContent;
1824
+ }
1825
+ }
1826
+
1827
+ class PixlControls extends HTMLElement {
1828
+ static observedAttributes = ["scope"];
1829
+ connectedCallback() {
1830
+ this.build();
1831
+ }
1832
+ attributeChangedCallback() {
1833
+ if (this.isConnected)
1834
+ this.build();
1835
+ }
1836
+ async build() {
1837
+ const widget = await sharedWidget();
1838
+ const scope = this.getAttribute("scope") === "settings" ? "settings" : "controls";
1839
+ const fields = (scope === "settings" ? widget?.manifest?.settings : widget?.manifest?.controls) ?? {};
1840
+ const groups = new Map;
1841
+ const labels = new Map;
1842
+ const out = [];
1843
+ for (const [name, def] of Object.entries(fields)) {
1844
+ if (def.hidden)
1845
+ continue;
1846
+ const key = def.group ?? "";
1847
+ let section = groups.get(key);
1848
+ if (!section) {
1849
+ section = document.createElement("pixl-section");
1850
+ section.style.marginBottom = "24px";
1851
+ groups.set(key, section);
1852
+ labels.set(key, []);
1853
+ out.push(section);
1854
+ }
1855
+ labels.get(key).push(def.label);
1856
+ const el = document.createElement(def.type === "list" ? "pixl-list" : "pixl-field");
1857
+ el.setAttribute("field", name);
1858
+ el.setAttribute("scope", scope);
1859
+ if (def.type === "list")
1860
+ el.style.padding = "12px 0";
1861
+ section.append(el);
1862
+ }
1863
+ const plain = (s) => s.trim().toLocaleLowerCase();
1864
+ for (const [key, section] of groups) {
1865
+ const names = labels.get(key) ?? [];
1866
+ const repeats = names.length === 1 && plain(names[0]) === plain(key);
1867
+ if (key && !repeats)
1868
+ section.setAttribute("heading", key);
1869
+ }
1870
+ this.replaceChildren(...out);
1871
+ }
1872
+ }
1873
+
1874
+ // src/ui/define.ts
1875
+ var ELEMENTS = [
1876
+ ["pixl-field", PixlField],
1877
+ ["pixl-toggle", PixlToggle],
1878
+ ["pixl-counter", PixlCounter],
1879
+ ["pixl-range", PixlRange],
1880
+ ["pixl-select", PixlSelect],
1881
+ ["pixl-text", PixlText],
1882
+ ["pixl-color", PixlColor],
1883
+ ["pixl-file", PixlFile],
1884
+ ["pixl-button", PixlButton],
1885
+ ["pixl-list", PixlList],
1886
+ ["pixl-dialog", PixlDialog],
1887
+ ["pixl-dropdown", PixlDropdown],
1888
+ ["pixl-checkbox", PixlCheckbox],
1889
+ ["pixl-section", PixlSection],
1890
+ ["pixl-controls", PixlControls]
1891
+ ];
1892
+ function defineElements() {
1893
+ if (typeof customElements === "undefined")
1894
+ return;
1895
+ injectPageStyles();
1896
+ for (const [name, element] of ELEMENTS)
1897
+ if (!customElements.get(name))
1898
+ customElements.define(name, element);
1899
+ }
1900
+
1901
+ // src/ui/index.ts
1902
+ setPixlClass(Pixl2);
1903
+ defineElements();
1904
+ export {
1905
+ PAGE_CSS,
1906
+ PixlCheckbox,
1907
+ PixlControls,
1908
+ PixlDialog,
1909
+ PixlDropdown,
1910
+ PixlField,
1911
+ PixlList,
1912
+ PixlSection,
1913
+ SHADOW_CSS,
1914
+ createInput,
1915
+ defineElements,
1916
+ requestFullscreen,
1917
+ sharedPixl,
1918
+ sharedWidget
1919
+ };