PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / assets / js / games.js

games.js in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.6, at assets/js/games.js

4,821 lines 166.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 (function() {
2 "use strict";
3 function html(strings, ...values) {
4 return { __wpdHtml: true, strings, values };
5 }
6 function isTemplateResult$1(v) {
7 return !!v && v.__wpdHtml === true;
8 }
9 const MARKER_PREFIX = "$$wpd$$";
10 const MARKER_RE = /\$\$wpd\$\$(\d+)\$\$/g;
11 function joinWithMarkers(strings) {
12 let out = strings[0];
13 for (let i = 1; i < strings.length; i++) {
14 out += `${MARKER_PREFIX}${i - 1}$$` + strings[i];
15 }
16 return out;
17 }
18 const compiledCache = /* @__PURE__ */ new WeakMap();
19 function compile(strings) {
20 const cached = compiledCache.get(strings);
21 if (cached) {
22 return cached;
23 }
24 const template = document.createElement("template");
25 template.innerHTML = joinWithMarkers(strings);
26 const recipes = [];
27 const walk = (node, path) => {
28 if (node.nodeType === Node.ELEMENT_NODE) {
29 const el = node;
30 for (const attr of Array.from(el.attributes)) {
31 const rawName = attr.name;
32 const rawValue = attr.value;
33 const prefix = rawName[0];
34 if (MARKER_RE.test(rawValue)) {
35 MARKER_RE.lastIndex = 0;
36 if (prefix === "@") {
37 const match = MARKER_RE.exec(rawValue);
38 MARKER_RE.lastIndex = 0;
39 recipes.push({
40 path,
41 kind: "event",
42 name: rawName.slice(1),
43 valueIndex: match ? Number(match[1]) : 0
44 });
45 el.removeAttribute(rawName);
46 } else if (prefix === ".") {
47 const match = MARKER_RE.exec(rawValue);
48 MARKER_RE.lastIndex = 0;
49 recipes.push({
50 path,
51 kind: "prop",
52 name: rawName.slice(1),
53 valueIndex: match ? Number(match[1]) : 0
54 });
55 el.removeAttribute(rawName);
56 } else if (prefix === "?") {
57 const match = MARKER_RE.exec(rawValue);
58 MARKER_RE.lastIndex = 0;
59 recipes.push({
60 path,
61 kind: "bool",
62 name: rawName.slice(1),
63 valueIndex: match ? Number(match[1]) : 0
64 });
65 el.removeAttribute(rawName);
66 } else {
67 const fragments = [];
68 const indices = [];
69 let lastEnd = 0;
70 let m;
71 MARKER_RE.lastIndex = 0;
72 while ((m = MARKER_RE.exec(rawValue)) !== null) {
73 fragments.push(rawValue.slice(lastEnd, m.index));
74 indices.push(Number(m[1]));
75 lastEnd = m.index + m[0].length;
76 }
77 fragments.push(rawValue.slice(lastEnd));
78 recipes.push({
79 path,
80 kind: "attr",
81 name: rawName,
82 template: fragments,
83 valueIndices: indices
84 });
85 el.setAttribute(rawName, "");
86 }
87 }
88 }
89 }
90 const children = Array.from(node.childNodes);
91 let shift = 0;
92 for (let i = 0; i < children.length; i++) {
93 const child = children[i];
94 const liveIndex = i + shift;
95 if (child.nodeType === Node.TEXT_NODE) {
96 const text = child.textContent || "";
97 if (!MARKER_RE.test(text)) {
98 MARKER_RE.lastIndex = 0;
99 continue;
100 }
101 MARKER_RE.lastIndex = 0;
102 const parent = child.parentNode;
103 let lastEnd = 0;
104 let m;
105 const newNodes = [];
106 const newRecipes = [];
107 MARKER_RE.lastIndex = 0;
108 while ((m = MARKER_RE.exec(text)) !== null) {
109 if (m.index > lastEnd) {
110 newNodes.push(document.createTextNode(text.slice(lastEnd, m.index)));
111 }
112 const placeholder = document.createTextNode("");
113 newNodes.push(placeholder);
114 newRecipes.push({
115 path: [...path, liveIndex + newNodes.length - 1],
116 kind: "node",
117 valueIndex: Number(m[1])
118 });
119 lastEnd = m.index + m[0].length;
120 }
121 if (lastEnd < text.length) {
122 newNodes.push(document.createTextNode(text.slice(lastEnd)));
123 }
124 for (const nn of newNodes) {
125 parent.insertBefore(nn, child);
126 }
127 parent.removeChild(child);
128 shift += newNodes.length - 1;
129 recipes.push(...newRecipes);
130 } else {
131 walk(child, [...path, liveIndex]);
132 }
133 }
134 };
135 walk(template.content, []);
136 const buildParts = (fragment) => {
137 const out = [];
138 for (const r of recipes) {
139 let node = fragment;
140 for (const idx of r.path) {
141 node = node.childNodes[idx];
142 }
143 if (r.kind === "node") {
144 out.push({
145 kind: "node",
146 valueIndex: r.valueIndex,
147 child: {
148 anchor: node,
149 state: null
150 }
151 });
152 } else if (r.kind === "attr") {
153 out.push({
154 kind: "attr",
155 element: node,
156 name: r.name,
157 template: r.template,
158 valueIndices: r.valueIndices
159 });
160 } else if (r.kind === "event") {
161 out.push({
162 kind: "event",
163 valueIndex: r.valueIndex,
164 element: node,
165 name: r.name
166 });
167 } else if (r.kind === "prop") {
168 out.push({
169 kind: "prop",
170 valueIndex: r.valueIndex,
171 element: node,
172 name: r.name
173 });
174 } else if (r.kind === "bool") {
175 out.push({
176 kind: "bool",
177 valueIndex: r.valueIndex,
178 element: node,
179 name: r.name
180 });
181 }
182 }
183 return out;
184 };
185 const entry = { template, buildParts };
186 compiledCache.set(strings, entry);
187 return entry;
188 }
189 const mountState = /* @__PURE__ */ new WeakMap();
190 function mountIntact(state, container) {
191 for (const node of state.nodes) {
192 if (node.parentNode !== container) {
193 return false;
194 }
195 }
196 return true;
197 }
198 function render(result, container) {
199 const existing = mountState.get(container);
200 if (existing && existing.strings === result.strings && mountIntact(existing, container)) {
201 applyValues(existing.parts, result.values);
202 return;
203 }
204 const compiled = compile(result.strings);
205 const fragment = compiled.template.content.cloneNode(true);
206 const parts = compiled.buildParts(fragment);
207 const nodes = Array.from(fragment.childNodes);
208 while (container.firstChild) {
209 container.removeChild(container.firstChild);
210 }
211 container.appendChild(fragment);
212 applyValues(parts, result.values);
213 mountState.set(container, { strings: result.strings, parts, nodes });
214 }
215 function applyValues(parts, values) {
216 for (const part of parts) {
217 if (part.kind === "node") {
218 updateChildPart(part.child, values[part.valueIndex]);
219 } else if (part.kind === "attr") {
220 let composed = part.template[0];
221 for (let i = 0; i < part.valueIndices.length; i++) {
222 composed += formatText(values[part.valueIndices[i]]);
223 composed += part.template[i + 1];
224 }
225 if (composed !== part.last) {
226 part.last = composed;
227 if (composed === "") {
228 part.element.removeAttribute(part.name);
229 } else {
230 part.element.setAttribute(part.name, composed);
231 }
232 }
233 } else if (part.kind === "event") {
234 const next = values[part.valueIndex];
235 if (next !== part.current) {
236 if (part.current) {
237 part.element.removeEventListener(part.name, part.current);
238 }
239 if (next) {
240 part.element.addEventListener(part.name, next);
241 }
242 part.current = next;
243 }
244 } else if (part.kind === "prop") {
245 const next = values[part.valueIndex];
246 if (next !== part.last) {
247 part.last = next;
248 part.element[part.name] = next;
249 }
250 } else if (part.kind === "bool") {
251 const next = !!values[part.valueIndex];
252 if (next !== part.last) {
253 part.last = next;
254 if (next) {
255 part.element.setAttribute(part.name, "");
256 } else {
257 part.element.removeAttribute(part.name);
258 }
259 }
260 }
261 }
262 }
263 function updateChildPart(child, value) {
264 if (value === null || value === void 0 || value === false) {
265 if (child.state) {
266 disposeChildState(child.state);
267 child.state = null;
268 }
269 return;
270 }
271 if (Array.isArray(value)) {
272 updateArrayChild(child, value);
273 return;
274 }
275 if (isTemplateResult$1(value)) {
276 updateTemplateChild(child, value);
277 return;
278 }
279 if (value instanceof Node) {
280 updateNodeChild(child, value);
281 return;
282 }
283 updateTextChild(child, formatText(value));
284 }
285 function updateNodeChild(child, node) {
286 const old = child.state;
287 if (old?.shape === "node" && old.node === node) {
288 return;
289 }
290 if (old) {
291 disposeChildState(old);
292 }
293 insertBeforeAnchor(child, [node]);
294 child.state = { shape: "node", node };
295 }
296 function updateTextChild(child, text) {
297 const old = child.state;
298 if (old?.shape === "text") {
299 if (old.text !== text) {
300 old.node.textContent = text;
301 old.text = text;
302 }
303 return;
304 }
305 if (old) {
306 disposeChildState(old);
307 }
308 const node = document.createTextNode(text);
309 insertBeforeAnchor(child, [node]);
310 child.state = { shape: "text", node, text };
311 }
312 function updateTemplateChild(child, result) {
313 const old = child.state;
314 if (old?.shape === "template" && old.strings === result.strings) {
315 applyValues(old.parts, result.values);
316 return;
317 }
318 if (old) {
319 disposeChildState(old);
320 }
321 const compiled = compile(result.strings);
322 const fragment = compiled.template.content.cloneNode(true);
323 const parts = compiled.buildParts(fragment);
324 const topNodes = Array.from(fragment.childNodes);
325 insertBeforeAnchor(child, [fragment]);
326 applyValues(parts, result.values);
327 child.state = {
328 shape: "template",
329 strings: result.strings,
330 parts,
331 nodes: topNodes
332 };
333 }
334 function updateArrayChild(child, arr) {
335 const old = child.state;
336 if (old?.shape === "array" && old.entries.length === arr.length) {
337 for (let i = 0; i < arr.length; i++) {
338 updateChildPart(old.entries[i], arr[i]);
339 }
340 return;
341 }
342 if (old) {
343 disposeChildState(old);
344 }
345 const entries = [];
346 for (const v of arr) {
347 const entryAnchor = document.createTextNode("");
348 insertBeforeAnchor(child, [entryAnchor]);
349 const entry = { anchor: entryAnchor, state: null };
350 updateChildPart(entry, v);
351 entries.push(entry);
352 }
353 child.state = { shape: "array", entries };
354 }
355 function insertBeforeAnchor(child, nodes) {
356 const parent = child.anchor.parentNode;
357 if (!parent) {
358 return;
359 }
360 for (const node of nodes) {
361 parent.insertBefore(node, child.anchor);
362 }
363 }
364 function disposeChildState(state) {
365 if (state.shape === "text") {
366 state.node.remove();
367 return;
368 }
369 if (state.shape === "template") {
370 for (const node of state.nodes) {
371 if (node.parentNode) {
372 node.parentNode.removeChild(node);
373 }
374 }
375 return;
376 }
377 if (state.shape === "node") {
378 if (state.node.parentNode) {
379 state.node.parentNode.removeChild(state.node);
380 }
381 return;
382 }
383 for (const entry of state.entries) {
384 if (entry.state) {
385 disposeChildState(entry.state);
386 }
387 entry.anchor.remove();
388 }
389 }
390 function formatText(v) {
391 if (v === null || v === void 0 || v === false) {
392 return "";
393 }
394 return String(v);
395 }
396 const _Component = class _Component extends HTMLElement {
397 constructor() {
398 super();
399 this._renderScheduled = false;
400 this._propValues = {};
401 const ctor = this.constructor;
402 if (ctor.shadow) {
403 this.attachShadow({ mode: "open" });
404 this._renderRoot = this.shadowRoot;
405 } else {
406 this._renderRoot = this;
407 }
408 this._installPropAccessors();
409 }
410 static get observedAttributes() {
411 return this.props.map(kebab);
412 }
413 connectedCallback() {
414 this._adoptStyles();
415 this.requestUpdate();
416 }
417 attributeChangedCallback(name, oldValue, newValue) {
418 if (oldValue === newValue) {
419 return;
420 }
421 const prop = camel(name);
422 this._propValues[prop] = newValue;
423 this.requestUpdate();
424 }
425 /**
426 * Declarative class-name setter. Assign an array (or a
427 * space-separated string) and the host's `class` attribute is
428 * rewritten to match. Intended for programmatic styling — when
429 * a plugin has enqueued its own stylesheet and wants to apply
430 * one of those classes to a shell component:
431 *
432 * ```js
433 * element.classNames = [ 'my-plugin-brand', 'is-active' ];
434 * // → <wpd-select class="my-plugin-brand is-active">
435 * ```
436 *
437 * The plain HTML `class="…"` attribute works just the same and
438 * is always preferred when writing markup by hand — this setter
439 * exists for the JS-API case where the caller has an array of
440 * conditional classes in hand.
441 *
442 * Getter returns the current `classList` as a plain array for
443 * symmetric read/write.
444 *
445 * @since 0.5.0
446 */
447 get classNames() {
448 return Array.from(this.classList);
449 }
450 set classNames(next) {
451 if (next === null || next === void 0) {
452 this.removeAttribute("class");
453 return;
454 }
455 const list = Array.isArray(next) ? next : String(next).split(/\s+/);
456 const cleaned = list.map((s) => String(s).trim()).filter((s) => s !== "");
457 this.className = cleaned.join(" ");
458 }
459 /**
460 * Request a re-render explicitly. Components rarely need this —
461 * declare state via props + attribute observers and the render
462 * loop picks up changes automatically.
463 */
464 requestUpdate() {
465 this._scheduleRender();
466 }
467 /**
468 * Dispatch a `CustomEvent` with a `detail`. Bubbles + composed
469 * by default (matches typical WC UX — events cross shadow
470 * boundaries, parents can listen without knowing about internal
471 * structure).
472 */
473 emit(name, detail) {
474 return this.dispatchEvent(
475 new CustomEvent(name, {
476 detail,
477 bubbles: true,
478 composed: true
479 })
480 );
481 }
482 // ------------------------------------------------------------------
483 // Internals
484 // ------------------------------------------------------------------
485 /**
486 * Wire every `static props` entry to a matched property getter +
487 * setter on the element. Setting the property reflects into the
488 * attribute (so downstream observers + CSS selectors see it);
489 * reading the property falls back to the attribute.
490 */
491 _installPropAccessors() {
492 const ctor = this.constructor;
493 for (const prop of ctor.props) {
494 if (Object.getOwnPropertyDescriptor(this, prop)) {
495 continue;
496 }
497 const attr = kebab(prop);
498 Object.defineProperty(this, prop, {
499 get: () => {
500 if (prop in this._propValues) {
501 return this._propValues[prop];
502 }
503 return this.getAttribute(attr);
504 },
505 set: (value) => {
506 let str;
507 if (value === null || value === void 0 || value === false) {
508 str = null;
509 } else if (value === true) {
510 str = "";
511 } else {
512 str = String(value);
513 }
514 this._propValues[prop] = str;
515 if (str === null) {
516 this.removeAttribute(attr);
517 } else {
518 this.setAttribute(attr, str);
519 }
520 this.requestUpdate();
521 },
522 enumerable: true,
523 configurable: true
524 });
525 }
526 }
527 /**
528 * Schedule a render on the next microtask. Multiple property
529 * assignments in the same tick collapse into a single render.
530 */
531 _scheduleRender() {
532 if (this._renderScheduled || !this.isConnected) {
533 return;
534 }
535 this._renderScheduled = true;
536 queueMicrotask(() => {
537 this._renderScheduled = false;
538 if (!this.isConnected) {
539 return;
540 }
541 render(this.render(), this._renderRoot);
542 });
543 }
544 /**
545 * Mount adoptable stylesheets onto the shadow root (via
546 * `adoptedStyleSheets`) or the light DOM (via one `<style>`
547 * tag per def). No-op if `static styles` is empty.
548 */
549 _adoptStyles() {
550 const ctor = this.constructor;
551 if (ctor.styles.length === 0) {
552 return;
553 }
554 if (ctor.shadow && this.shadowRoot) {
555 const sheets = ctor.styles.map((s) => s.sheet).filter((s) => s !== null);
556 this.shadowRoot.adoptedStyleSheets = sheets;
557 if (sheets.length !== ctor.styles.length) {
558 for (const s of ctor.styles) {
559 if (!s.sheet) {
560 const tag = document.createElement("style");
561 tag.textContent = s.cssText;
562 this.shadowRoot.appendChild(tag);
563 }
564 }
565 }
566 } else {
567 this._adoptLightStyles(ctor);
568 }
569 }
570 _adoptLightStyles(ctor) {
571 if (_Component._lightStylesAdopted.has(ctor)) {
572 return;
573 }
574 _Component._lightStylesAdopted.add(ctor);
575 for (const s of ctor.styles) {
576 const tag = document.createElement("style");
577 tag.dataset.wpdUi = this.tagName.toLowerCase();
578 tag.textContent = s.cssText;
579 document.head.appendChild(tag);
580 }
581 }
582 };
583 _Component.props = [];
584 _Component.styles = [];
585 _Component.shadow = true;
586 _Component._lightStylesAdopted = /* @__PURE__ */ new WeakSet();
587 let Component = _Component;
588 function defineComponent(tag, ctor) {
589 if (customElements.get(tag)) {
590 return;
591 }
592 customElements.define(tag, ctor);
593 }
594 function kebab(s) {
595 return s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
596 }
597 function camel(s) {
598 return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
599 }
600 const SUPPORTS_CONSTRUCTABLE_SHEETS = (() => {
601 try {
602 const s = new CSSStyleSheet();
603 return typeof s.replaceSync === "function";
604 } catch {
605 return false;
606 }
607 })();
608 function css(strings, ...values) {
609 let text = strings[0];
610 for (let i = 1; i < strings.length; i++) {
611 const v = values[i - 1];
612 if (typeof v === "string" || typeof v === "number") {
613 text += String(v);
614 } else if (v && v.__wpdCss) {
615 text += v.cssText;
616 } else {
617 throw new TypeError(
618 "[wpd-ui] css`` interpolations must be strings, numbers, or other css`` results. Got: " + typeof v
619 );
620 }
621 text += strings[i];
622 }
623 if (SUPPORTS_CONSTRUCTABLE_SHEETS) {
624 const sheet = new CSSStyleSheet();
625 sheet.replaceSync(text);
626 return { __wpdCss: true, sheet, cssText: text };
627 }
628 return { __wpdCss: true, sheet: null, cssText: text };
629 }
630 const styles$4 = css`:host{display:inline-flex}:host( [ fill-cell ] ){display:flex;width:100%}button{appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:var( --wpd-button-padding,6px 12px );border-radius:var( --wpd-button-border-radius,6px );font:inherit;font-weight:500;cursor:pointer;transition:background-color 0.12s ease,color 0.12s ease,border-color 0.12s ease;background:var( --wpd-button-bg,transparent );color:var( --wpd-button-fg,var( --desktop-mode-text,#1d2327 ) );border:var( --wpd-button-border,1px solid var( --desktop-mode-border,#c3c4c7 ) )}:host( [ fill-cell ] ) button{width:100%;min-height:var( --wpd-button-min-height,44px )}button:disabled{opacity:0.5;cursor:not-allowed}button:hover:not(:disabled ){background:var( --wpd-button-bg-hover,rgba( 0,0,0,0.04 ) )}:host( [ variant='primary' ] ) button{background:var( --wpd-button-bg,var( --wp-admin-theme-color,#2271b1 ) );color:var( --wpd-button-fg,#fff );border:var( --wpd-button-border,1px solid transparent )}:host( [ variant='primary' ] ) button:hover:not(:disabled ){filter:brightness( 1.06 );background:var( --wpd-button-bg,var( --wp-admin-theme-color,#2271b1 ) )}:host( [ variant='secondary' ] ) button{background:var( --wpd-button-bg,rgba( 0,0,0,0.06 ) );color:var( --wpd-button-fg,var( --desktop-mode-text,#1d2327 ) );border:var( --wpd-button-border,1px solid transparent )}:host( [ variant='secondary' ] ) button:hover:not(:disabled ){background:var( --wpd-button-bg-hover,rgba( 0,0,0,0.1 ) )}:host( [ variant='danger' ] ) button{background:var( --wpd-button-bg,transparent );color:var( --wpd-button-fg,#d63638 );border:var( --wpd-button-border,1px solid currentColor )}:host( [ variant='danger' ] ) button:hover:not(:disabled ){background:#d63638;color:#fff}:host( [ variant='link' ] ) button{background:transparent;color:var( --wpd-button-fg,var( --wp-admin-theme-color,#2271b1 ) );border:0;padding:0;text-decoration:underline}:host( [ busy ] ) button{pointer-events:none;opacity:0.75}.wpd-button__spinner{box-sizing:border-box;display:inline-block;width:12px;height:12px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:wpd-button-spin 0.6s linear infinite;flex-shrink:0}@keyframes wpd-button-spin{to{transform:rotate( 360deg )}}`;
631 const _WpdButton = class _WpdButton extends Component {
632 render() {
633 const disabled = this.disabled !== null;
634 const busy = this.busy !== null;
635 const type = this.type || "button";
636 return html`
637 <button
638 part="button"
639 type=${type}
640 ?disabled=${disabled || busy}
641 aria-busy=${busy ? "true" : "false"}
642 >
643 ${busy ? html`<span class="wpd-button__spinner" aria-hidden="true"></span>` : ""}
644 <slot></slot>
645 </button>
646 `;
647 }
648 };
649 _WpdButton.props = ["variant", "disabled", "type", "busy", "fill-cell"];
650 _WpdButton.styles = [styles$4];
651 _WpdButton.help = {
652 title: "Button",
653 summary: "Thin wrapper around <button> with consistent variant styling and a slot for the label.",
654 status: "stable",
655 since: "0.9.0",
656 props: [
657 {
658 name: "variant",
659 type: "'primary' | 'secondary' | 'ghost' | 'danger' | 'link'",
660 default: "ghost",
661 description: "Visual weight of the button. Use primary for the single attention-grabbing action per surface."
662 },
663 {
664 name: "disabled",
665 type: "boolean attribute",
666 description: "Disable pointer + keyboard interaction and dim the chrome."
667 },
668 {
669 name: "type",
670 type: "'button' | 'submit' | 'reset'",
671 default: "button",
672 description: "Forwarded to the underlying native <button>."
673 },
674 {
675 name: "busy",
676 type: "boolean attribute",
677 description: "Marks the button as in-progress (e.g., awaiting a fetch)."
678 },
679 {
680 name: "fill-cell",
681 type: "boolean attribute",
682 description: "Grow to fill the parent flex/grid cell. Useful for tiled keypads."
683 }
684 ],
685 slots: [{ name: "(default)", description: "Button label." }],
686 parts: [{ name: "button", description: "Underlying <button> element." }],
687 cssProps: [
688 { name: "--wpd-button-bg", description: "Background color." },
689 {
690 name: "--wpd-button-bg-hover",
691 description: "Hover wash (ghost + secondary variants)."
692 },
693 { name: "--wpd-button-fg", description: "Text color." },
694 { name: "--wpd-button-border", description: "Border shorthand." },
695 { name: "--wpd-button-border-radius", default: "6px" },
696 { name: "--wpd-button-padding", default: "6px 12px" },
697 {
698 name: "--wpd-button-min-height",
699 description: "Minimum height when fill-cell is set."
700 }
701 ],
702 example: html`
703 <wpd-cluster gap="8">
704 <wpd-button variant="primary">Primary</wpd-button>
705 <wpd-button variant="secondary">Secondary</wpd-button>
706 <wpd-button variant="ghost">Ghost</wpd-button>
707 <wpd-button variant="danger">Danger</wpd-button>
708 <wpd-button variant="link">Link</wpd-button>
709 </wpd-cluster>
710 `
711 };
712 let WpdButton = _WpdButton;
713 defineComponent("wpd-button", WpdButton);
714 const styles$3 = css`:host{display:inline-flex;align-items:center;justify-content:center;width:var( --wpd-icon-size,16px );height:var( --wpd-icon-size,16px );color:inherit;line-height:1}:host( [ hidden ] ){display:none}.wpd-icon__glyph{font-size:var( --wpd-icon-size,16px );width:var( --wpd-icon-size,16px );height:var( --wpd-icon-size,16px );line-height:1;color:inherit;display:inline-flex;align-items:center;justify-content:center}.wpd-icon__glyph--char{font-family:dashicons;font-style:normal;font-weight:normal;font-variant:normal;text-transform:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;speak:none}.wpd-icon__glyph.dashicons{font-family:dashicons}`;
715 let _cache = null;
716 function parseCssContentToChar(raw) {
717 let value = raw.trim();
718 if (value === "") {
719 return null;
720 }
721 if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
722 value = value.slice(1, -1);
723 }
724 const escaped = value.match(/^\\([0-9a-f]{1,6})\s?$/i);
725 if (escaped) {
726 return String.fromCodePoint(parseInt(escaped[1], 16));
727 }
728 return value || null;
729 }
730 function buildMap() {
731 const map = /* @__PURE__ */ new Map();
732 if (typeof document === "undefined") {
733 return map;
734 }
735 const sheets = Array.from(document.styleSheets ?? []);
736 for (const sheet of sheets) {
737 let rules = null;
738 try {
739 rules = sheet.cssRules;
740 } catch {
741 continue;
742 }
743 if (!rules) {
744 continue;
745 }
746 for (const rule of Array.from(rules)) {
747 const styleRule = rule;
748 if (!styleRule || !styleRule.selectorText) {
749 continue;
750 }
751 const match = styleRule.selectorText.match(
752 /\.dashicons-([a-z0-9-]+)::?before/i
753 );
754 if (!match) {
755 continue;
756 }
757 const content = styleRule.style?.content;
758 if (!content) {
759 continue;
760 }
761 const char = parseCssContentToChar(content);
762 if (char) {
763 map.set(match[1], char);
764 }
765 }
766 }
767 return map;
768 }
769 function resolveDashicon(name) {
770 if (!_cache) {
771 _cache = buildMap();
772 }
773 const slug = name.startsWith("dashicons-") ? name.slice("dashicons-".length) : name;
774 return _cache.get(slug) ?? null;
775 }
776 function refreshDashiconCache() {
777 _cache = buildMap();
778 }
779 let _scheduled = false;
780 function primeOnLoad() {
781 if (_scheduled || typeof window === "undefined") {
782 return;
783 }
784 _scheduled = true;
785 const refresh = () => {
786 refreshDashiconCache();
787 };
788 if (document.readyState === "loading") {
789 document.addEventListener("DOMContentLoaded", refresh, { once: true });
790 }
791 window.addEventListener("load", refresh, { once: true });
792 }
793 primeOnLoad();
794 const _WpdIcon = class _WpdIcon extends Component {
795 render() {
796 const rawName = this.name || "";
797 const slug = rawName.startsWith("dashicons-") ? rawName.slice("dashicons-".length) : rawName;
798 const size = this.size;
799 if (size && /^\d+$/.test(size)) {
800 this.style.setProperty("--wpd-icon-size", `${size}px`);
801 }
802 const char = resolveDashicon(slug);
803 if (char) {
804 return html`<span
805 class="wpd-icon__glyph wpd-icon__glyph--char dashicons dashicons-${slug}"
806 aria-hidden="true"
807 >${char}</span>`;
808 }
809 return html`<span
810 class="wpd-icon__glyph dashicons dashicons-${slug}"
811 aria-hidden="true"
812 ></span>`;
813 }
814 };
815 _WpdIcon.props = ["name", "size"];
816 _WpdIcon.styles = [styles$3];
817 _WpdIcon.help = {
818 title: "Icon",
819 summary: 'Dashicon wrapper that inherits theme colour + sizing from its context. Accepts either the dashicon suffix ("calculator") or the full class ("dashicons-calculator"). Marked aria-hidden; wrap in a button/link with its own label for accessible use.',
820 status: "stable",
821 since: "0.5.0",
822 props: [
823 {
824 name: "name",
825 type: "string",
826 description: "Dashicon identifier, with or without the `dashicons-` prefix."
827 },
828 {
829 name: "size",
830 type: "integer (px)",
831 default: "16",
832 description: "Glyph size in pixels."
833 }
834 ],
835 cssProps: [
836 { name: "--wpd-icon-size", default: "16px" }
837 ],
838 example: html`
839 <wpd-cluster gap="8" align="center">
840 <wpd-icon name="admin-post"></wpd-icon>
841 <wpd-icon name="calculator" size="20"></wpd-icon>
842 <wpd-icon name="dashicons-star-filled" size="32"></wpd-icon>
843 </wpd-cluster>
844 `
845 };
846 let WpdIcon = _WpdIcon;
847 defineComponent("wpd-icon", WpdIcon);
848 const styles$2 = css`:host{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:32px 24px;text-align:center;color:var( --wpd-empty-state-fg,var( --desktop-mode-muted,#646970 ) )}:host( [ hidden ] ){display:none}.wpd-empty-state__icon{margin-bottom:4px;color:var( --wpd-empty-state-icon-color,currentColor );opacity:0.75}.wpd-empty-state__heading{margin:0;font-size:14px;font-weight:600;color:var( --desktop-mode-text,#1d2327 )}.wpd-empty-state__description{margin:0;font-size:12px;line-height:1.4;max-width:48ch}.wpd-empty-state__description:empty{display:none}.wpd-empty-state__cta{margin-top:8px}.wpd-empty-state__cta:empty{display:none}`;
849 const _WpdEmptyState = class _WpdEmptyState extends Component {
850 render() {
851 const icon = this.icon || "";
852 const heading = this.heading || "";
853 const description = this.description || "";
854 return html`
855 ${icon ? html`<wpd-icon
856 class="wpd-empty-state__icon"
857 name=${icon}
858 size="28"
859 ></wpd-icon>` : null}
860 <h3 class="wpd-empty-state__heading">${heading}</h3>
861 <p class="wpd-empty-state__description">${description}</p>
862 <div class="wpd-empty-state__cta">
863 <slot name="cta"></slot>
864 </div>
865 <slot></slot>
866 `;
867 }
868 };
869 _WpdEmptyState.props = ["icon", "heading", "description"];
870 _WpdEmptyState.styles = [styles$2];
871 _WpdEmptyState.help = {
872 title: "Empty state",
873 summary: 'Centered placeholder for "nothing here yet" UI: icon + heading + description + optional CTA. A canonical shape so empty states look consistent across the shell.',
874 status: "stable",
875 since: "0.5.0",
876 props: [
877 {
878 name: "icon",
879 type: "string (dashicons slug)",
880 description: "Dashicons identifier (with or without the dashicons- prefix)."
881 },
882 {
883 name: "heading",
884 type: "string",
885 description: "Bold first line."
886 },
887 {
888 name: "description",
889 type: "string",
890 description: "Secondary paragraph below the heading."
891 }
892 ],
893 slots: [
894 { name: "cta", description: "Call-to-action button row below the description." },
895 { name: "(default)", description: "Any additional content rendered after the CTA." }
896 ],
897 cssProps: [
898 { name: "--desktop-mode-text", description: "Heading colour." },
899 { name: "--desktop-mode-muted", description: "Description colour." },
900 { name: "--wpd-empty-state-fg" },
901 { name: "--wpd-empty-state-icon-color" }
902 ],
903 example: html`
904 <wpd-empty-state
905 icon="admin-plugins"
906 heading="No plugins installed yet"
907 description="Install a plugin to see it here."
908 >
909 <wpd-button slot="cta" variant="primary">Browse plugins</wpd-button>
910 </wpd-empty-state>
911 `
912 };
913 let WpdEmptyState = _WpdEmptyState;
914 defineComponent("wpd-empty-state", WpdEmptyState);
915 const TEXT_DOMAIN = "desktop-mode";
916 function i18n() {
917 return window.wp?.i18n;
918 }
919 function __(text, domain = TEXT_DOMAIN) {
920 return i18n()?.__(text, domain) ?? text;
921 }
922 function sprintf(format, ...args) {
923 const impl = i18n()?.sprintf;
924 if (impl) {
925 return impl(format, ...args);
926 }
927 let i = 0;
928 return format.replace(/%(?:(\d+)\$)?[sd]/g, (_match, pos) => {
929 const idx = pos ? Number.parseInt(pos, 10) - 1 : i++;
930 return String(args[idx] ?? "");
931 });
932 }
933 function getWpHooks() {
934 const hooks = window.wp?.hooks;
935 if (!hooks) {
936 throw new Error(
937 "[desktop-mode] `window.wp.hooks` is not available. The plugin declares `wp-hooks` as a script dependency; if you are seeing this error, verify the enqueue order."
938 );
939 }
940 return hooks;
941 }
942 function addAction(hookName2, namespace, callback, priority) {
943 getWpHooks().addAction(
944 hookName2,
945 namespace,
946 callback,
947 priority
948 );
949 }
950 function removeAction(hookName2, namespace) {
951 return getWpHooks().removeAction(hookName2, namespace);
952 }
953 function applyFilters(hookName2, value, ...args) {
954 return getWpHooks().applyFilters(hookName2, value, ...args);
955 }
956 function doAction(hookName2, ...args) {
957 getWpHooks().doAction(hookName2, ...args);
958 }
959 const HOOKS = {
960 /**
961 * Filter, receives the games registry array (`GameRegistryEntry[]`)
962 * on every read. Mirrors the PHP-side `desktop_mode_games` filter.
963 *
964 * @since 0.9.6
965 */
966 GAMES: "desktop-mode.games"
967 };
968 const HOOK_PREFIX = "desktop-mode.activity.";
969 function hookName(channel) {
970 return `${HOOK_PREFIX}${String(channel)}`;
971 }
972 let subscribeSeq = 0;
973 const activity = {
974 publish(channel, payload) {
975 doAction(hookName(channel), payload);
976 },
977 subscribe(channel, cb) {
978 const ns = `desktop-mode/activity-sub/${++subscribeSeq}`;
979 const hook = hookName(channel);
980 addAction(
981 hook,
982 ns,
983 (payload) => cb(payload)
984 );
985 let removed = false;
986 return () => {
987 if (removed) {
988 return;
989 }
990 removed = true;
991 removeAction(hook, ns);
992 };
993 },
994 filter(channel, value, ...args) {
995 return applyFilters(hookName(channel), value, ...args);
996 }
997 };
998 const CANARY_TAG = "wpd-confirm-dialog";
999 let inflight = null;
1000 function isLoaded() {
1001 return typeof window.customElements !== "undefined" && !!window.customElements.get(CANARY_TAG);
1002 }
1003 function injectScript(scriptUrl) {
1004 return new Promise((resolve, reject) => {
1005 const existing = document.querySelector(
1006 'script[data-desktop-mode-shell-overlays="1"]'
1007 );
1008 const finish = () => {
1009 if (isLoaded()) {
1010 resolve();
1011 return;
1012 }
1013 reject(
1014 new Error(
1015 "[desktop-mode] shell-overlays bundle loaded but did not register the overlay components."
1016 )
1017 );
1018 };
1019 if (existing) {
1020 if (isLoaded()) {
1021 finish();
1022 } else {
1023 existing.addEventListener("load", finish);
1024 existing.addEventListener(
1025 "error",
1026 () => reject(new Error("failed to load shell-overlays bundle"))
1027 );
1028 }
1029 return;
1030 }
1031 const s = document.createElement("script");
1032 s.src = scriptUrl;
1033 s.async = true;
1034 s.dataset.desktopModeShellOverlays = "1";
1035 s.addEventListener("load", finish);
1036 s.addEventListener(
1037 "error",
1038 () => reject(new Error("failed to load shell-overlays bundle"))
1039 );
1040 document.head.appendChild(s);
1041 });
1042 }
1043 function ensureShellOverlaysLoaded(scriptUrl) {
1044 if (isLoaded()) {
1045 return Promise.resolve();
1046 }
1047 if (!scriptUrl) {
1048 return Promise.resolve();
1049 }
1050 if (!inflight) {
1051 inflight = injectScript(scriptUrl);
1052 }
1053 return inflight;
1054 }
1055 function shellOverlaysBundleUrl() {
1056 const cfg = window.desktopModeConfig;
1057 return cfg?.shellOverlaysBundleUrl ?? "";
1058 }
1059 function openWithShellOverlays(isStillCurrent, fn) {
1060 const url = shellOverlaysBundleUrl();
1061 if (isLoaded() || !url) {
1062 fn();
1063 return;
1064 }
1065 void ensureShellOverlaysLoaded(url).then(() => {
1066 if (!isStillCurrent()) {
1067 return;
1068 }
1069 fn();
1070 }).catch((err) => {
1071 if (typeof console !== "undefined") {
1072 console.warn(
1073 "[desktop-mode] shell-overlays failed to load; menu/dialog suppressed:",
1074 err
1075 );
1076 }
1077 });
1078 }
1079 const DEFAULT_DURATION_MS = 4e3;
1080 const FADE_OUT_MS = 200;
1081 function showToast(options) {
1082 const intent = activity.filter(
1083 "desktop-mode/toast-requested",
1084 { ...options }
1085 );
1086 if (!intent || intent.cancel === true) {
1087 return () => void 0;
1088 }
1089 let dismissRequested = false;
1090 let realDismiss = null;
1091 openWithShellOverlays(
1092 () => !dismissRequested,
1093 () => {
1094 realDismiss = renderToast(intent);
1095 }
1096 );
1097 return () => {
1098 dismissRequested = true;
1099 if (realDismiss) {
1100 realDismiss();
1101 }
1102 };
1103 }
1104 function renderToast(intent) {
1105 const container = ensureContainer();
1106 const toast = document.createElement("wpd-toast");
1107 toast.textContent = intent.message;
1108 if (intent.action) {
1109 toast.setAttribute("action", intent.action.label);
1110 toast.addEventListener("wpd-toast-action", () => {
1111 intent.action?.onClick();
1112 dismiss();
1113 });
1114 }
1115 if (intent.dismissible) {
1116 toast.setAttribute("dismissible", "");
1117 toast.addEventListener("wpd-toast-dismiss", () => {
1118 intent.onDismiss?.();
1119 dismiss();
1120 });
1121 }
1122 container.appendChild(toast);
1123 let dismissed = false;
1124 let dismissTimer = null;
1125 const dismiss = () => {
1126 if (dismissed) {
1127 return;
1128 }
1129 dismissed = true;
1130 if (dismissTimer !== null) {
1131 window.clearTimeout(dismissTimer);
1132 dismissTimer = null;
1133 }
1134 toast.setAttribute("state", "out");
1135 window.setTimeout(() => {
1136 toast.remove();
1137 }, FADE_OUT_MS);
1138 };
1139 requestAnimationFrame(() => {
1140 toast.setAttribute("state", "in");
1141 });
1142 if (!intent.persistent) {
1143 dismissTimer = window.setTimeout(
1144 dismiss,
1145 intent.duration ?? DEFAULT_DURATION_MS
1146 );
1147 }
1148 activity.publish("desktop-mode/toast-shown", { ...intent });
1149 return dismiss;
1150 }
1151 function ensureContainer() {
1152 const existing = document.querySelector(
1153 "wpd-toast-container"
1154 );
1155 if (existing) {
1156 return existing;
1157 }
1158 const el = document.createElement("wpd-toast-container");
1159 document.body.appendChild(el);
1160 return el;
1161 }
1162 function collectRegistrationErrors(def, checks) {
1163 if (!def || typeof def !== "object") {
1164 return ["def (not an object)"];
1165 }
1166 const d = def;
1167 const errors = [];
1168 for (const check of checks) {
1169 if (!check.valid(d)) {
1170 errors.push(`${check.field} (${check.message})`);
1171 }
1172 }
1173 return errors;
1174 }
1175 class RegistrationError extends Error {
1176 constructor(kind, errors, def) {
1177 super(
1178 `[desktop-mode] ${kind} registration rejected — fields: ` + errors.join(", ") + "."
1179 );
1180 this.name = "RegistrationError";
1181 this.kind = kind;
1182 this.errors = errors;
1183 this.def = def;
1184 }
1185 }
1186 function throwOnRegistrationErrors(kind, errors, def) {
1187 if (errors.length === 0) {
1188 return;
1189 }
1190 throw new RegistrationError(kind, errors, def);
1191 }
1192 const SHARED_STORES_SLOT = "__desktopModeSharedStores";
1193 function resolveSlot() {
1194 const w = window;
1195 let slot = w[SHARED_STORES_SLOT];
1196 if (!slot) {
1197 slot = /* @__PURE__ */ new Map();
1198 w[SHARED_STORES_SLOT] = slot;
1199 }
1200 return slot;
1201 }
1202 function createSharedStore(key, initialState) {
1203 const slot = resolveSlot();
1204 let record = slot.get(key);
1205 if (!record) {
1206 record = {
1207 state: initialState(),
1208 listeners: /* @__PURE__ */ new Set(),
1209 rebuild: initialState
1210 };
1211 slot.set(key, record);
1212 }
1213 const handle = {
1214 // `record.state` is the live reference. The getter on the
1215 // `state` field reads the latest value even if `reset()`
1216 // reassigned it to a fresh object.
1217 get state() {
1218 return record.state;
1219 },
1220 set state(next) {
1221 record.state = next;
1222 },
1223 getState() {
1224 return record.state;
1225 },
1226 notify() {
1227 for (const cb of Array.from(record.listeners)) {
1228 try {
1229 cb(record.state);
1230 } catch (err) {
1231 console.error(
1232 `[desktop-mode/shared-store:${key}] subscriber threw:`,
1233 err
1234 );
1235 }
1236 }
1237 },
1238 subscribe(cb) {
1239 record.listeners.add(cb);
1240 return () => {
1241 record.listeners.delete(cb);
1242 };
1243 },
1244 setState(patch) {
1245 const cur = record.state;
1246 if (typeof cur !== "object" || cur === null) {
1247 console.warn(
1248 `[desktop-mode/shared-store:${key}] setState called on a primitive store; use the state setter instead.`
1249 );
1250 return;
1251 }
1252 Object.assign(cur, patch);
1253 handle.notify();
1254 },
1255 reset() {
1256 const fresh = record.rebuild();
1257 const cur = record.state;
1258 if (typeof cur === "object" && cur !== null && typeof fresh === "object" && fresh !== null) {
1259 const target = cur;
1260 for (const k of Object.keys(target)) {
1261 delete target[k];
1262 }
1263 Object.assign(target, fresh);
1264 } else {
1265 record.state = fresh;
1266 }
1267 record.listeners.clear();
1268 }
1269 };
1270 return handle;
1271 }
1272 const store$1 = createSharedStore(
1273 "desktop-mode/games-registry",
1274 () => ({
1275 seed: [],
1276 listeners: /* @__PURE__ */ new Set()
1277 })
1278 );
1279 const seed = store$1.state.seed;
1280 const listeners = store$1.state.listeners;
1281 function register(entry) {
1282 throwOnRegistrationErrors(
1283 "Game",
1284 collectRegistrationErrors(entry, GAME_CHECKS),
1285 entry
1286 );
1287 const idx = seed.findIndex((g) => g.id === entry.id);
1288 if (idx >= 0) {
1289 seed[idx] = entry;
1290 } else {
1291 seed.push(entry);
1292 }
1293 notify$1();
1294 }
1295 function subscribe(cb) {
1296 listeners.add(cb);
1297 return () => {
1298 listeners.delete(cb);
1299 };
1300 }
1301 function notify$1() {
1302 const snapshot = Array.from(listeners);
1303 for (const cb of snapshot) {
1304 try {
1305 cb();
1306 } catch (err) {
1307 if (typeof console !== "undefined") {
1308 console.error(
1309 "[desktop-mode] games registry listener threw:",
1310 err
1311 );
1312 }
1313 }
1314 }
1315 }
1316 function all() {
1317 const copy = seed.slice();
1318 const filtered = applyFilters(HOOKS.GAMES, copy);
1319 if (!Array.isArray(filtered)) {
1320 if (typeof console !== "undefined") {
1321 console.warn(
1322 "[desktop-mode] `desktop-mode.games` filter returned a non-array; falling back to seed list."
1323 );
1324 }
1325 return copy;
1326 }
1327 return filtered.filter(isValidEntry);
1328 }
1329 function get(id) {
1330 return all().find((g) => g.id === id);
1331 }
1332 const GAME_CHECKS = [
1333 {
1334 field: "id",
1335 message: "missing or not a non-empty string",
1336 valid: (g) => typeof g.id === "string" && g.id !== ""
1337 },
1338 {
1339 field: "title",
1340 message: "missing or not a non-empty string",
1341 valid: (g) => typeof g.title === "string" && g.title !== ""
1342 },
1343 {
1344 field: "scoreColumns",
1345 message: "must be an array",
1346 valid: (g) => Array.isArray(g.scoreColumns)
1347 },
1348 {
1349 field: "render/scriptUrl",
1350 message: "needs a `render` callback or a `scriptUrl` to lazily load one",
1351 valid: (g) => typeof g.render === "function" || typeof g.scriptUrl === "string" && g.scriptUrl !== ""
1352 }
1353 ];
1354 function isValidEntry(entry) {
1355 return collectRegistrationErrors(entry, GAME_CHECKS).length === 0;
1356 }
1357 const FALLBACK_BASE = "http://localhost/";
1358 function joinRestUrl(restRoot, path) {
1359 const base = typeof window !== "undefined" && window.location ? window.location.href : FALLBACK_BASE;
1360 const url = new URL(restRoot, base);
1361 const trimmed = path.replace(/^\/+/, "");
1362 const queryAt = trimmed.indexOf("?");
1363 const route = queryAt === -1 ? trimmed : trimmed.slice(0, queryAt);
1364 const extraQuery = queryAt === -1 ? "" : trimmed.slice(queryAt + 1);
1365 if (url.searchParams.has("rest_route")) {
1366 const existing = url.searchParams.get("rest_route") ?? "/";
1367 const prefix = existing.endsWith("/") ? existing : existing + "/";
1368 url.searchParams.set("rest_route", prefix + route);
1369 } else {
1370 const pathname = url.pathname.endsWith("/") ? url.pathname : url.pathname + "/";
1371 url.pathname = pathname + route;
1372 }
1373 if (extraQuery) {
1374 const extras = new URLSearchParams(extraQuery);
1375 extras.forEach((value, key) => {
1376 url.searchParams.append(key, value);
1377 });
1378 }
1379 return url.toString();
1380 }
1381 const NONCE_HEADER = "X-WP-Nonce";
1382 function injectRestNonce(input, init) {
1383 const nonce = readRestNonce();
1384 if (!nonce) {
1385 return init;
1386 }
1387 const url = resolveUrl(input);
1388 if (!url || !isSameOriginRestUrl(url)) {
1389 return init;
1390 }
1391 const baseHeaders = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0);
1392 const headers = new Headers(baseHeaders ?? {});
1393 if (headers.has(NONCE_HEADER)) {
1394 return init;
1395 }
1396 headers.set(NONCE_HEADER, nonce);
1397 return { ...init ?? {}, headers };
1398 }
1399 function readRestNonce() {
1400 if (typeof window === "undefined") {
1401 return void 0;
1402 }
1403 const cfg = window.desktopModeConfig;
1404 const value = cfg?.restNonce;
1405 return typeof value === "string" && value.length > 0 ? value : void 0;
1406 }
1407 function resolveUrl(input) {
1408 try {
1409 const base = typeof window !== "undefined" && window.location ? window.location.href : void 0;
1410 if (typeof input === "string") {
1411 return new URL(input, base);
1412 }
1413 if (input instanceof URL) {
1414 return input;
1415 }
1416 if (typeof Request !== "undefined" && input instanceof Request) {
1417 return new URL(input.url, base);
1418 }
1419 return null;
1420 } catch {
1421 return null;
1422 }
1423 }
1424 function isSameOriginRestUrl(url) {
1425 if (typeof window === "undefined" || !window.location || url.origin !== window.location.origin) {
1426 return false;
1427 }
1428 if (url.pathname.includes("/wp-json/")) {
1429 return true;
1430 }
1431 if (url.searchParams.has("rest_route")) {
1432 return true;
1433 }
1434 return false;
1435 }
1436 function trackedFetch(input, init, opts = {}) {
1437 const fn = window.wp?.desktop?.fetch;
1438 if (typeof fn === "function") {
1439 return fn(input, init, opts);
1440 }
1441 const finalInit = injectRestNonce(input, init);
1442 return fetch(input, finalInit);
1443 }
1444 const SOURCE = "desktop-mode/games";
1445 function restEnv() {
1446 const wpGlobal = window.wp;
1447 const config = wpGlobal?.desktop?.config;
1448 return {
1449 restUrl: config?.restUrl || "/wp-json/",
1450 restNonce: config?.restNonce || ""
1451 };
1452 }
1453 async function call(path, init = {}, opts = {}) {
1454 const { restUrl, restNonce } = restEnv();
1455 const headers = new Headers(init.headers ?? {});
1456 headers.set("X-WP-Nonce", restNonce);
1457 if (init.body && !headers.has("Content-Type")) {
1458 headers.set("Content-Type", "application/json");
1459 }
1460 const res = await trackedFetch(
1461 joinRestUrl(restUrl, path),
1462 { ...init, headers, credentials: "same-origin" },
1463 { source: SOURCE, windowId: opts.windowId, silent: opts.silent }
1464 );
1465 const body = await res.json().catch(() => null);
1466 if (!res.ok) {
1467 const message = body?.message || `Games request failed (${res.status})`;
1468 const error = new Error(message);
1469 error.status = res.status;
1470 throw error;
1471 }
1472 return body;
1473 }
1474 function fetchScores(game, args = {}) {
1475 const params = new URLSearchParams({
1476 page: String(args.page ?? 1),
1477 per_page: String(args.perPage ?? 25),
1478 orderby: args.orderby ?? "score",
1479 order: args.order ?? "desc"
1480 });
1481 if (args.userId) {
1482 params.set("user_id", String(args.userId));
1483 }
1484 return call(`desktop-mode/v1/games/${game}/scores?${params}`);
1485 }
1486 function submitScore(game, submission, opts = {}) {
1487 return call(
1488 `desktop-mode/v1/games/${game}/scores`,
1489 {
1490 method: "POST",
1491 body: JSON.stringify({
1492 score: submission.score,
1493 meta: submission.meta ?? {}
1494 })
1495 },
1496 opts
1497 );
1498 }
1499 function fetchPlaytime() {
1500 return call("desktop-mode/v1/games/playtime");
1501 }
1502 function recordPlaytime(game, seconds, opts = {}) {
1503 return call(
1504 `desktop-mode/v1/games/${game}/playtime`,
1505 {
1506 method: "POST",
1507 body: JSON.stringify({ seconds })
1508 },
1509 opts
1510 );
1511 }
1512 function fetchChallenges(args = {}) {
1513 const params = new URLSearchParams({ box: args.box ?? "all" });
1514 if (args.state) {
1515 params.set("state", args.state);
1516 }
1517 return call(`desktop-mode/v1/games/challenges?${params}`);
1518 }
1519 function createChallenge(args) {
1520 return call("desktop-mode/v1/games/challenges", {
1521 method: "POST",
1522 body: JSON.stringify({
1523 game: args.game,
1524 recipient_id: args.recipientId,
1525 score: args.score,
1526 meta: args.meta ?? {}
1527 })
1528 });
1529 }
1530 function acceptChallenge(id) {
1531 return call(`desktop-mode/v1/games/challenges/${id}/accept`, {
1532 method: "POST"
1533 });
1534 }
1535 function declineChallenge(id) {
1536 return call(`desktop-mode/v1/games/challenges/${id}/decline`, {
1537 method: "POST"
1538 });
1539 }
1540 function completeChallenge(id, submission, opts = {}) {
1541 return call(
1542 `desktop-mode/v1/games/challenges/${id}/complete`,
1543 {
1544 method: "POST",
1545 body: JSON.stringify({
1546 score: submission.score,
1547 meta: submission.meta ?? {}
1548 })
1549 },
1550 opts
1551 );
1552 }
1553 const FLUSH_INTERVAL_MS = 6e4;
1554 function startPlaytimeTracker(gameId, opts = {}) {
1555 let runningSince = Date.now();
1556 let bankedMs = 0;
1557 let stopped = false;
1558 const harvest = () => {
1559 if (runningSince === null) {
1560 return;
1561 }
1562 const now = Date.now();
1563 bankedMs += Math.max(0, now - runningSince);
1564 runningSince = now;
1565 };
1566 const flush = () => {
1567 harvest();
1568 const seconds = Math.floor(bankedMs / 1e3);
1569 if (seconds < 1) {
1570 return;
1571 }
1572 bankedMs -= seconds * 1e3;
1573 recordPlaytime(gameId, seconds, {
1574 windowId: opts.windowId,
1575 silent: true
1576 }).catch(() => {
1577 bankedMs += seconds * 1e3;
1578 });
1579 };
1580 const interval = setInterval(flush, FLUSH_INTERVAL_MS);
1581 return {
1582 pause: () => {
1583 harvest();
1584 runningSince = null;
1585 },
1586 resume: () => {
1587 if (stopped || runningSince !== null) {
1588 return;
1589 }
1590 runningSince = Date.now();
1591 },
1592 stop: () => {
1593 if (stopped) {
1594 return;
1595 }
1596 stopped = true;
1597 clearInterval(interval);
1598 harvest();
1599 runningSince = null;
1600 flush();
1601 }
1602 };
1603 }
1604 function sumPlaytimeSince(daily, todayKey, windowDays) {
1605 const today = /* @__PURE__ */ new Date(`${todayKey}T00:00:00Z`);
1606 if (isNaN(today.getTime()) || windowDays < 1) {
1607 return 0;
1608 }
1609 const cutoff = new Date(
1610 today.getTime() - (windowDays - 1) * 864e5
1611 ).toISOString().slice(0, 10);
1612 let sum = 0;
1613 for (const [day, seconds] of Object.entries(daily)) {
1614 if (day >= cutoff && day <= todayKey) {
1615 sum += Math.max(0, Math.floor(Number(seconds) || 0));
1616 }
1617 }
1618 return sum;
1619 }
1620 function formatPlaytime(seconds) {
1621 const total = Math.max(0, Math.floor(Number(seconds) || 0));
1622 const hours = Math.floor(total / 3600);
1623 const minutes = Math.floor(total % 3600 / 60);
1624 if (hours > 0) {
1625 return sprintf(
1626 /* translators: 1: hours, 2: minutes. */
1627 __("%1$dh %2$dm"),
1628 hours,
1629 minutes
1630 );
1631 }
1632 if (minutes > 0) {
1633 return sprintf(__("%dm"), minutes);
1634 }
1635 return sprintf(__("%ds"), total);
1636 }
1637 function desktopGlobal() {
1638 return window.wp?.desktop ?? {};
1639 }
1640 const DEFAULT_GAME_WIDTH = 760;
1641 const DEFAULT_GAME_HEIGHT = 560;
1642 const DEFAULT_GAME_MIN_WIDTH = 480;
1643 const DEFAULT_GAME_MIN_HEIGHT = 380;
1644 async function ensureGameRender(entry) {
1645 if (typeof entry.render === "function") {
1646 return entry;
1647 }
1648 const loadVendorScript = desktopGlobal().loadVendorScript;
1649 if (!entry.scriptUrl || typeof loadVendorScript !== "function") {
1650 throw new Error(
1651 `[desktop-mode] Game "${entry.id}" has no render callback and no loadable script.`
1652 );
1653 }
1654 await loadVendorScript(entry.scriptUrl, {
1655 translations: entry.scriptTranslations,
1656 l10n: entry.scriptL10n,
1657 before: entry.scriptBefore,
1658 after: entry.scriptAfter
1659 });
1660 const globals = window;
1661 const def = globals.desktopModeGames?.[entry.id];
1662 if (!def || typeof def.render !== "function") {
1663 throw new Error(
1664 `[desktop-mode] No game def on window.desktopModeGames["${entry.id}"]. Script loaded but didn't publish a def — check the plugin's global assignment.`
1665 );
1666 }
1667 const upgraded = {
1668 ...entry,
1669 render: def.render,
1670 window: def.window ?? entry.window
1671 };
1672 register(upgraded);
1673 return upgraded;
1674 }
1675 async function launchGame(id, opts = {}) {
1676 const desktop = desktopGlobal();
1677 let entry = get(id);
1678 if (!entry) {
1679 throw new Error(`[desktop-mode] Unknown game "${id}".`);
1680 }
1681 entry = await ensureGameRender(entry);
1682 const render2 = entry.render;
1683 if (typeof render2 !== "function") {
1684 throw new Error(
1685 `[desktop-mode] Game "${id}" did not provide a render callback.`
1686 );
1687 }
1688 if (typeof desktop.registerWindow !== "function") {
1689 throw new Error(
1690 "[desktop-mode] wp.desktop.registerWindow is missing — the shell must boot before launching games."
1691 );
1692 }
1693 const windowId = `desktop-mode-game-${id}`;
1694 const suspendReason = `game:${windowId}`;
1695 const manager = desktop.windowManager;
1696 const existing = manager?.getByBaseId?.(windowId) ?? manager?.getById(windowId);
1697 if (existing) {
1698 const winDesktop = existing.config?.desktopId;
1699 if (winDesktop && manager?.switchDesktop && winDesktop !== manager?.getActiveDesktopId?.()) {
1700 manager.switchDesktop(winDesktop);
1701 }
1702 void desktop.registerWindow({
1703 id: windowId,
1704 title: entry.title,
1705 icon: entry.icon,
1706 render: () => void 0
1707 });
1708 return;
1709 }
1710 desktop.wallpaper?.suspend(suspendReason);
1711 let resumed = false;
1712 const resumeOnce = () => {
1713 if (resumed) {
1714 return;
1715 }
1716 resumed = true;
1717 desktop.wallpaper?.resume(suspendReason);
1718 };
1719 let tracker = null;
1720 const stopTracker = () => {
1721 tracker?.stop();
1722 tracker = null;
1723 };
1724 desktop.onWindow?.(windowId, {
1725 closed: () => {
1726 stopTracker();
1727 resumeOnce();
1728 },
1729 minimized: () => tracker?.pause(),
1730 restored: () => tracker?.resume()
1731 });
1732 const submit = (result) => {
1733 if (opts.challenge) {
1734 return completeChallenge(opts.challenge.id, result, {
1735 windowId
1736 }).then(() => void 0);
1737 }
1738 return submitScore(id, result, { windowId }).then(
1739 () => void 0
1740 );
1741 };
1742 try {
1743 await desktop.registerWindow({
1744 id: windowId,
1745 title: entry.title,
1746 icon: entry.icon,
1747 width: entry.window?.width ?? DEFAULT_GAME_WIDTH,
1748 height: entry.window?.height ?? DEFAULT_GAME_HEIGHT,
1749 minWidth: entry.window?.minWidth ?? DEFAULT_GAME_MIN_WIDTH,
1750 minHeight: entry.window?.minHeight ?? DEFAULT_GAME_MIN_HEIGHT,
1751 render: (body) => {
1752 const ctx = {
1753 windowId,
1754 container: body,
1755 config: entry.config ?? {},
1756 challenge: opts.challenge,
1757 submitScore: submit,
1758 close: () => {
1759 desktop.windowManager?.getById(windowId)?.close();
1760 }
1761 };
1762 tracker = startPlaytimeTracker(id, { windowId });
1763 const teardown = render2(ctx);
1764 return () => {
1765 try {
1766 teardown?.();
1767 } finally {
1768 stopTracker();
1769 resumeOnce();
1770 }
1771 };
1772 }
1773 });
1774 } catch (err) {
1775 stopTracker();
1776 resumeOnce();
1777 throw err;
1778 }
1779 }
1780 function hashTitleToHue(input) {
1781 if (!input) {
1782 return 214;
1783 }
1784 let hash = 5381;
1785 for (let i = 0; i < input.length; i++) {
1786 hash = Math.imul(hash, 33) + input.charCodeAt(i);
1787 }
1788 return (hash % 360 + 360) % 360;
1789 }
1790 const avatarStyles = css`:host{display:inline-flex;position:relative;width:var( --wpd-avatar-size,32px );height:var( --wpd-avatar-size,32px );flex:0 0 auto;vertical-align:middle;line-height:0;perspective:calc( var( --wpd-avatar-size,32px ) * 8 );--wpd-avatar-tilt-x:0deg;--wpd-avatar-tilt-y:0deg;--wpd-avatar-hover:0;--wpd-avatar-glare-x:50%;--wpd-avatar-glare-y:50%}:host( [ hidden ] ){display:none}.wpd-avatar__tile{position:relative;width:100%;height:100%;border-radius:50%;overflow:hidden;background:var( --desktop-mode-window-bg,#f0f0f1 );color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:calc( var( --wpd-avatar-size,32px ) * 0.48 );line-height:1;letter-spacing:0;font-feature-settings:'tnum' 1;user-select:none;transform-style:preserve-3d;transform:rotateX( var( --wpd-avatar-tilt-x ) ) rotateY( var( --wpd-avatar-tilt-y ) ) scale( calc( 1 + var( --wpd-avatar-hover ) * 0.07 ) );transition:transform 220ms cubic-bezier( 0.2,0.8,0.2,1 ),box-shadow 220ms cubic-bezier( 0.2,0.8,0.2,1 );box-shadow:inset 0 0 0 1px rgba( 255,255,255,calc( 0.18 + 0.22 * var( --wpd-avatar-hover ) ) ),inset 0 0 0 calc( 1px + var( --wpd-avatar-hover ) * 1px ) rgba( 0,0,0,calc( 0.08 + 0.04 * var( --wpd-avatar-hover ) ) ),0 calc( 1px + var( --wpd-avatar-hover ) * 8px ) calc( 6px + var( --wpd-avatar-hover ) * 18px ) rgba( 0,0,0,calc( 0.08 + 0.18 * var( --wpd-avatar-hover ) ) )}.wpd-avatar__tile::after{content:'';position:absolute;inset:0;border-radius:50%;background:radial-gradient( circle at var( --wpd-avatar-glare-x ) var( --wpd-avatar-glare-y ),rgba( 255,255,255,0.55 ) 0%,rgba( 255,255,255,0 ) 55% );opacity:var( --wpd-avatar-hover );mix-blend-mode:overlay;pointer-events:none;transition:opacity 220ms cubic-bezier( 0.2,0.8,0.2,1 )}.wpd-avatar__tile::before{content:'';position:absolute;inset:calc( var( --wpd-avatar-hover ) * -3px );border-radius:50%;background:radial-gradient( circle at var( --wpd-avatar-glare-x ) var( --wpd-avatar-glare-y ),rgba( 99,102,241,calc( 0.35 * var( --wpd-avatar-hover ) ) ) 0%,rgba( 99,102,241,0 ) 70% );filter:blur( 4px );pointer-events:none;z-index:-1;transition:inset 220ms cubic-bezier( 0.2,0.8,0.2,1 ),background 220ms}.wpd-avatar__tile img{width:100%;height:100%;object-fit:cover;display:block;transform:translateZ( 1px )}.wpd-avatar__dot{position:absolute;bottom:0;inset-inline-end:0;width:calc( var( --wpd-avatar-size,32px ) * 0.32 );height:calc( var( --wpd-avatar-size,32px ) * 0.32 );min-width:8px;min-height:8px;border-radius:50%;box-sizing:border-box;border:2px solid var( --wpd-avatar-dot-ring,var( --desktop-mode-window-bg,#fff ) );background:var( --wpd-avatar-dot-color,transparent );z-index:2}.wpd-avatar__dot--online{background:var( --desktop-mode-success,#00a32a )}.wpd-avatar__dot--inactive{background:var( --desktop-mode-warning,#dba617 )}.wpd-avatar__dot--offline{background:var( --desktop-mode-muted,#8c8f94 )}@media ( prefers-reduced-motion:reduce ){.wpd-avatar__tile{transform:none;transition:box-shadow 200ms}.wpd-avatar__tile::after,.wpd-avatar__tile::before{display:none}}`;
1791 const SIZE_MAP = {
1792 xs: 20,
1793 sm: 24,
1794 md: 40,
1795 lg: 64,
1796 xl: 96
1797 };
1798 const VALID_PRESENCE = /* @__PURE__ */ new Set(["online", "inactive", "offline"]);
1799 const _WpdAvatar = class _WpdAvatar extends Component {
1800 constructor() {
1801 super(...arguments);
1802 this._presenceHandler = null;
1803 this._imgFailed = false;
1804 this._onPointerMove = null;
1805 this._onPointerEnter = null;
1806 this._onPointerLeave = null;
1807 this._tiltRaf = 0;
1808 this._pendingTiltX = "0deg";
1809 this._pendingTiltY = "0deg";
1810 this._pendingGlareX = "50%";
1811 this._pendingGlareY = "50%";
1812 }
1813 connectedCallback() {
1814 super.connectedCallback();
1815 this._maybeAttachPresenceListener();
1816 this._attachHoverEffect();
1817 }
1818 disconnectedCallback() {
1819 if (this._presenceHandler) {
1820 document.removeEventListener(
1821 "desktop-mode-presence-changed",
1822 this._presenceHandler
1823 );
1824 this._presenceHandler = null;
1825 }
1826 this._detachHoverEffect();
1827 }
1828 attributeChangedCallback(name, oldValue, newValue) {
1829 super.attributeChangedCallback(name, oldValue, newValue);
1830 if (name === "src") {
1831 this._imgFailed = false;
1832 }
1833 if (name === "user-id" || name === "presence") {
1834 this._maybeAttachPresenceListener();
1835 }
1836 }
1837 render() {
1838 const src = this._attr("src");
1839 const name = this._attr("name") || "";
1840 const altRaw = this._attr("alt");
1841 const alt = altRaw !== null ? altRaw : name;
1842 const sizeRaw = this._attr("size");
1843 const size = this._resolveSize(sizeRaw);
1844 const presence = this._presenceForRender();
1845 const clickable = this._attr("clickable") !== null;
1846 this.style.setProperty("--wpd-avatar-size", `${size}px`);
1847 const initialsBg = src && !this._imgFailed ? "" : this._initialsBg(name);
1848 const inner = src && !this._imgFailed ? html`<img
1849 src=${src}
1850 alt=${alt}
1851 @error=${() => this._onImgError()}
1852 loading="lazy"
1853 />` : this._initials(name);
1854 const dot = presence ? html`<span
1855 class=${`wpd-avatar__dot wpd-avatar__dot--${presence}`}
1856 aria-label=${this._presenceLabel(presence)}
1857 ></span>` : html``;
1858 if (clickable) {
1859 return html`
1860 <button
1861 type="button"
1862 class="wpd-avatar__tile"
1863 aria-label=${alt || "User"}
1864 style=${initialsBg ? `background:${initialsBg};` : ""}
1865 @click=${(e) => this._onClick(e)}
1866 >${inner}</button>
1867 ${dot}
1868 `;
1869 }
1870 return html`
1871 <div
1872 class="wpd-avatar__tile"
1873 role="img"
1874 aria-label=${alt || "User"}
1875 style=${initialsBg ? `background:${initialsBg};` : ""}
1876 >${inner}</div>
1877 ${dot}
1878 `;
1879 }
1880 _attr(name) {
1881 return this.getAttribute(name);
1882 }
1883 _resolveSize(raw) {
1884 if (!raw) {
1885 return 32;
1886 }
1887 if (raw in SIZE_MAP) {
1888 return SIZE_MAP[raw];
1889 }
1890 const n = Number(raw);
1891 return Number.isFinite(n) && n > 0 ? n : 32;
1892 }
1893 _initials(name) {
1894 const trimmed = name.trim();
1895 if (!trimmed) {
1896 return "?";
1897 }
1898 return Array.from(trimmed)[0]?.toUpperCase() ?? "?";
1899 }
1900 _initialsBg(name) {
1901 const hue = hashTitleToHue(name);
1902 return `linear-gradient(135deg, hsl(${hue} 62% 55%), hsl(${(hue + 24) % 360} 58% 42%))`;
1903 }
1904 _presenceForRender() {
1905 const raw = this._attr("presence");
1906 if (raw && VALID_PRESENCE.has(raw)) {
1907 return raw;
1908 }
1909 return null;
1910 }
1911 _presenceLabel(p) {
1912 switch (p) {
1913 case "online":
1914 return "Online";
1915 case "inactive":
1916 return "Inactive";
1917 case "offline":
1918 return "Offline";
1919 }
1920 }
1921 _onImgError() {
1922 this._imgFailed = true;
1923 this.requestUpdate();
1924 }
1925 _onClick(e) {
1926 const userId = this._attr("user-id");
1927 const detail = {
1928 userId: userId !== null ? Number(userId) || null : null,
1929 originalEvent: e
1930 };
1931 this.emit("wpd-avatar-click", detail);
1932 }
1933 /**
1934 * Wire up the pointer-driven tilt + glare. Listens on the host so
1935 * one set of bindings covers both the clickable `<button>` and
1936 * the decorative `<div>` rendering branches. The actual math
1937 * runs in `_handlePointerMove`; this method just owns the
1938 * bind/unbind plumbing.
1939 *
1940 * Bails entirely when `prefers-reduced-motion: reduce` is set —
1941 * the CSS has its own `@media` guard for the visual layer, but
1942 * skipping the JS too saves the per-event work for users who
1943 * won't benefit from it.
1944 */
1945 _attachHoverEffect() {
1946 const reduceMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
1947 if (reduceMotion) {
1948 return;
1949 }
1950 this._onPointerEnter = () => {
1951 this.style.setProperty("--wpd-avatar-hover", "1");
1952 };
1953 this._onPointerLeave = () => {
1954 this.style.setProperty("--wpd-avatar-hover", "0");
1955 this._pendingTiltX = "0deg";
1956 this._pendingTiltY = "0deg";
1957 this._pendingGlareX = "50%";
1958 this._pendingGlareY = "50%";
1959 this._flushTilt();
1960 };
1961 this._onPointerMove = (e) => {
1962 const rect = this.getBoundingClientRect();
1963 if (rect.width === 0 || rect.height === 0) {
1964 return;
1965 }
1966 const nx = (e.clientX - rect.left) / rect.width - 0.5;
1967 const ny = (e.clientY - rect.top) / rect.height - 0.5;
1968 const MAX = 14;
1969 this._pendingTiltY = `${(nx * MAX).toFixed(2)}deg`;
1970 this._pendingTiltX = `${(-ny * MAX).toFixed(2)}deg`;
1971 const gx = Math.max(0, Math.min(100, (nx + 0.5) * 100));
1972 const gy = Math.max(0, Math.min(100, (ny + 0.5) * 100));
1973 this._pendingGlareX = `${gx.toFixed(1)}%`;
1974 this._pendingGlareY = `${gy.toFixed(1)}%`;
1975 if (!this._tiltRaf) {
1976 this._tiltRaf = requestAnimationFrame(() => this._flushTilt());
1977 }
1978 };
1979 this.addEventListener("pointerenter", this._onPointerEnter);
1980 this.addEventListener("pointerleave", this._onPointerLeave);
1981 this.addEventListener("pointermove", this._onPointerMove);
1982 }
1983 _flushTilt() {
1984 this._tiltRaf = 0;
1985 this.style.setProperty("--wpd-avatar-tilt-x", this._pendingTiltX);
1986 this.style.setProperty("--wpd-avatar-tilt-y", this._pendingTiltY);
1987 this.style.setProperty("--wpd-avatar-glare-x", this._pendingGlareX);
1988 this.style.setProperty("--wpd-avatar-glare-y", this._pendingGlareY);
1989 }
1990 _detachHoverEffect() {
1991 if (this._onPointerMove) {
1992 this.removeEventListener("pointermove", this._onPointerMove);
1993 this._onPointerMove = null;
1994 }
1995 if (this._onPointerEnter) {
1996 this.removeEventListener("pointerenter", this._onPointerEnter);
1997 this._onPointerEnter = null;
1998 }
1999 if (this._onPointerLeave) {
2000 this.removeEventListener("pointerleave", this._onPointerLeave);
2001 this._onPointerLeave = null;
2002 }
2003 if (this._tiltRaf) {
2004 cancelAnimationFrame(this._tiltRaf);
2005 this._tiltRaf = 0;
2006 }
2007 }
2008 _maybeAttachPresenceListener() {
2009 const userId = this._attr("user-id");
2010 const explicit = this._attr("presence");
2011 const wantsListener = !!userId && !explicit;
2012 if (wantsListener && !this._presenceHandler) {
2013 this._presenceHandler = (e) => {
2014 const detail = e.detail;
2015 if (!detail) {
2016 return;
2017 }
2018 if (String(detail.userId) !== String(userId)) {
2019 return;
2020 }
2021 if (detail.newStatus && VALID_PRESENCE.has(detail.newStatus)) {
2022 this.setAttribute("presence", detail.newStatus);
2023 }
2024 };
2025 document.addEventListener(
2026 "desktop-mode-presence-changed",
2027 this._presenceHandler
2028 );
2029 } else if (!wantsListener && this._presenceHandler) {
2030 document.removeEventListener(
2031 "desktop-mode-presence-changed",
2032 this._presenceHandler
2033 );
2034 this._presenceHandler = null;
2035 }
2036 }
2037 };
2038 _WpdAvatar.props = ["src", "alt", "name", "size", "presence", "userId", "clickable"];
2039 _WpdAvatar.styles = [avatarStyles];
2040 _WpdAvatar.help = {
2041 title: "Avatar",
2042 summary: "Image-or-initials user tile with an optional presence dot. Falls back to a deterministic-hue letter tile when src is empty. Set user-id to auto-subscribe the dot to desktop-mode-presence-changed.",
2043 status: "stable",
2044 since: "0.6.0",
2045 props: [
2046 { name: "src", type: "string", description: "Image URL. Falls back to initials when empty or load fails." },
2047 { name: "alt", type: "string", description: "Alt text for the image. Defaults to `name` when omitted." },
2048 { name: "name", type: "string", description: "Used for initials + hue fallback when no src." },
2049 {
2050 name: "size",
2051 type: 'number | "xs" | "sm" | "md" | "lg" | "xl"',
2052 description: "Pixel size or named preset. Default 32 (sm-ish). Sets --wpd-avatar-size."
2053 },
2054 {
2055 name: "presence",
2056 type: '"online" | "inactive" | "offline"',
2057 description: "Presence dot color. Omit for no dot."
2058 },
2059 {
2060 name: "user-id",
2061 type: "number",
2062 description: "When set AND presence is unset, auto-subscribes to desktop-mode-presence-changed and updates the dot."
2063 },
2064 {
2065 name: "clickable",
2066 type: "boolean attribute",
2067 description: "Renders the tile as a focusable button that emits wpd-avatar-click. Omit for a decorative tile that lets clicks pass through to the surrounding row."
2068 }
2069 ],
2070 events: [
2071 {
2072 name: "wpd-avatar-click",
2073 description: "Fires on click when the `clickable` attribute is set. Detail carries userId when set.",
2074 detail: "{ userId: number | null }"
2075 }
2076 ],
2077 cssProps: [
2078 { name: "--wpd-avatar-size", description: "Tile size in any CSS length. Set automatically by the size attribute." },
2079 { name: "--wpd-avatar-dot-ring", description: "Background color used as the dot ring (matches surrounding panel by default)." }
2080 ],
2081 example: html`
2082 <wpd-avatar name="Daniel" size="40" presence="online"></wpd-avatar>
2083 `
2084 };
2085 let WpdAvatar = _WpdAvatar;
2086 defineComponent("wpd-avatar", WpdAvatar);
2087 const modalStyles = css`:host{display:none;position:fixed;inset:0;align-items:center;justify-content:center;background:rgba( 0,0,0,0.45 );backdrop-filter:blur( 2px );z-index:10000;--desktop-mode-text:#f0f0f1;--desktop-mode-text-muted:#bbc1c7;--desktop-mode-muted:#a7aaad;--desktop-mode-muted-fg:#a7aaad;--desktop-mode-border:rgba( 255,255,255,0.25 );--desktop-mode-window-bg:#2c3338;--wpd-button-bg-hover:rgba( 255,255,255,0.08 )}:host( [ open ] ){display:flex}.dialog{max-width:92vw;max-height:90vh;background:var( --wpd-modal-bg,var( --desktop-mode-bg,#1d2327 ) );color:var( --wpd-modal-fg,var( --desktop-mode-fg,#fff ) );border:1px solid rgba( 255,255,255,0.08 );border-radius:10px;box-shadow:0 20px 50px rgba( 0,0,0,0.6 );display:flex;flex-direction:column;overflow:hidden}:host( [ size='sm' ] ) .dialog{width:min( 360px,92vw )}:host(:not( [ size ] ) ) .dialog,:host( [ size='md' ] ) .dialog{width:min( 540px,92vw )}:host( [ size='lg' ] ) .dialog{width:min( 760px,94vw )}.header{display:flex;align-items:center;gap:10px;padding:16px 20px 12px;border-bottom:1px solid rgba( 255,255,255,0.06 )}.title{margin:0;flex:1;font-size:15px;font-weight:600}.header-actions{display:flex;gap:6px}.header-actions::slotted( * ){margin-inline-start:6px}.close{background:transparent;border:0;color:inherit;font-size:18px;line-height:1;padding:4px 8px;border-radius:4px;cursor:pointer;opacity:0.7}.close:hover{opacity:1;background:rgba( 255,255,255,0.08 )}.body{padding:16px 20px;overflow:auto;flex:1 1 auto;font-size:13px;line-height:1.5}.footer{padding:12px 20px 16px;border-top:1px solid rgba( 255,255,255,0.06 )}.footer slot{display:flex;justify-content:flex-end;gap:10px;flex-wrap:wrap}:host( [ mandatory ] ) .close{display:none}`;
2088 const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
2089 const _WpdModal = class _WpdModal extends Component {
2090 constructor() {
2091 super(...arguments);
2092 this._prevFocus = null;
2093 this._onKey = (e) => {
2094 if (e.key === "Escape" && !this.hasAttribute("mandatory")) {
2095 e.preventDefault();
2096 this._cancel();
2097 return;
2098 }
2099 if (e.key === "Tab") {
2100 const f = this._focusables();
2101 if (f.length === 0) {
2102 return;
2103 }
2104 const first = f[0];
2105 const last = f[f.length - 1];
2106 const doc = this.ownerDocument;
2107 const fallback = doc ? doc.activeElement : null;
2108 const active = e.composedPath()[0] || fallback;
2109 if (e.shiftKey && active === first) {
2110 e.preventDefault();
2111 last.focus();
2112 } else if (!e.shiftKey && active === last) {
2113 e.preventDefault();
2114 first.focus();
2115 }
2116 }
2117 };
2118 this._onBackdrop = (e) => {
2119 if (this.hasAttribute("mandatory")) {
2120 return;
2121 }
2122 const path = e.composedPath();
2123 const original = path.length > 0 ? path[0] : e.target;
2124 if (original === this) {
2125 this._cancel();
2126 }
2127 };
2128 }
2129 connectedCallback() {
2130 super.connectedCallback();
2131 this.setAttribute("role", "dialog");
2132 this.setAttribute("aria-modal", "true");
2133 this.addEventListener("keydown", this._onKey);
2134 this.addEventListener("click", this._onBackdrop);
2135 }
2136 disconnectedCallback() {
2137 this.removeEventListener("keydown", this._onKey);
2138 this.removeEventListener("click", this._onBackdrop);
2139 }
2140 attributeChangedCallback(name, oldValue, newValue) {
2141 super.attributeChangedCallback?.(name, oldValue, newValue);
2142 if (name === "open") {
2143 if (newValue !== null) {
2144 const doc = this.ownerDocument;
2145 this._prevFocus = doc ? doc.activeElement : null;
2146 queueMicrotask(() => this._focusFirst());
2147 } else if (this._prevFocus) {
2148 try {
2149 this._prevFocus.focus();
2150 } catch (e) {
2151 }
2152 this._prevFocus = null;
2153 }
2154 }
2155 }
2156 showModal() {
2157 this.setAttribute("open", "");
2158 }
2159 hideModal() {
2160 this.removeAttribute("open");
2161 }
2162 _focusables() {
2163 const root = this.shadowRoot;
2164 if (!root) {
2165 return [];
2166 }
2167 const slotted = Array.from(this.querySelectorAll(FOCUSABLE));
2168 const inShadow = Array.from(root.querySelectorAll(FOCUSABLE));
2169 return [...slotted, ...inShadow].filter((el) => el.offsetParent !== null || el.tagName === "BUTTON");
2170 }
2171 _focusFirst() {
2172 const f = this._focusables();
2173 if (f.length > 0) {
2174 f[0].focus();
2175 } else {
2176 const inner = this.shadowRoot?.querySelector(".dialog");
2177 inner?.focus?.();
2178 }
2179 }
2180 _cancel() {
2181 const ev = new CustomEvent("wpd-modal-cancel", {
2182 bubbles: true,
2183 cancelable: true,
2184 composed: true
2185 });
2186 const allowed = this.dispatchEvent(ev);
2187 if (allowed) {
2188 this.hideModal();
2189 }
2190 }
2191 render() {
2192 const title = this.getAttribute("title") ?? "";
2193 const mandatory = this.hasAttribute("mandatory");
2194 return html`
2195 <div class="dialog" tabindex="-1">
2196 ${title ? html`
2197 <div class="header">
2198 <h2 class="title">${title}</h2>
2199 <div class="header-actions">
2200 <slot name="header-actions"></slot>
2201 ${mandatory ? html`` : html`<button
2202 type="button"
2203 class="close"
2204 aria-label="Close"
2205 @click=${() => this._cancel()}
2206 >×</button>`}
2207 </div>
2208 </div>
2209 ` : html``}
2210 <div class="body">
2211 <slot></slot>
2212 </div>
2213 <div class="footer">
2214 <slot name="footer"></slot>
2215 </div>
2216 </div>
2217 `;
2218 }
2219 };
2220 _WpdModal.props = ["open", "title", "size", "mandatory"];
2221 _WpdModal.styles = [modalStyles];
2222 _WpdModal.help = {
2223 title: "Modal overlay",
2224 summary: "Overlay container with title, body, and footer slots. Handles ESC, click-outside, focus trap. Use for rich modal flows that go beyond a yes/no confirm. The dialog surface is dark and re-points the shared surface tokens (--desktop-mode-text/-muted/-border/-window-bg, --wpd-button-bg-hover) so wpd-* controls slotted into it resolve readable dark-surface colors automatically.",
2225 status: "experimental",
2226 since: "0.8.5",
2227 props: [
2228 { name: "open", type: "boolean attribute", description: "Mounts the dialog visible." },
2229 { name: "title", type: "string", description: "Heading shown at the top of the dialog." },
2230 { name: "size", type: "'sm' | 'md' | 'lg'", default: "md", description: "Width preset." },
2231 {
2232 name: "mandatory",
2233 type: "boolean attribute",
2234 description: "Disables ESC, click-outside and the close button."
2235 }
2236 ],
2237 slots: [
2238 { name: "(default)", description: "Body content." },
2239 { name: "footer", description: "Footer button row, right-aligned." },
2240 { name: "header-actions", description: "Extra actions next to the close button." }
2241 ],
2242 events: [
2243 {
2244 name: "wpd-modal-cancel",
2245 description: "Fires when the user dismisses the modal (ESC, click-outside, close button). Cancelable; calling `preventDefault()` keeps the modal open."
2246 }
2247 ]
2248 };
2249 let WpdModal = _WpdModal;
2250 defineComponent("wpd-modal", WpdModal);
2251 const userSearchStyles = css`:host{display:block;position:relative;font-size:13px}.input{width:100%;padding:8px 10px;background:var( --wpd-input-bg,rgba( 255,255,255,0.06 ) );color:inherit;border:1px solid rgba( 255,255,255,0.12 );border-radius:6px;font:inherit;box-sizing:border-box}.input:focus{outline:2px solid var( --wp-admin-theme-color,#2271b1 );outline-offset:-1px}.dropdown{background:var( --desktop-mode-bg,#1d2327 );color:var( --desktop-mode-fg,#fff );border:1px solid rgba( 255,255,255,0.18 );border-radius:6px;overflow:auto;z-index:11000;box-shadow:0 12px 32px rgba( 0,0,0,0.5 )}.empty.error{color:#ff8080}.item{display:flex;align-items:center;gap:10px;padding:8px 10px;cursor:pointer;border:0;background:transparent;color:inherit;width:100%;text-align:start;font:inherit}.item:hover,.item:focus{background:rgba( 255,255,255,0.06 );outline:none}.avatar{width:24px;height:24px;border-radius:50%;flex:0 0 auto;background:rgba( 255,255,255,0.1 )}.name{font-weight:500}.slug{opacity:0.6;font-size:12px}.empty{padding:12px;color:rgba( 255,255,255,0.5 );font-size:12px}`;
2252 const _WpdUserSearch = class _WpdUserSearch extends Component {
2253 constructor() {
2254 super(...arguments);
2255 this._timer = null;
2256 this._abort = null;
2257 this._results = [];
2258 this._query = "";
2259 this._open = false;
2260 this._phase = "idle";
2261 this._error = "";
2262 this._dropdownStyle = "";
2263 this._onScrollOrResize = () => void 0;
2264 this._onInput = (e) => {
2265 const value = e.target.value;
2266 this._query = value;
2267 this._scheduleSearch(value);
2268 };
2269 this._onFocus = () => {
2270 if (this._results.length === 0 && this._phase === "idle") {
2271 this._scheduleSearch(this._query);
2272 return;
2273 }
2274 this._open = true;
2275 this._positionDropdown();
2276 this.requestUpdate();
2277 };
2278 this._onBlur = () => {
2279 setTimeout(() => {
2280 this._open = false;
2281 this.requestUpdate();
2282 }, 150);
2283 };
2284 this._pick = (user) => {
2285 this.emit("wpd-user-pick", { user });
2286 this._results = [];
2287 this._open = false;
2288 this._phase = "idle";
2289 this._query = "";
2290 const input = this.shadowRoot?.querySelector(".input");
2291 if (input) {
2292 input.value = "";
2293 }
2294 this.requestUpdate();
2295 };
2296 }
2297 connectedCallback() {
2298 super.connectedCallback();
2299 this._onScrollOrResize = () => {
2300 if (this._open) {
2301 this._positionDropdown();
2302 this.requestUpdate();
2303 }
2304 };
2305 window.addEventListener("resize", this._onScrollOrResize);
2306 window.addEventListener("scroll", this._onScrollOrResize, true);
2307 }
2308 disconnectedCallback() {
2309 if (this._timer) {
2310 clearTimeout(this._timer);
2311 }
2312 if (this._abort) {
2313 this._abort.abort();
2314 }
2315 window.removeEventListener("resize", this._onScrollOrResize);
2316 window.removeEventListener("scroll", this._onScrollOrResize, true);
2317 }
2318 _endpoint() {
2319 const attr = this.getAttribute("endpoint");
2320 if (attr) {
2321 return attr;
2322 }
2323 return window.desktopModeConfig?.filesUsersSearchUrl || "";
2324 }
2325 _scheduleSearch(q) {
2326 if (this._timer) {
2327 clearTimeout(this._timer);
2328 }
2329 this._phase = "loading";
2330 this._open = true;
2331 this._positionDropdown();
2332 this.requestUpdate();
2333 this._timer = setTimeout(() => this._runSearch(q), 200);
2334 }
2335 async _runSearch(q) {
2336 const url = this._endpoint();
2337 if (!url) {
2338 this._phase = "error";
2339 this._error = "Search endpoint is not configured.";
2340 this._results = [];
2341 this._open = true;
2342 this.requestUpdate();
2343 return;
2344 }
2345 if (this._abort) {
2346 this._abort.abort();
2347 }
2348 const ctrl = new AbortController();
2349 this._abort = ctrl;
2350 const exclude = this.getAttribute("exclude") || "";
2351 const full = url + "?q=" + encodeURIComponent(q) + "&exclude=" + encodeURIComponent(exclude);
2352 try {
2353 const init = {
2354 signal: ctrl.signal,
2355 credentials: "same-origin"
2356 };
2357 const res = await trackedFetch(full, init, {
2358 source: "desktop-mode/files-user-search",
2359 silent: true
2360 });
2361 if (!res.ok) {
2362 throw new Error(`HTTP ${res.status}`);
2363 }
2364 const json = await res.json();
2365 this._results = json && Array.isArray(json.users) ? json.users : [];
2366 this._phase = "ready";
2367 this._error = "";
2368 this._open = true;
2369 } catch (e) {
2370 if (e.name === "AbortError") {
2371 return;
2372 }
2373 this._results = [];
2374 this._phase = "error";
2375 this._error = e.message || "Search failed.";
2376 this._open = true;
2377 }
2378 this._positionDropdown();
2379 this.requestUpdate();
2380 }
2381 _positionDropdown() {
2382 const input = this.shadowRoot?.querySelector(".input");
2383 if (!input) {
2384 return;
2385 }
2386 const rect = input.getBoundingClientRect();
2387 const top = rect.bottom + 4;
2388 const left = rect.left;
2389 const width = rect.width;
2390 const viewportH = window.innerHeight;
2391 const spaceBelow = viewportH - rect.bottom;
2392 const spaceAbove = rect.top;
2393 const maxHeight = Math.max(120, Math.min(280, Math.max(spaceBelow, spaceAbove) - 16));
2394 if (spaceBelow < 200 && spaceAbove > spaceBelow) {
2395 this._dropdownStyle = [
2396 "position:fixed",
2397 `left:${left}px`,
2398 `top:${rect.top - 4 - maxHeight}px`,
2399 `width:${width}px`,
2400 `max-height:${maxHeight}px`
2401 ].join(";");
2402 } else {
2403 this._dropdownStyle = [
2404 "position:fixed",
2405 `left:${left}px`,
2406 `top:${top}px`,
2407 `width:${width}px`,
2408 `max-height:${maxHeight}px`
2409 ].join(";");
2410 }
2411 }
2412 _dropdownContent() {
2413 if (this._phase === "loading") {
2414 return html`<div class="empty">Searching…</div>`;
2415 }
2416 if (this._phase === "error") {
2417 return html`<div class="empty error">${this._error}</div>`;
2418 }
2419 if (this._results.length === 0) {
2420 const message = this._query ? "No matches." : "No users available.";
2421 return html`<div class="empty">${message}</div>`;
2422 }
2423 return this._results.map(
2424 (u) => html`
2425 <button
2426 type="button"
2427 class="item"
2428 role="option"
2429 @mousedown=${(e) => e.preventDefault()}
2430 @click=${() => this._pick(u)}
2431 >
2432 <img class="avatar" src=${u.avatarUrl} alt="" />
2433 <div>
2434 <div class="name">${u.name}</div>
2435 <div class="slug">${u.slug}</div>
2436 </div>
2437 </button>
2438 `
2439 );
2440 }
2441 render() {
2442 const placeholder = this.getAttribute("placeholder") || "Search users…";
2443 return html`
2444 <input
2445 class="input"
2446 type="search"
2447 placeholder=${placeholder}
2448 autocomplete="off"
2449 @input=${this._onInput}
2450 @focus=${this._onFocus}
2451 @blur=${this._onBlur}
2452 .value=${this._query}
2453 />
2454 ${this._open ? html`
2455 <div class="dropdown" role="listbox" style=${this._dropdownStyle}>
2456 ${this._dropdownContent()}
2457 </div>
2458 ` : html``}
2459 `;
2460 }
2461 };
2462 _WpdUserSearch.props = ["placeholder", "exclude", "endpoint"];
2463 _WpdUserSearch.styles = [userSearchStyles];
2464 _WpdUserSearch.help = {
2465 title: "User autocomplete",
2466 summary: "Debounced autocomplete over /desktop-mode/v1/files/users/search. Emits wpd-user-pick { user } when a row is chosen. Dropdown anchors as position: fixed so it escapes overflow:auto ancestors.",
2467 status: "experimental",
2468 since: "0.8.5",
2469 props: [
2470 { name: "placeholder", type: "string", description: "Input placeholder text." },
2471 {
2472 name: "exclude",
2473 type: "csv user ids",
2474 description: "Already-picked user ids to suppress in results."
2475 },
2476 {
2477 name: "endpoint",
2478 type: "URL",
2479 description: "Override the search URL (defaults to desktopModeConfig.filesUsersSearchUrl)."
2480 }
2481 ],
2482 events: [
2483 { name: "wpd-user-pick", description: "Emitted on pick. Detail: `{ user: SearchUser }`." }
2484 ]
2485 };
2486 let WpdUserSearch = _WpdUserSearch;
2487 defineComponent("wpd-user-search", WpdUserSearch);
2488 function usersSearchUrl() {
2489 const globals = window;
2490 const localized = globals.desktopModeGamesConfig?.usersSearchUrl;
2491 if (localized) {
2492 return localized;
2493 }
2494 const wpGlobal = window.wp;
2495 const restUrl = wpGlobal?.desktop?.config?.restUrl || "/wp-json/";
2496 return joinRestUrl(restUrl, "desktop-mode/v1/games/users/search");
2497 }
2498 function openChallengeDialog(args) {
2499 return new Promise((resolve) => {
2500 const modal = document.createElement("wpd-modal");
2501 modal.setAttribute("open", "");
2502 modal.setAttribute("title", __("Send a challenge"));
2503 modal.setAttribute("size", "sm");
2504 const body = document.createElement("div");
2505 body.className = "desktop-mode-games__challenge-dialog";
2506 const summary = document.createElement("p");
2507 summary.className = "desktop-mode-games__challenge-summary";
2508 summary.textContent = sprintf(
2509 /* translators: 1: game title, 2: score. */
2510 __("Challenge someone to beat your %1$s score of %2$s."),
2511 args.gameTitle,
2512 String(args.score)
2513 );
2514 body.appendChild(summary);
2515 const search = document.createElement("wpd-user-search");
2516 search.setAttribute("placeholder", __("Find a player…"));
2517 search.setAttribute("endpoint", usersSearchUrl());
2518 body.appendChild(search);
2519 const picked = document.createElement("div");
2520 picked.className = "desktop-mode-games__challenge-picked";
2521 picked.hidden = true;
2522 body.appendChild(picked);
2523 modal.appendChild(body);
2524 const footer = document.createElement("div");
2525 footer.setAttribute("slot", "footer");
2526 footer.className = "desktop-mode-games__challenge-footer";
2527 const cancel = document.createElement("wpd-button");
2528 cancel.setAttribute("variant", "ghost");
2529 cancel.textContent = __("Cancel");
2530 const send = document.createElement("wpd-button");
2531 send.setAttribute("variant", "primary");
2532 send.setAttribute("disabled", "");
2533 send.textContent = __("Send challenge");
2534 footer.append(cancel, send);
2535 modal.appendChild(footer);
2536 let opponent = null;
2537 let sending = false;
2538 const close = () => {
2539 modal.remove();
2540 resolve();
2541 };
2542 const paintPicked = () => {
2543 picked.innerHTML = "";
2544 if (!opponent) {
2545 picked.hidden = true;
2546 send.setAttribute("disabled", "");
2547 return;
2548 }
2549 picked.hidden = false;
2550 const avatar = document.createElement("wpd-avatar");
2551 avatar.setAttribute("src", opponent.avatarUrl);
2552 avatar.setAttribute("name", opponent.name);
2553 avatar.setAttribute("size", "sm");
2554 avatar.setAttribute("user-id", String(opponent.id));
2555 picked.appendChild(avatar);
2556 const name = document.createElement("span");
2557 name.textContent = opponent.name;
2558 picked.appendChild(name);
2559 send.removeAttribute("disabled");
2560 };
2561 search.addEventListener("wpd-user-pick", (e) => {
2562 const user = e.detail?.user;
2563 if (user) {
2564 opponent = user;
2565 paintPicked();
2566 }
2567 });
2568 cancel.addEventListener("click", close);
2569 modal.addEventListener("wpd-modal-cancel", close);
2570 send.addEventListener("click", () => {
2571 if (!opponent || sending) {
2572 return;
2573 }
2574 sending = true;
2575 send.setAttribute("disabled", "");
2576 void createChallenge({
2577 game: args.game,
2578 recipientId: opponent.id,
2579 score: args.score,
2580 meta: args.meta
2581 }).then(() => {
2582 showToast({
2583 message: sprintf(
2584 /* translators: %s: opponent display name. */
2585 __("Challenge sent to %s."),
2586 opponent.name
2587 )
2588 });
2589 close();
2590 }).catch((err) => {
2591 sending = false;
2592 send.removeAttribute("disabled");
2593 showToast({
2594 message: err instanceof Error ? err.message : __("Could not send the challenge.")
2595 });
2596 });
2597 });
2598 document.body.appendChild(modal);
2599 });
2600 }
2601 const styles$1 = css`:host{display:inline;color:inherit;font:inherit}`;
2602 const _instances = /* @__PURE__ */ new Set();
2603 let _ticker = null;
2604 const TICK_INTERVAL_MS = 3e4;
2605 function startTicker() {
2606 if (_ticker !== null) {
2607 return;
2608 }
2609 _ticker = window.setInterval(() => {
2610 for (const i of _instances) {
2611 i.tick();
2612 }
2613 }, TICK_INTERVAL_MS);
2614 }
2615 function stopTickerIfIdle() {
2616 if (_ticker !== null && _instances.size === 0) {
2617 window.clearInterval(_ticker);
2618 _ticker = null;
2619 }
2620 }
2621 function parseDatetime(raw) {
2622 if (!raw) {
2623 return null;
2624 }
2625 const tryDate = (v) => {
2626 const d = new Date(v);
2627 return Number.isNaN(d.getTime()) ? null : d;
2628 };
2629 if (raw.includes("T") || raw.endsWith("Z")) {
2630 return tryDate(raw);
2631 }
2632 return tryDate(raw.replace(" ", "T") + "Z");
2633 }
2634 let _rtfCache = null;
2635 function getRtf() {
2636 if (!_rtfCache) {
2637 const lang = typeof navigator !== "undefined" && navigator.language || "en";
2638 _rtfCache = new Intl.RelativeTimeFormat(lang, { numeric: "auto" });
2639 }
2640 return _rtfCache;
2641 }
2642 function relativeText(date, now) {
2643 const rtf = getRtf();
2644 const diffMs = date.getTime() - now;
2645 const diffSec = Math.round(diffMs / 1e3);
2646 const abs = Math.abs;
2647 if (abs(diffSec) < 45) {
2648 return rtf.format(0, "second");
2649 }
2650 const diffMin = Math.round(diffSec / 60);
2651 if (abs(diffMin) < 45) {
2652 return rtf.format(diffMin, "minute");
2653 }
2654 const diffHour = Math.round(diffMin / 60);
2655 if (abs(diffHour) < 22) {
2656 return rtf.format(diffHour, "hour");
2657 }
2658 const diffDay = Math.round(diffHour / 24);
2659 if (abs(diffDay) < 26) {
2660 return rtf.format(diffDay, "day");
2661 }
2662 const diffMonth = Math.round(diffDay / 30);
2663 if (abs(diffMonth) < 11) {
2664 return rtf.format(diffMonth, "month");
2665 }
2666 const diffYear = Math.round(diffDay / 365);
2667 return rtf.format(diffYear, "year");
2668 }
2669 const _WpdRelativeTime = class _WpdRelativeTime extends Component {
2670 connectedCallback() {
2671 super.connectedCallback();
2672 _instances.add(this);
2673 startTicker();
2674 }
2675 disconnectedCallback() {
2676 _instances.delete(this);
2677 stopTickerIfIdle();
2678 }
2679 /** Public — the shared ticker calls this on every interval. */
2680 tick() {
2681 this.requestUpdate();
2682 }
2683 render() {
2684 const raw = this.datetime;
2685 const date = parseDatetime(raw);
2686 if (!date) {
2687 return html`<span>${raw ?? ""}</span>`;
2688 }
2689 const text = relativeText(date, Date.now());
2690 const absolute = date.toLocaleString();
2691 return html`<time datetime=${date.toISOString()} title=${absolute}
2692 >${text}</time
2693 >`;
2694 }
2695 };
2696 _WpdRelativeTime.props = ["datetime"];
2697 _WpdRelativeTime.styles = [styles$1];
2698 _WpdRelativeTime.help = {
2699 title: "Relative time",
2700 summary: 'Auto-ticking relative timestamp. Renders "5 minutes ago" / "yesterday" / "in 3 hours" via Intl.RelativeTimeFormat and updates itself every 30s while connected. Useful for any list cell that should age live (recycle bin, notifications, activity log) without forcing the surrounding view to repaint.',
2701 status: "experimental",
2702 since: "0.6.0",
2703 props: [
2704 {
2705 name: "datetime",
2706 type: 'ISO 8601 string OR MySQL-style "Y-m-d H:i:s" (treated as UTC)',
2707 description: "The moment the relative copy is anchored to. Accepts the format WordPress hands back from `*_gmt` columns directly."
2708 }
2709 ],
2710 slots: [],
2711 cssProps: [],
2712 example: html`<wpd-relative-time
2713 datetime="${new Date(Date.now() - 1e3 * 60 * 5).toISOString()}"
2714 ></wpd-relative-time>`
2715 };
2716 let WpdRelativeTime = _WpdRelativeTime;
2717 defineComponent("wpd-relative-time", WpdRelativeTime);
2718 const styles = css`:host{display:block;--wpd-table-bg:var( --wpd-surface,#fff );--wpd-table-border:var( --wpd-border,rgba( 0,0,0,0.08 ) );--wpd-table-column-border:var( --wpd-border-strong,rgba( 0,0,0,0.14 ) );--wpd-table-header-bg:var( --wpd-surface-elevated,#f6f7f7 );--wpd-table-row-hover:rgba( 0,0,0,0.04 );--wpd-table-stripe:rgba( 0,0,0,0.03 );--wpd-table-cell-padding:8px 12px;--wpd-table-font-size:13px;--wpd-table-max-height:none;font-size:var( --wpd-table-font-size );color:inherit}:host( [ hidden ] ){display:none}.scroll{position:relative;overflow:auto;max-height:var( --wpd-table-max-height );border:1px solid var( --wpd-table-border );border-radius:4px;background:var( --wpd-table-bg )}table{width:100%;border-collapse:separate;border-spacing:0;background:var( --wpd-table-bg )}thead th{text-align:start;font-weight:600;background-color:var( --wpd-table-header-bg );padding:var( --wpd-table-cell-padding );border-bottom:1px solid var( --wpd-table-border );white-space:nowrap}tbody td{padding:var( --wpd-table-cell-padding );border-bottom:1px solid var( --wpd-table-border );background-color:var( --wpd-table-bg );vertical-align:middle}tbody tr:last-child td{border-bottom:0}:host( [ striped ] ) tbody tr:nth-child( odd ) td{background-image:linear-gradient( var( --wpd-table-stripe ),var( --wpd-table-stripe ) )}:host( [ hover ] ) tbody tr:hover td{background-image:linear-gradient( var( --wpd-table-row-hover ),var( --wpd-table-row-hover ) )}:host( [ hover ] [ striped ] ) tbody tr:nth-child( odd ):hover td{background-image:linear-gradient( var( --wpd-table-row-hover ),var( --wpd-table-row-hover ) ),linear-gradient( var( --wpd-table-stripe ),var( --wpd-table-stripe ) )}:host( [ compact ] ){--wpd-table-cell-padding:4px 8px;--wpd-table-font-size:12px}:host( [ bordered ] ) thead th,:host( [ bordered ] ) tbody td{border-inline-end:1px solid var( --wpd-table-column-border )}:host( [ bordered ] ) thead th:last-child,:host( [ bordered ] ) tbody td:last-child{border-inline-end:0}th.is-sticky,td.is-sticky{position:sticky;z-index:10}tbody td.is-sticky{background-color:var( --wpd-table-bg )}thead th.is-sticky{background-color:var( --wpd-table-header-bg );z-index:30}:host( [ sticky-header ] ) thead th{position:sticky;top:0;z-index:20}:host( [ sticky-header ] ) thead tr.filter-row th{top:var( --wpd-table-header-height,33px );z-index:20}:host( [ sticky-header ] ) thead th.is-sticky{z-index:40}:host( [ sticky-header ] ) thead tr.filter-row th.is-sticky{z-index:40}th.is-sticky-edge,td.is-sticky-edge{border-inline-end:var( --wpd-table-sticky-edge,2px solid var( --wpd-table-border ) )}.align-center{text-align:center}.align-end{text-align:end}.filter-row th{padding:4px 8px;background-color:var( --wpd-table-header-bg );border-bottom:1px solid var( --wpd-table-border );font-weight:400}.filter-input,.filter-select{width:100%;min-width:60px;box-sizing:border-box;padding:4px 6px;font:inherit;color:inherit;background-color:var( --wpd-table-bg );border:1px solid var( --wpd-table-border );border-radius:3px}.filter-input:focus,.filter-select:focus{outline:2px solid var( --wp-admin-theme-color,#2271b1 );outline-offset:-1px}.expander{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;background:transparent;color:inherit;cursor:pointer;border-radius:3px;font-size:11px;line-height:1}.expander:hover{background:rgba( 0,0,0,0.06 )}td.col-expander,th.col-expander{width:36px;min-width:36px;padding-left:0;padding-right:0;text-align:center}tr.subtable td{padding:0;background-color:var( --wpd-table-bg );background-image:linear-gradient( var( --wpd-table-stripe ),var( --wpd-table-stripe ) );border-bottom:1px solid var( --wpd-table-border )}tr.subtable .subtable-inner{padding:8px 12px 8px 32px}tr.empty td{padding:24px;text-align:center;color:var( --wpd-text-muted,rgba( 0,0,0,0.55 ) );font-style:italic}thead th.is-sortable{cursor:pointer;user-select:none}thead th.is-sortable:hover{background-image:linear-gradient( var( --wpd-table-row-hover ),var( --wpd-table-row-hover ) )}thead th.is-sortable:focus-visible{outline:2px solid var( --wp-admin-theme-color,#2271b1 );outline-offset:-2px}.sort-indicator{font-size:10px;color:var( --wpd-text-muted,rgba( 0,0,0,0.55 ) );margin-inline-start:2px}thead th.sort-asc .sort-indicator,thead th.sort-desc .sort-indicator{color:var( --wp-admin-theme-color,#2271b1 )}td.col-select,th.col-select{width:40px;min-width:40px;padding-left:0;padding-right:0;text-align:center}.select-all-checkbox,.select-row-checkbox{cursor:pointer;margin:0}tbody tr.is-selected td{background-color:color-mix( in srgb,var( --wp-admin-theme-color,#2271b1 ) 10%,var( --wpd-table-bg ) );background-image:none}tbody tr.is-selected:hover td{background-color:color-mix( in srgb,var( --wp-admin-theme-color,#2271b1 ) 16%,var( --wpd-table-bg ) )}tbody tr.skeleton td{padding:var( --wpd-table-cell-padding )}.skeleton-bar{display:block;height:12px;border-radius:3px;background:linear-gradient( 90deg,var( --wpd-table-skeleton-color,rgba( 0,0,0,0.06 ) ) 0%,var( --wpd-table-skeleton-highlight,rgba( 0,0,0,0.14 ) ) 50%,var( --wpd-table-skeleton-color,rgba( 0,0,0,0.06 ) ) 100% );background-size:200% 100%;animation:wpd-table-skeleton-pulse 1.4s ease-in-out infinite}@keyframes wpd-table-skeleton-pulse{0%{background-position:200% 50%}100%{background-position:-200% 50%}}@media ( prefers-reduced-motion:reduce ){.skeleton-bar{animation:none}}`;
2719 const EXPANDER_KEY = "__wpd_expander__";
2720 const SELECT_KEY = "__wpd_select__";
2721 const _WpdTable = class _WpdTable extends Component {
2722 constructor() {
2723 super(...arguments);
2724 this._data = [];
2725 this._columns = [];
2726 this._filters = {};
2727 this._expanded = /* @__PURE__ */ new Set();
2728 this._subTable = null;
2729 this._sort = null;
2730 this._selection = /* @__PURE__ */ new Set();
2731 this._getRowId = (_row, index) => index;
2732 this._filterCache = /* @__PURE__ */ new Map();
2733 this._paintScheduled = false;
2734 this._stickyHeaderWarned = false;
2735 this._stickyRaceWarned = false;
2736 this._resizeObserver = null;
2737 this._stickyMicroScheduled = false;
2738 this._stickyRafHandle = null;
2739 this._loadingDesyncWarned = false;
2740 this._lastStickyIndex = -1;
2741 }
2742 // ------------------------------------------------------------------
2743 // Public properties — set from JS (use `.data=${...}` in templates).
2744 // ------------------------------------------------------------------
2745 /** The row buffer. Reassigning replaces (and clears expansion state). */
2746 get data() {
2747 return this._data;
2748 }
2749 set data(next) {
2750 this._data = Array.isArray(next) ? next.slice() : [];
2751 this._expanded.clear();
2752 this._schedulePaint();
2753 }
2754 /** Column descriptors. See {@link WpdTableColumn}. */
2755 get columns() {
2756 return this._columns;
2757 }
2758 set columns(next) {
2759 this._columns = Array.isArray(next) ? next.slice() : [];
2760 const keys = new Set(this._columns.map((c) => c.key));
2761 for (const k of Object.keys(this._filters)) {
2762 if (!keys.has(k)) {
2763 delete this._filters[k];
2764 }
2765 }
2766 for (const k of Array.from(this._filterCache.keys())) {
2767 if (!keys.has(k)) {
2768 this._filterCache.delete(k);
2769 }
2770 }
2771 if (this._sort && !keys.has(this._sort.key)) {
2772 this._sort = null;
2773 }
2774 this._schedulePaint();
2775 }
2776 /** Read or replace the current filter map. */
2777 get filters() {
2778 return { ...this._filters };
2779 }
2780 set filters(next) {
2781 this._filters = next ? { ...next } : {};
2782 this._schedulePaint();
2783 }
2784 /** Read or set the active sort. `null` clears it. */
2785 get sort() {
2786 return this._sort ? { ...this._sort } : null;
2787 }
2788 set sort(next) {
2789 this._sort = next ? { ...next } : null;
2790 this._schedulePaint();
2791 }
2792 /** Read or replace the selection (set of row ids). */
2793 get selection() {
2794 return new Set(this._selection);
2795 }
2796 set selection(next) {
2797 this._selection = new Set(next ?? []);
2798 this._schedulePaint();
2799 }
2800 /** The currently-selected rows (resolved from `selection` + `data`). */
2801 get selectedRows() {
2802 const out = [];
2803 this._data.forEach((row, i) => {
2804 if (this._selection.has(this._getRowId(row, i))) {
2805 out.push(row);
2806 }
2807 });
2808 return out;
2809 }
2810 /**
2811 * The rows currently visible — i.e. passing the active client-side
2812 * filters, in data order. This is the row set `selectAll()` and
2813 * the header select-all tri-state operate on.
2814 *
2815 * Destructive bulk consumers should resolve `selection` against
2816 * THIS list rather than `data`: selection deliberately survives
2817 * `data` reassignment, and a data-driven change (a realtime
2818 * refresh editing a row so it no longer matches an active filter)
2819 * can hide a selected row without any filter event firing. Rows
2820 * the user cannot see must never be swept into a destructive
2821 * action. See `collectSelectedItems()` in src/recycle-bin/index.ts
2822 * for the canonical consumer.
2823 *
2824 * @since 0.9.4
2825 */
2826 get visibleRows() {
2827 return this._filteredRows().map((entry) => entry.row);
2828 }
2829 /** Stable row-id extractor. Default is row index. */
2830 get getRowId() {
2831 return this._getRowId;
2832 }
2833 set getRowId(fn) {
2834 this._getRowId = typeof fn === "function" ? fn : (_r, i) => i;
2835 this._schedulePaint();
2836 }
2837 /**
2838 * Sub-table accessor. Return `null` (or omit) for rows with no
2839 * children. Return `{ columns, data }` to render a nested
2840 * `<wpd-table>` inline; or return any `Node` / `html\`\`` template
2841 * for fully custom expanded content.
2842 */
2843 get subTable() {
2844 return this._subTable;
2845 }
2846 set subTable(fn) {
2847 this._subTable = typeof fn === "function" ? fn : null;
2848 this._expanded.clear();
2849 this._schedulePaint();
2850 }
2851 /** Read or replace the expansion set (row indices that are open). */
2852 get expanded() {
2853 return new Set(this._expanded);
2854 }
2855 set expanded(next) {
2856 this._expanded = new Set(next ?? []);
2857 this._schedulePaint();
2858 }
2859 // ------------------------------------------------------------------
2860 // Programmatic methods
2861 // ------------------------------------------------------------------
2862 /** Open a row's sub-table by index. No-op if the index is out of range. */
2863 expand(index) {
2864 if (index < 0 || index >= this._data.length) {
2865 return;
2866 }
2867 if (this._expanded.has(index)) {
2868 return;
2869 }
2870 this._expanded.add(index);
2871 this.emit("wpd-table-expand-change", {
2872 row: this._data[index],
2873 index,
2874 expanded: true
2875 });
2876 this._schedulePaint();
2877 }
2878 /** Close a row's sub-table by index. No-op if it wasn't open. */
2879 collapse(index) {
2880 if (!this._expanded.has(index)) {
2881 return;
2882 }
2883 this._expanded.delete(index);
2884 this.emit("wpd-table-expand-change", {
2885 row: this._data[index],
2886 index,
2887 expanded: false
2888 });
2889 this._schedulePaint();
2890 }
2891 /** Open every row that has children. */
2892 expandAll() {
2893 if (!this._subTable) {
2894 return;
2895 }
2896 let changed = false;
2897 for (let i = 0; i < this._data.length; i++) {
2898 if (!this._subTable(this._data[i], i)) {
2899 continue;
2900 }
2901 if (!this._expanded.has(i)) {
2902 this._expanded.add(i);
2903 changed = true;
2904 }
2905 }
2906 if (changed) {
2907 this._schedulePaint();
2908 }
2909 }
2910 /** Close every open row. */
2911 collapseAll() {
2912 if (this._expanded.size === 0) {
2913 return;
2914 }
2915 this._expanded.clear();
2916 this._schedulePaint();
2917 }
2918 isExpanded(index) {
2919 return this._expanded.has(index);
2920 }
2921 /** Drop every active filter and emit `wpd-table-filter-change`. */
2922 clearFilters() {
2923 if (Object.keys(this._filters).length === 0) {
2924 return;
2925 }
2926 this._filters = {};
2927 this.emit("wpd-table-filter-change", { filters: {} });
2928 this._schedulePaint();
2929 }
2930 /** Drop the active sort and emit `wpd-table-sort-change`. */
2931 clearSort() {
2932 if (this._sort === null) {
2933 return;
2934 }
2935 this._sort = null;
2936 this.emit("wpd-table-sort-change", { sort: null });
2937 this._schedulePaint();
2938 }
2939 /**
2940 * Add a row id to the selection. Emits `wpd-table-selection-change`.
2941 *
2942 * Selection mutators (`select` / `deselect` / `selectAll` /
2943 * `clearSelection`) update the affected row in place via
2944 * {@link _syncSelectionDom} rather than re-rendering the whole
2945 * tbody — a rebuild would tear down the focused checkbox and
2946 * (because scroll-anchoring abandons a momentarily empty container)
2947 * could snap scroll back to the top.
2948 */
2949 select(id) {
2950 if (this._selection.has(id)) {
2951 return;
2952 }
2953 const mode = this._readSelectable();
2954 const previouslySelected = mode === "single" ? Array.from(this._selection) : [];
2955 if (mode === "single") {
2956 this._selection.clear();
2957 }
2958 this._selection.add(id);
2959 this._emitSelectionChange();
2960 this._syncSelectionDom([id, ...previouslySelected]);
2961 }
2962 /** Remove a row id from the selection. */
2963 deselect(id) {
2964 if (!this._selection.delete(id)) {
2965 return;
2966 }
2967 this._emitSelectionChange();
2968 this._syncSelectionDom([id]);
2969 }
2970 /** Select every visible row — the rows passing the active client-side filters (multi-mode only). */
2971 selectAll() {
2972 if (this._readSelectable() !== "multi") {
2973 return;
2974 }
2975 for (const { row, index } of this._filteredRows()) {
2976 this._selection.add(this._getRowId(row, index));
2977 }
2978 this._emitSelectionChange();
2979 this._syncSelectionDom("all");
2980 }
2981 /** Empty the selection. */
2982 clearSelection() {
2983 if (this._selection.size === 0) {
2984 return;
2985 }
2986 this._selection.clear();
2987 this._emitSelectionChange();
2988 this._syncSelectionDom("all");
2989 }
2990 /**
2991 * Apply a selection change to the existing tbody DOM without
2992 * rebuilding it. Updates each affected row's `is-selected` class
2993 * and `select-row-checkbox` `checked` state, then re-syncs the
2994 * header select-all checkbox (checked / indeterminate / empty).
2995 *
2996 * @param ids `'all'` to walk every row, or an iterable of row ids
2997 * whose rows need updating. Unknown ids are silently
2998 * skipped (row may not be in the current filter/page).
2999 */
3000 _syncSelectionDom(ids) {
3001 const root = this.shadowRoot;
3002 if (!root) {
3003 return;
3004 }
3005 const tbody = root.querySelector("tbody");
3006 if (!tbody) {
3007 return;
3008 }
3009 let needle = null;
3010 if (ids !== "all") {
3011 needle = /* @__PURE__ */ new Set();
3012 for (const id of ids) {
3013 needle.add(String(id));
3014 }
3015 }
3016 const rows = tbody.querySelectorAll(
3017 "tr[data-row-id]"
3018 );
3019 for (const tr of rows) {
3020 const rowIdStr = tr.dataset.rowId;
3021 if (rowIdStr === void 0) {
3022 continue;
3023 }
3024 if (needle && !needle.has(rowIdStr)) {
3025 continue;
3026 }
3027 const idx = Number(tr.dataset.rowIndex);
3028 if (!Number.isFinite(idx)) {
3029 continue;
3030 }
3031 const row = this._data[idx];
3032 if (row === void 0) {
3033 continue;
3034 }
3035 const id = this._getRowId(row, idx);
3036 const isSelected = this._selection.has(id);
3037 tr.classList.toggle("is-selected", isSelected);
3038 const cb = tr.querySelector(
3039 "input.select-row-checkbox"
3040 );
3041 if (cb && cb.checked !== isSelected) {
3042 cb.checked = isSelected;
3043 }
3044 }
3045 const headerCb = root.querySelector(
3046 "thead .select-all-checkbox"
3047 );
3048 if (headerCb) {
3049 const { total, selected } = this._visibleSelectionStats();
3050 headerCb.checked = total > 0 && selected === total;
3051 headerCb.indeterminate = selected > 0 && selected < total;
3052 }
3053 }
3054 /** Scroll the (filtered) row at `index` into view inside the table's scroll container. */
3055 scrollToRow(index) {
3056 const root = this.shadowRoot;
3057 if (!root) {
3058 return;
3059 }
3060 const rows = root.querySelectorAll(
3061 "tbody tr:not(.subtable):not(.empty):not(.skeleton)"
3062 );
3063 const row = rows[index];
3064 if (row) {
3065 row.scrollIntoView({ block: "nearest", inline: "nearest" });
3066 }
3067 }
3068 connectedCallback() {
3069 super.connectedCallback();
3070 this._schedulePaint();
3071 }
3072 disconnectedCallback() {
3073 this._resizeObserver?.disconnect();
3074 this._resizeObserver = null;
3075 if (this._stickyRafHandle !== null && typeof cancelAnimationFrame !== "undefined") {
3076 cancelAnimationFrame(this._stickyRafHandle);
3077 this._stickyRafHandle = null;
3078 }
3079 }
3080 /**
3081 * Force a sticky-offsets recompute. Public escape hatch for the
3082 * rare case where layout settles after every internal hook has
3083 * fired — e.g. an out-of-band font swap or a JS-driven width
3084 * change on an ancestor that doesn't bubble through ResizeObserver.
3085 *
3086 * Usually you don't need this: the component schedules recomputes
3087 * on a microtask + animation frame after every paint, and a
3088 * ResizeObserver on the inner scroll element catches geometry
3089 * changes thereafter. Reach for `recomputeLayout()` only if you've
3090 * confirmed that all of those pathways missed your case.
3091 */
3092 recomputeLayout() {
3093 this._applyStickyOffsets();
3094 this._measureHeaderHeight();
3095 }
3096 // ------------------------------------------------------------------
3097 // Skeleton + paint pipeline
3098 // ------------------------------------------------------------------
3099 render() {
3100 return html`
3101 <div class="scroll" part="scroll">
3102 <table part="table">
3103 <colgroup></colgroup>
3104 <thead></thead>
3105 <tbody></tbody>
3106 </table>
3107 </div>
3108 `;
3109 }
3110 requestUpdate() {
3111 super.requestUpdate();
3112 this._schedulePaint();
3113 }
3114 _schedulePaint() {
3115 if (this._paintScheduled || !this.isConnected) {
3116 return;
3117 }
3118 this._paintScheduled = true;
3119 queueMicrotask(() => {
3120 this._paintScheduled = false;
3121 if (!this.isConnected) {
3122 return;
3123 }
3124 this._paint();
3125 });
3126 }
3127 _paint() {
3128 const root = this.shadowRoot;
3129 if (!root) {
3130 return;
3131 }
3132 if (!root.querySelector("tbody")) {
3133 render(this.render(), root);
3134 }
3135 const colgroup = root.querySelector("colgroup");
3136 const thead = root.querySelector("thead");
3137 const tbody = root.querySelector("tbody");
3138 if (!colgroup || !thead || !tbody) {
3139 return;
3140 }
3141 const cols = this._effectiveColumns();
3142 const stickyN = this._readStickyColumns();
3143 this._lastStickyIndex = this._computeLastStickyIndex(cols, stickyN);
3144 this._paintColgroup(colgroup, cols);
3145 this._paintHead(thead, cols, stickyN);
3146 this._paintBody(tbody, cols, stickyN);
3147 this._applyStickyOffsets();
3148 this._measureHeaderHeight();
3149 this._scheduleStickyOffsets();
3150 this._maybeWarnStickyHeader();
3151 this._maybeWarnLoadingDesync(tbody);
3152 this._ensureResizeObserver();
3153 }
3154 /**
3155 * Diagnostic for the "I set `loading` but the skeleton never
3156 * appeared" footgun. If we get here with the attribute on but no
3157 * `.skeleton` rows in `tbody`, something between attribute set and
3158 * paint went off the rails — historically this happened when the
3159 * base `Component.attributeChangedCallback` called `_scheduleRender`
3160 * directly, bypassing our `requestUpdate` override. Same pattern as
3161 * the sticky-columns 0px tripwire: should never fire, but if it
3162 * does, names the bug instead of leaving the dev guessing.
3163 */
3164 _maybeWarnLoadingDesync(tbody) {
3165 if (this._loadingDesyncWarned) {
3166 return;
3167 }
3168 if (!this.hasAttribute("loading")) {
3169 return;
3170 }
3171 if (tbody.querySelector("tr.skeleton")) {
3172 return;
3173 }
3174 this._loadingDesyncWarned = true;
3175 console.warn(
3176 "[wpd-table] `loading` attribute is set but no skeleton rows rendered. Either attributeChangedCallback didn't route through requestUpdate (framework regression), or `loading` was set after the most recent paint and no follow-up trigger ran. Toggling `data` will force a paint as a workaround."
3177 );
3178 }
3179 /**
3180 * Belt-and-braces sticky-offset scheduling.
3181 *
3182 * - Microtask: cheap, fires after the current task drains. Fixes
3183 * mounts where the synchronous read in `_paint` happened before
3184 * a sibling style applied.
3185 * - rAF: fires before the next paint. Catches "layout settles
3186 * after a queued style mutation" races — the most common cause
3187 * of "col 1 ended up at inset-inline-start: 0px".
3188 *
3189 * Both reduce to a no-op when nothing changed. The cost is two
3190 * extra DOM reads per paint; the win is the bug class disappears.
3191 */
3192 _scheduleStickyOffsets() {
3193 if (!this._stickyMicroScheduled) {
3194 this._stickyMicroScheduled = true;
3195 queueMicrotask(() => {
3196 this._stickyMicroScheduled = false;
3197 if (this.isConnected) {
3198 this._applyStickyOffsets();
3199 }
3200 });
3201 }
3202 if (this._stickyRafHandle === null && typeof requestAnimationFrame !== "undefined") {
3203 this._stickyRafHandle = requestAnimationFrame(() => {
3204 this._stickyRafHandle = null;
3205 if (this.isConnected) {
3206 this._applyStickyOffsets();
3207 this._measureHeaderHeight();
3208 }
3209 });
3210 }
3211 }
3212 /**
3213 * Wire a `ResizeObserver` on the inner `.scroll` element (NOT the
3214 * host). Why: the host's outer width is often pinned by its parent
3215 * panel — a vertical scrollbar appearing inside the table changes
3216 * the inner scroll-area width by ~15px without changing the host
3217 * size. Observing the host would miss that reflow and leave sticky
3218 * offsets stale.
3219 *
3220 * Idempotent — runs once after the first paint produces a real
3221 * `.scroll` element. Disconnect happens in `disconnectedCallback`.
3222 */
3223 _ensureResizeObserver() {
3224 if (this._resizeObserver) {
3225 return;
3226 }
3227 if (typeof ResizeObserver === "undefined") {
3228 return;
3229 }
3230 const scroll = this.shadowRoot?.querySelector(
3231 ".scroll"
3232 );
3233 if (!scroll) {
3234 return;
3235 }
3236 this._resizeObserver = new ResizeObserver(() => {
3237 if (!this.isConnected) {
3238 return;
3239 }
3240 this._applyStickyOffsets();
3241 this._measureHeaderHeight();
3242 this._stickyHeaderWarned = false;
3243 this._maybeWarnStickyHeader();
3244 });
3245 this._resizeObserver.observe(scroll);
3246 this._resizeObserver.observe(this);
3247 }
3248 _paintColgroup(colgroup, cols) {
3249 const out = [];
3250 for (const c of cols) {
3251 const col = document.createElement("col");
3252 if (c.width) {
3253 col.style.width = c.width;
3254 }
3255 out.push(col);
3256 }
3257 colgroup.replaceChildren(...out);
3258 }
3259 _paintHead(thead, cols, stickyN) {
3260 const newHeaderRow = document.createElement("tr");
3261 newHeaderRow.setAttribute("part", "header-row");
3262 for (let i = 0; i < cols.length; i++) {
3263 newHeaderRow.appendChild(this._buildHeaderCell(cols[i], i, stickyN));
3264 }
3265 const existingHeader = thead.querySelector(
3266 ':scope > tr[part="header-row"]'
3267 );
3268 if (existingHeader) {
3269 thead.replaceChild(newHeaderRow, existingHeader);
3270 } else {
3271 thead.insertBefore(newHeaderRow, thead.firstChild);
3272 }
3273 const hasFilter = cols.some(
3274 (c) => c.filter || Array.isArray(c.filterOptions) || typeof c.filterRender === "function"
3275 );
3276 let existingFilter = thead.querySelector(
3277 ":scope > tr.filter-row"
3278 );
3279 if (hasFilter) {
3280 const cells = [];
3281 for (let i = 0; i < cols.length; i++) {
3282 cells.push(this._buildFilterCell(cols[i], i, stickyN));
3283 }
3284 if (!existingFilter) {
3285 existingFilter = document.createElement("tr");
3286 existingFilter.classList.add("filter-row");
3287 existingFilter.setAttribute("part", "filter-row");
3288 thead.appendChild(existingFilter);
3289 }
3290 const current = Array.from(existingFilter.children);
3291 let same = current.length === cells.length;
3292 if (same) {
3293 for (let i = 0; i < cells.length; i++) {
3294 if (current[i] !== cells[i]) {
3295 same = false;
3296 break;
3297 }
3298 }
3299 }
3300 if (!same) {
3301 const wanted = new Set(cells);
3302 for (const cell of cells) {
3303 existingFilter.appendChild(cell);
3304 }
3305 for (const child of Array.from(existingFilter.children)) {
3306 if (!wanted.has(child)) {
3307 existingFilter.removeChild(child);
3308 }
3309 }
3310 }
3311 } else if (existingFilter) {
3312 existingFilter.remove();
3313 }
3314 }
3315 _buildHeaderCell(col, index, stickyN) {
3316 const th = document.createElement("th");
3317 th.setAttribute("scope", "col");
3318 th.dataset.key = col.key;
3319 this._applyCellClasses(th, col, index, stickyN);
3320 if (col.minWidth) {
3321 th.style.minWidth = col.minWidth;
3322 }
3323 if (col.key === SELECT_KEY) {
3324 const mode = this._readSelectable();
3325 if (mode === "multi") {
3326 const cb = document.createElement("input");
3327 cb.type = "checkbox";
3328 cb.className = "select-all-checkbox";
3329 cb.setAttribute("data-noclick", "");
3330 cb.setAttribute("aria-label", "Select all rows");
3331 const { total, selected } = this._visibleSelectionStats();
3332 cb.checked = total > 0 && selected === total;
3333 cb.indeterminate = selected > 0 && selected < total;
3334 cb.addEventListener("change", () => {
3335 if (cb.checked) {
3336 this.selectAll();
3337 } else {
3338 this.clearSelection();
3339 }
3340 });
3341 th.appendChild(cb);
3342 }
3343 return th;
3344 }
3345 th.textContent = col.label ?? (col.key === EXPANDER_KEY ? "" : col.key);
3346 if (col.sortable) {
3347 th.classList.add("is-sortable");
3348 const isActive = this._sort?.key === col.key;
3349 const indicator = document.createElement("span");
3350 indicator.className = "sort-indicator";
3351 let arrow = "";
3352 if (isActive) {
3353 arrow = this._sort.direction === "asc" ? " â–²" : " â–¼";
3354 }
3355 indicator.textContent = arrow;
3356 th.appendChild(indicator);
3357 if (isActive) {
3358 th.classList.add(
3359 this._sort.direction === "asc" ? "sort-asc" : "sort-desc"
3360 );
3361 }
3362 th.addEventListener("click", () => this._cycleSort(col.key));
3363 }
3364 return th;
3365 }
3366 _buildFilterCell(col, index, stickyN) {
3367 const cached = this._filterCache.get(col.key);
3368 const hasExplicitOptions = Array.isArray(col.filterOptions);
3369 const hasCustomRender = typeof col.filterRender === "function";
3370 let desiredKind;
3371 if (!col.filter && !hasExplicitOptions && !hasCustomRender || col.key === EXPANDER_KEY || col.key === SELECT_KEY) {
3372 desiredKind = "none";
3373 } else if (hasCustomRender) {
3374 desiredKind = "custom";
3375 } else if (col.filter === "select" || hasExplicitOptions) {
3376 desiredKind = "select";
3377 } else {
3378 desiredKind = "text";
3379 }
3380 if (cached && cached.kind === desiredKind) {
3381 cached.th.className = "";
3382 this._applyCellClasses(cached.th, col, index, stickyN);
3383 if (desiredKind === "select") {
3384 const select = cached.control;
3385 const opts = this._resolveFilterOptions(col);
3386 const optsKey = opts.map((o) => o.value).join("|");
3387 if (optsKey !== cached.optionsKey) {
3388 this._populateSelect(select, opts, this._filters[col.key] ?? "");
3389 cached.optionsKey = optsKey;
3390 } else {
3391 select.value = this._filters[col.key] ?? "";
3392 }
3393 } else if (desiredKind === "text") {
3394 const input = cached.control;
3395 const want = this._filters[col.key] ?? "";
3396 if (input.value !== want && input.ownerDocument.activeElement !== input) {
3397 input.value = want;
3398 }
3399 } else if (desiredKind === "custom" && col.filterRender) {
3400 col.filterRender(cached.th, {
3401 value: this._filters[col.key] ?? "",
3402 setValue: (next) => this._onFilterChange(col.key, next),
3403 col
3404 });
3405 }
3406 return cached.th;
3407 }
3408 const th = document.createElement("th");
3409 this._applyCellClasses(th, col, index, stickyN);
3410 if (desiredKind === "none") {
3411 this._filterCache.set(col.key, {
3412 th,
3413 control: null,
3414 optionsKey: "",
3415 kind: "none"
3416 });
3417 return th;
3418 }
3419 if (desiredKind === "custom" && col.filterRender) {
3420 col.filterRender(th, {
3421 value: this._filters[col.key] ?? "",
3422 setValue: (next) => this._onFilterChange(col.key, next),
3423 col
3424 });
3425 this._filterCache.set(col.key, {
3426 th,
3427 control: null,
3428 optionsKey: "",
3429 kind: "custom"
3430 });
3431 return th;
3432 }
3433 let control;
3434 let optionsKey = "";
3435 if (desiredKind === "select") {
3436 const select = document.createElement("select");
3437 select.classList.add("filter-select");
3438 select.setAttribute("data-noclick", "");
3439 select.setAttribute(
3440 "aria-label",
3441 `Filter ${col.label ?? col.key}`
3442 );
3443 const opts = this._resolveFilterOptions(col);
3444 this._populateSelect(select, opts, this._filters[col.key] ?? "");
3445 optionsKey = opts.map((o) => o.value).join("|");
3446 select.addEventListener("change", () => {
3447 this._onFilterChange(col.key, select.value);
3448 });
3449 control = select;
3450 } else {
3451 const input = document.createElement("input");
3452 input.type = "search";
3453 input.classList.add("filter-input");
3454 input.setAttribute("data-noclick", "");
3455 input.setAttribute("placeholder", "Filter…");
3456 input.setAttribute("aria-label", `Filter ${col.label ?? col.key}`);
3457 input.value = this._filters[col.key] ?? "";
3458 input.addEventListener("input", () => {
3459 this._onFilterChange(col.key, input.value);
3460 });
3461 control = input;
3462 }
3463 th.appendChild(control);
3464 this._filterCache.set(col.key, {
3465 th,
3466 control,
3467 optionsKey,
3468 kind: desiredKind
3469 });
3470 return th;
3471 }
3472 _populateSelect(select, options, current) {
3473 select.replaceChildren();
3474 const all2 = document.createElement("option");
3475 all2.value = "";
3476 all2.textContent = "All";
3477 select.appendChild(all2);
3478 for (const opt of options) {
3479 const el = document.createElement("option");
3480 el.value = opt.value;
3481 el.textContent = opt.label;
3482 if (opt.value === current) {
3483 el.selected = true;
3484 }
3485 select.appendChild(el);
3486 }
3487 select.value = current;
3488 }
3489 /**
3490 * Resolve the option list for a select-filter column. Explicit
3491 * `filterOptions` win — that's the contract for server-driven
3492 * tables that need the dropdown to list values not present on
3493 * the current page. Without `filterOptions`, fall back to the
3494 * unique row values in the column (legacy behaviour for
3495 * client-side tables).
3496 */
3497 _resolveFilterOptions(col) {
3498 if (Array.isArray(col.filterOptions)) {
3499 return col.filterOptions;
3500 }
3501 return this._uniqueValues(col.key).map((v) => ({
3502 value: v,
3503 label: v
3504 }));
3505 }
3506 // ------------------------------------------------------------------
3507 // Body
3508 // ------------------------------------------------------------------
3509 _paintBody(tbody, cols, stickyN) {
3510 tbody.replaceChildren();
3511 if (this.hasAttribute("loading")) {
3512 const count = this._readLoadingRows();
3513 for (let i = 0; i < count; i++) {
3514 tbody.appendChild(this._buildSkeletonRow(cols, i));
3515 }
3516 return;
3517 }
3518 const filtered = this._sortedRows(this._filteredRows());
3519 if (filtered.length === 0) {
3520 tbody.appendChild(this._buildEmptyRow(cols.length));
3521 return;
3522 }
3523 for (const { row, index } of filtered) {
3524 tbody.appendChild(this._buildBodyRow(row, index, cols, stickyN));
3525 if (this._expanded.has(index) && this._subTable) {
3526 const sub = this._subTable(row, index);
3527 if (sub) {
3528 tbody.appendChild(this._buildSubTableRow(sub, cols.length));
3529 }
3530 }
3531 }
3532 }
3533 _buildEmptyRow(colspan) {
3534 const tr = document.createElement("tr");
3535 tr.classList.add("empty");
3536 const td = document.createElement("td");
3537 td.colSpan = colspan;
3538 const slot = document.createElement("slot");
3539 slot.name = "empty";
3540 slot.textContent = this.getAttribute("empty") || "No data";
3541 td.appendChild(slot);
3542 tr.appendChild(td);
3543 return tr;
3544 }
3545 _buildSkeletonRow(cols, seed2) {
3546 const tr = document.createElement("tr");
3547 tr.classList.add("skeleton");
3548 tr.setAttribute("aria-hidden", "true");
3549 for (const _c of cols) {
3550 const td = document.createElement("td");
3551 const bar = document.createElement("span");
3552 bar.className = "skeleton-bar";
3553 const widthPct = 50 + (seed2 * 7 + tr.children.length * 13) % 40;
3554 bar.style.width = `${widthPct}%`;
3555 td.appendChild(bar);
3556 tr.appendChild(td);
3557 }
3558 return tr;
3559 }
3560 _buildBodyRow(row, rowIndex, cols, stickyN) {
3561 const tr = document.createElement("tr");
3562 tr.setAttribute("part", "row");
3563 tr.dataset.rowIndex = String(rowIndex);
3564 const id = this._getRowId(row, rowIndex);
3565 tr.dataset.rowId = String(id);
3566 if (this._selection.has(id)) {
3567 tr.classList.add("is-selected");
3568 }
3569 tr.addEventListener("click", (e) => {
3570 this._onRowClick(row, rowIndex, e);
3571 });
3572 for (let i = 0; i < cols.length; i++) {
3573 tr.appendChild(
3574 this._buildBodyCell(cols[i], i, row, rowIndex, stickyN)
3575 );
3576 }
3577 return tr;
3578 }
3579 _buildBodyCell(col, colIndex, row, rowIndex, stickyN) {
3580 const td = document.createElement("td");
3581 this._applyCellClasses(td, col, colIndex, stickyN);
3582 if (col.minWidth) {
3583 td.style.minWidth = col.minWidth;
3584 }
3585 if (col.key === SELECT_KEY) {
3586 const id = this._getRowId(row, rowIndex);
3587 const cb = document.createElement("input");
3588 cb.type = "checkbox";
3589 cb.className = "select-row-checkbox";
3590 cb.setAttribute("data-noclick", "");
3591 cb.setAttribute("aria-label", "Select row");
3592 cb.checked = this._selection.has(id);
3593 cb.addEventListener("change", () => {
3594 if (cb.checked) {
3595 this.select(id);
3596 } else {
3597 this.deselect(id);
3598 }
3599 });
3600 td.appendChild(cb);
3601 return td;
3602 }
3603 if (col.key === EXPANDER_KEY) {
3604 const hasChildren = this._subTable ? !!this._subTable(row, rowIndex) : false;
3605 if (!hasChildren) {
3606 return td;
3607 }
3608 const isOpen = this._expanded.has(rowIndex);
3609 const btn = document.createElement("button");
3610 btn.type = "button";
3611 btn.className = "expander";
3612 btn.setAttribute("data-noclick", "");
3613 btn.setAttribute("aria-expanded", isOpen ? "true" : "false");
3614 btn.setAttribute(
3615 "aria-label",
3616 isOpen ? "Collapse row" : "Expand row"
3617 );
3618 btn.textContent = isOpen ? "â–¾" : "â–¸";
3619 btn.addEventListener("click", (e) => {
3620 this._toggleRow(rowIndex, row, e);
3621 });
3622 td.appendChild(btn);
3623 return td;
3624 }
3625 const value = row[col.key];
3626 if (col.render) {
3627 const out = col.render(value, row, rowIndex);
3628 this._mountCellContent(td, out);
3629 } else if (value !== null && value !== void 0) {
3630 td.textContent = String(value);
3631 }
3632 return td;
3633 }
3634 _buildSubTableRow(sub, colspan) {
3635 const tr = document.createElement("tr");
3636 tr.classList.add("subtable");
3637 tr.setAttribute("part", "subtable-row");
3638 const td = document.createElement("td");
3639 td.colSpan = colspan;
3640 const inner = document.createElement("div");
3641 inner.classList.add("subtable-inner");
3642 if (sub instanceof Node) {
3643 inner.appendChild(sub);
3644 } else if (isTemplateResult(sub)) {
3645 render(sub, inner);
3646 } else {
3647 const nested = document.createElement("wpd-table");
3648 nested.columns = sub.columns;
3649 nested.data = sub.data;
3650 if (sub.subTable) {
3651 nested.subTable = sub.subTable;
3652 }
3653 inner.appendChild(nested);
3654 }
3655 td.appendChild(inner);
3656 tr.appendChild(td);
3657 return tr;
3658 }
3659 _mountCellContent(td, out) {
3660 if (typeof out === "string") {
3661 td.textContent = out;
3662 return;
3663 }
3664 if (out instanceof Node) {
3665 td.appendChild(out);
3666 return;
3667 }
3668 if (isTemplateResult(out)) {
3669 render(out, td);
3670 }
3671 }
3672 // ------------------------------------------------------------------
3673 // Behavior
3674 // ------------------------------------------------------------------
3675 _onFilterChange(key, value) {
3676 if (value === "") {
3677 delete this._filters[key];
3678 } else {
3679 this._filters[key] = value;
3680 }
3681 this.emit("wpd-table-filter-change", { filters: { ...this._filters } });
3682 const root = this.shadowRoot;
3683 const tbody = root?.querySelector("tbody");
3684 if (tbody) {
3685 const cols = this._effectiveColumns();
3686 const stickyN = this._readStickyColumns();
3687 this._lastStickyIndex = this._computeLastStickyIndex(cols, stickyN);
3688 this._paintBody(tbody, cols, stickyN);
3689 this._applyStickyOffsets();
3690 }
3691 }
3692 _onRowClick(row, index, e) {
3693 const path = e.composedPath?.() ?? [];
3694 for (const node of path) {
3695 if (node instanceof Element && node.hasAttribute("data-noclick")) {
3696 return;
3697 }
3698 if (node === this) {
3699 break;
3700 }
3701 }
3702 this.emit("wpd-table-row-click", { row, index, originalEvent: e });
3703 }
3704 _toggleRow(index, row, e) {
3705 e.stopPropagation();
3706 const isOpen = this._expanded.has(index);
3707 if (isOpen) {
3708 this._expanded.delete(index);
3709 } else {
3710 this._expanded.add(index);
3711 }
3712 this.emit("wpd-table-expand-change", {
3713 row,
3714 index,
3715 expanded: !isOpen
3716 });
3717 this._schedulePaint();
3718 }
3719 _cycleSort(key) {
3720 if (!this._sort || this._sort.key !== key) {
3721 this._sort = { key, direction: "asc" };
3722 } else if (this._sort.direction === "asc") {
3723 this._sort = { key, direction: "desc" };
3724 } else {
3725 this._sort = null;
3726 }
3727 this.emit("wpd-table-sort-change", {
3728 sort: this._sort ? { ...this._sort } : null
3729 });
3730 this._schedulePaint();
3731 }
3732 _emitSelectionChange() {
3733 this.emit("wpd-table-selection-change", {
3734 selection: Array.from(this._selection),
3735 rows: this.selectedRows
3736 });
3737 }
3738 // ------------------------------------------------------------------
3739 // Filtering + sorting
3740 // ------------------------------------------------------------------
3741 _filteredRows() {
3742 const out = [];
3743 const active = Object.keys(this._filters).filter(
3744 (k) => this._filters[k] !== ""
3745 );
3746 for (let i = 0; i < this._data.length; i++) {
3747 const row = this._data[i];
3748 let pass = true;
3749 for (const key of active) {
3750 const col = this._columns.find((c) => c.key === key);
3751 if (col && typeof col.filterRender === "function") {
3752 continue;
3753 }
3754 const filter = this._filters[key] ?? "";
3755 const cell = row[key];
3756 const cellStr = cell === null || cell === void 0 ? "" : String(cell);
3757 if (col?.filter === "select") {
3758 if (cellStr !== filter) {
3759 pass = false;
3760 break;
3761 }
3762 } else if (!cellStr.toLowerCase().includes(filter.toLowerCase())) {
3763 pass = false;
3764 break;
3765 }
3766 }
3767 if (pass) {
3768 out.push({ row, index: i });
3769 }
3770 }
3771 return out;
3772 }
3773 _sortedRows(rows) {
3774 if (!this._sort) {
3775 return rows;
3776 }
3777 const col = this._columns.find((c) => c.key === this._sort.key);
3778 if (!col) {
3779 return rows;
3780 }
3781 const dir = this._sort.direction === "desc" ? -1 : 1;
3782 const out = rows.slice();
3783 out.sort((a, b) => {
3784 const av = col.sortValue ? col.sortValue(a.row, a.row[col.key]) : a.row[col.key];
3785 const bv = col.sortValue ? col.sortValue(b.row, b.row[col.key]) : b.row[col.key];
3786 return compareValues(av, bv) * dir;
3787 });
3788 return out;
3789 }
3790 _uniqueValues(key) {
3791 const seen = /* @__PURE__ */ new Set();
3792 for (const row of this._data) {
3793 const v = row[key];
3794 if (v === null || v === void 0) {
3795 continue;
3796 }
3797 seen.add(String(v));
3798 }
3799 return Array.from(seen).sort();
3800 }
3801 /**
3802 * Selection stats over the VISIBLE (client-side-filtered) rows —
3803 * the same set `selectAll()` operates on. The header select-all
3804 * tri-state derives from these so "checked" always means "every
3805 * row the user can see is selected", even while ids of currently
3806 * hidden rows linger in the selection set.
3807 */
3808 _visibleSelectionStats() {
3809 let total = 0;
3810 let selected = 0;
3811 for (const { row, index } of this._filteredRows()) {
3812 total++;
3813 if (this._selection.has(this._getRowId(row, index))) {
3814 selected++;
3815 }
3816 }
3817 return { total, selected };
3818 }
3819 // ------------------------------------------------------------------
3820 // Sticky columns + attribute reads
3821 // ------------------------------------------------------------------
3822 _readStickyColumns() {
3823 const raw = parseInt(this.getAttribute("sticky-columns") || "0", 10);
3824 return Number.isFinite(raw) && raw > 0 ? raw : 0;
3825 }
3826 _readLoadingRows() {
3827 const raw = parseInt(this.getAttribute("loading-rows") || "5", 10);
3828 return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 100) : 5;
3829 }
3830 _readSelectable() {
3831 const v = this.getAttribute("selectable");
3832 if (v === "single") {
3833 return "single";
3834 }
3835 if (v === "multi" || v === "") {
3836 return "multi";
3837 }
3838 return null;
3839 }
3840 /**
3841 * Sticky-band membership. The first N columns get pinned, with two
3842 * per-column overrides: `column.sticky = true` opts in even outside
3843 * the band; `column.sticky = false` opts out within it.
3844 */
3845 _isStickyIndex(index, stickyN, col) {
3846 if (col.sticky === false) {
3847 return false;
3848 }
3849 if (col.sticky === true) {
3850 return true;
3851 }
3852 return index < stickyN;
3853 }
3854 _computeLastStickyIndex(cols, stickyN) {
3855 let last = -1;
3856 for (let i = 0; i < cols.length; i++) {
3857 if (this._isStickyIndex(i, stickyN, cols[i])) {
3858 last = i;
3859 }
3860 }
3861 return last;
3862 }
3863 _applyCellClasses(cell, col, index, stickyN) {
3864 if (col.key === EXPANDER_KEY) {
3865 cell.classList.add("col-expander");
3866 }
3867 if (col.key === SELECT_KEY) {
3868 cell.classList.add("col-select");
3869 }
3870 if (col.align === "center") {
3871 cell.classList.add("align-center");
3872 }
3873 if (col.align === "end") {
3874 cell.classList.add("align-end");
3875 }
3876 const sticky = this._isStickyIndex(index, stickyN, col);
3877 if (sticky) {
3878 cell.classList.add("is-sticky");
3879 if (index === this._lastStickyIndex) {
3880 cell.classList.add("is-sticky-edge");
3881 }
3882 }
3883 }
3884 _effectiveColumns() {
3885 const out = [];
3886 if (this._readSelectable()) {
3887 out.push({
3888 key: SELECT_KEY,
3889 label: "",
3890 // The descriptor width is painted onto a `<col>`
3891 // element and is the authoritative column-width
3892 // source in table-layout: auto — CSS `td { width }`
3893 // is ignored once `<col>` has a value. Pair with
3894 // the matching `td.col-select` rule (zero
3895 // `padding-inline`, `text-align: center`) so the
3896 // checkbox sits with breathing room on both sides.
3897 width: "40px",
3898 align: "center"
3899 });
3900 }
3901 if (this._subTable) {
3902 out.push({
3903 key: EXPANDER_KEY,
3904 label: "",
3905 // Same contract as col-select. 36px column +
3906 // 20px button + zero padding centers the chevron
3907 // with ~8px on each side.
3908 width: "36px",
3909 align: "center"
3910 });
3911 }
3912 out.push(...this._columns);
3913 return out;
3914 }
3915 /**
3916 * Walk the header row, sum the natural widths of the sticky cells,
3917 * then write cumulative `inset-inline-start` offsets onto every
3918 * row's matching cells.
3919 */
3920 _applyStickyOffsets() {
3921 const root = this.shadowRoot;
3922 if (!root) {
3923 return;
3924 }
3925 const headRow = root.querySelector("thead tr");
3926 if (!headRow) {
3927 return;
3928 }
3929 const ths = Array.from(headRow.children);
3930 const offsets = [];
3931 let acc = 0;
3932 for (let i = 0; i < ths.length; i++) {
3933 offsets[i] = acc;
3934 if (ths[i].classList.contains("is-sticky")) {
3935 acc += ths[i].offsetWidth;
3936 }
3937 }
3938 const rows = root.querySelectorAll(
3939 "thead tr, tbody tr:not(.subtable):not(.empty):not(.skeleton)"
3940 );
3941 rows.forEach((r) => {
3942 const cells = Array.from(r.children);
3943 for (let i = 0; i < cells.length; i++) {
3944 if (cells[i].classList.contains("is-sticky")) {
3945 cells[i].style.insetInlineStart = `${offsets[i]}px`;
3946 }
3947 }
3948 });
3949 this._maybeWarnStickyOffsetRace(ths, offsets);
3950 }
3951 _maybeWarnStickyOffsetRace(ths, offsets) {
3952 if (this._stickyRaceWarned) {
3953 return;
3954 }
3955 const stickyN = this._readStickyColumns();
3956 if (stickyN < 2) {
3957 return;
3958 }
3959 const lastIdx = Math.min(stickyN - 1, ths.length - 1);
3960 if (lastIdx <= 0) {
3961 return;
3962 }
3963 if (offsets[lastIdx] !== 0) {
3964 return;
3965 }
3966 if (this.offsetWidth === 0) {
3967 return;
3968 }
3969 this._stickyRaceWarned = true;
3970 const w0 = ths[0]?.offsetWidth ?? 0;
3971 console.warn(
3972 `[wpd-table] sticky-columns: column ${lastIdx} resolved to inset-inline-start: 0px while the host is visible. ths[0].offsetWidth was ${w0}px at measurement time. Likely a layout race — call recomputeLayout() after the panel finishes its mount/transition, or wrap the assignment of \`data\` in a requestAnimationFrame.`
3973 );
3974 }
3975 _measureHeaderHeight() {
3976 const root = this.shadowRoot;
3977 if (!root) {
3978 return;
3979 }
3980 const headRow = root.querySelector("thead tr");
3981 if (!headRow) {
3982 return;
3983 }
3984 const h = headRow.offsetHeight;
3985 if (h > 0) {
3986 this.style.setProperty("--wpd-table-header-height", `${h}px`);
3987 }
3988 }
3989 /**
3990 * Once-per-element warning for the most common sticky-header
3991 * mistake: forgetting to give the table a scroll container. Without
3992 * a max-height (or a scrolling ancestor), `position: sticky`
3993 * silently does nothing because there's no scrollport for it to
3994 * stick within.
3995 */
3996 _maybeWarnStickyHeader() {
3997 if (this._stickyHeaderWarned) {
3998 return;
3999 }
4000 if (!this.hasAttribute("sticky-header")) {
4001 return;
4002 }
4003 if (this.hasAttribute("loading") || this._data.length < 8) {
4004 return;
4005 }
4006 const scroll = this.shadowRoot?.querySelector(
4007 ".scroll"
4008 );
4009 if (!scroll) {
4010 return;
4011 }
4012 if (scroll.offsetWidth === 0) {
4013 return;
4014 }
4015 if (scroll.scrollHeight <= scroll.clientHeight + 1) {
4016 this._stickyHeaderWarned = true;
4017 console.warn(
4018 "[wpd-table] sticky-header is set but the table has no scroll container. Set --wpd-table-max-height on the host (or wrap it in a scrolling parent) so the header has something to stick to."
4019 );
4020 }
4021 }
4022 };
4023 _WpdTable.props = [
4024 "stickyColumns",
4025 "stickyHeader",
4026 "striped",
4027 "hover",
4028 "compact",
4029 "bordered",
4030 "empty",
4031 "loading",
4032 "loadingRows",
4033 "selectable"
4034 ];
4035 _WpdTable.styles = [styles];
4036 _WpdTable.help = {
4037 title: "Table",
4038 summary: "Data-driven table. Assign `columns` + `data` and you get a styled table with optional per-column filters, click-to-sort, multi-row selection, sticky columns/header, sub-tables, custom cell renderers, loading skeleton, and a slottable empty state.",
4039 status: "experimental",
4040 since: "0.6.0",
4041 props: [
4042 {
4043 name: "sticky-columns",
4044 type: "integer",
4045 description: "Pin the first N columns to the inline-start edge. Widths are measured after layout, so variable-width columns work. The auto-injected expander (subTable) and select (selectable) columns count toward N."
4046 },
4047 {
4048 name: "sticky-header",
4049 type: "boolean",
4050 description: "Pin the header (and filter row) to the top. Requires a scrolling parent or `--wpd-table-max-height` — the component warns once if it detects sticky-header on a non-scrolling container."
4051 },
4052 { name: "striped", type: "boolean", description: "Zebra rows." },
4053 { name: "hover", type: "boolean", description: "Highlight rows on hover." },
4054 { name: "compact", type: "boolean", description: "Tighter padding + smaller font." },
4055 { name: "bordered", type: "boolean", description: "Vertical cell borders." },
4056 {
4057 name: "empty",
4058 type: "string",
4059 description: "Fallback text shown when there are no rows. For richer empty states, project light-DOM content into the `empty` slot."
4060 },
4061 {
4062 name: "loading",
4063 type: "boolean",
4064 description: "Paint shimmering skeleton rows in place of body content. Filters / sort headers stay live."
4065 },
4066 {
4067 name: "loading-rows",
4068 type: "integer",
4069 description: "Number of skeleton rows when loading. Default 5."
4070 },
4071 {
4072 name: "selectable",
4073 type: '"single" | "multi"',
4074 description: "Auto-prepend a checkbox column. `multi` puts a select-all checkbox in the header; `single` enforces at-most-one selected."
4075 }
4076 ],
4077 events: [
4078 { name: "wpd-table-filter-change", description: "Filter input changed." },
4079 { name: "wpd-table-sort-change", description: "Header click cycled the sort." },
4080 { name: "wpd-table-selection-change", description: "Selection set changed." },
4081 { name: "wpd-table-row-click", description: "Body row clicked (skips data-noclick descendants)." },
4082 { name: "wpd-table-expand-change", description: "Sub-table toggled." }
4083 ],
4084 slots: [
4085 { name: "empty", description: "Custom empty-state content (CTA, illustration, etc.)." }
4086 ],
4087 cssProps: [
4088 { name: "--wpd-table-bg" },
4089 { name: "--wpd-table-border" },
4090 { name: "--wpd-table-column-border" },
4091 { name: "--wpd-table-header-bg" },
4092 { name: "--wpd-table-row-hover" },
4093 { name: "--wpd-table-stripe" },
4094 { name: "--wpd-table-cell-padding" },
4095 { name: "--wpd-table-font-size" },
4096 { name: "--wpd-table-max-height" },
4097 { name: "--wpd-table-skeleton-color" }
4098 ],
4099 example: html`
4100 <wpd-table id="sample-table" sticky-header striped hover></wpd-table>
4101 `
4102 };
4103 let WpdTable = _WpdTable;
4104 function isTemplateResult(v) {
4105 return !!v && v.__wpdHtml === true;
4106 }
4107 function compareValues(a, b) {
4108 if (a === b) {
4109 return 0;
4110 }
4111 if (a === null || a === void 0) {
4112 return -1;
4113 }
4114 if (b === null || b === void 0) {
4115 return 1;
4116 }
4117 if (typeof a === "number" && typeof b === "number") {
4118 return a - b;
4119 }
4120 if (a instanceof Date && b instanceof Date) {
4121 return a.getTime() - b.getTime();
4122 }
4123 const an = Number(a);
4124 const bn = Number(b);
4125 if (Number.isFinite(an) && Number.isFinite(bn)) {
4126 return an - bn;
4127 }
4128 return String(a).localeCompare(String(b));
4129 }
4130 defineComponent("wpd-table", WpdTable);
4131 const PER_PAGE = 25;
4132 function currentUserId$2() {
4133 const wpGlobal = window.wp;
4134 return Number(wpGlobal?.desktop?.config?.currentUserId) || 0;
4135 }
4136 function formatTimeValue(value) {
4137 const seconds = Math.max(0, Math.round(Number(value) || 0));
4138 const minutes = Math.floor(seconds / 60);
4139 const rest = seconds % 60;
4140 return `${minutes}:${String(rest).padStart(2, "0")}`;
4141 }
4142 function buildColumns(game) {
4143 const columns = [
4144 {
4145 key: "userName",
4146 label: __("Player"),
4147 render: (_value, row) => {
4148 const cell = document.createElement("span");
4149 cell.style.cssText = "display:inline-flex;align-items:center;gap:8px;min-width:0;";
4150 const avatar = document.createElement("wpd-avatar");
4151 avatar.setAttribute("src", row.userAvatar);
4152 avatar.setAttribute("name", row.userName);
4153 avatar.setAttribute("size", "xs");
4154 avatar.setAttribute("user-id", String(row.userId));
4155 cell.appendChild(avatar);
4156 const name = document.createElement("span");
4157 name.textContent = row.userName;
4158 cell.appendChild(name);
4159 return cell;
4160 }
4161 }
4162 ];
4163 for (const column of game.scoreColumns) {
4164 columns.push({
4165 key: column.key,
4166 label: column.label,
4167 render: (_value, row) => {
4168 const raw = "score" === column.key ? row.score : row.meta[column.key];
4169 if (raw === void 0 || raw === null) {
4170 return "—";
4171 }
4172 if ("time" === column.type) {
4173 return formatTimeValue(raw);
4174 }
4175 return String(raw);
4176 }
4177 });
4178 }
4179 columns.push({
4180 key: "createdAtMs",
4181 label: __("When"),
4182 render: (_value, row) => {
4183 const time = document.createElement("wpd-relative-time");
4184 time.setAttribute(
4185 "datetime",
4186 new Date(row.createdAtMs).toISOString()
4187 );
4188 return time;
4189 }
4190 });
4191 columns.push({
4192 key: "__actions",
4193 label: "",
4194 render: (_value, row) => {
4195 if (row.userId !== currentUserId$2()) {
4196 return "";
4197 }
4198 const btn = document.createElement("wpd-button");
4199 btn.setAttribute("variant", "secondary");
4200 btn.setAttribute("size", "sm");
4201 btn.textContent = __("Challenge…");
4202 btn.addEventListener("click", () => {
4203 openChallengeDialog({
4204 game: game.id,
4205 gameTitle: game.title,
4206 score: row.score,
4207 meta: row.meta
4208 });
4209 });
4210 return btn;
4211 }
4212 });
4213 return columns;
4214 }
4215 function renderScoreboard(container, game) {
4216 container.innerHTML = "";
4217 const tableHost = document.createElement("div");
4218 tableHost.className = "desktop-mode-games__scoreboard-table";
4219 container.appendChild(tableHost);
4220 const pager = document.createElement("div");
4221 pager.className = "desktop-mode-games__pager";
4222 container.appendChild(pager);
4223 let page = 1;
4224 let total = 0;
4225 let loadSeq = 0;
4226 let disposed = false;
4227 const table = document.createElement("wpd-table");
4228 table.setAttribute("sticky-header", "");
4229 table.setAttribute("hover", "");
4230 table.setAttribute("striped", "");
4231 const empty = document.createElement("div");
4232 empty.setAttribute("slot", "empty");
4233 empty.className = "desktop-mode-games__scoreboard-empty";
4234 empty.textContent = __("No scores yet — be the first to play!");
4235 table.appendChild(empty);
4236 tableHost.appendChild(table);
4237 table.columns = buildColumns(game);
4238 table.data = [];
4239 const paintPager = () => {
4240 pager.innerHTML = "";
4241 const pages = Math.max(1, Math.ceil(total / PER_PAGE));
4242 if (pages <= 1) {
4243 return;
4244 }
4245 const prev = document.createElement("wpd-button");
4246 prev.setAttribute("variant", "ghost");
4247 prev.textContent = __("Previous");
4248 if (page <= 1) {
4249 prev.setAttribute("disabled", "");
4250 }
4251 prev.addEventListener("click", () => void load(page - 1));
4252 const label = document.createElement("span");
4253 label.className = "desktop-mode-games__pager-label";
4254 label.textContent = `${page} / ${pages}`;
4255 const next = document.createElement("wpd-button");
4256 next.setAttribute("variant", "ghost");
4257 next.textContent = __("Next");
4258 if (page >= pages) {
4259 next.setAttribute("disabled", "");
4260 }
4261 next.addEventListener("click", () => void load(page + 1));
4262 pager.append(prev, label, next);
4263 };
4264 const load = async (toPage) => {
4265 const seq = ++loadSeq;
4266 table.setAttribute("loading", "");
4267 try {
4268 const result = await fetchScores(game.id, {
4269 page: toPage,
4270 perPage: PER_PAGE
4271 });
4272 if (disposed || seq !== loadSeq) {
4273 return;
4274 }
4275 page = toPage;
4276 total = result.total;
4277 table.data = result.scores;
4278 } catch (err) {
4279 if (typeof console !== "undefined") {
4280 console.error("[desktop-mode] scoreboard load failed:", err);
4281 }
4282 } finally {
4283 if (!disposed && seq === loadSeq) {
4284 table.removeAttribute("loading");
4285 paintPager();
4286 }
4287 }
4288 };
4289 void load(1);
4290 return () => {
4291 disposed = true;
4292 };
4293 }
4294 const store = createSharedStore(
4295 "desktop-mode/games-challenges",
4296 () => ({
4297 rows: /* @__PURE__ */ new Map(),
4298 version: 0,
4299 listeners: /* @__PURE__ */ new Set()
4300 })
4301 );
4302 function ingestChallenges(rows) {
4303 const state = store.state;
4304 let changed = false;
4305 for (const row of rows) {
4306 if (!row || typeof row.id !== "number") {
4307 continue;
4308 }
4309 const prev = state.rows.get(row.id);
4310 if (!prev || prev.updatedAtMs !== row.updatedAtMs) {
4311 state.rows.set(row.id, row);
4312 changed = true;
4313 }
4314 if (row.updatedAtMs > state.version) {
4315 state.version = row.updatedAtMs;
4316 }
4317 }
4318 if (changed) {
4319 notify();
4320 }
4321 }
4322 function subscribeChallenges(cb) {
4323 store.state.listeners.add(cb);
4324 return () => {
4325 store.state.listeners.delete(cb);
4326 };
4327 }
4328 function notify() {
4329 for (const cb of Array.from(store.state.listeners)) {
4330 try {
4331 cb();
4332 } catch (err) {
4333 if (typeof console !== "undefined") {
4334 console.error(
4335 "[desktop-mode] challenges store listener threw:",
4336 err
4337 );
4338 }
4339 }
4340 }
4341 }
4342 function allChallenges() {
4343 return Array.from(store.state.rows.values()).sort(
4344 (a, b) => b.updatedAtMs - a.updatedAtMs
4345 );
4346 }
4347 function gameTitle(id) {
4348 return get(id)?.title || id;
4349 }
4350 async function acceptAndPlay(row) {
4351 const { challenge } = await acceptChallenge(row.id);
4352 ingestChallenges([challenge]);
4353 await launchGame(row.game, {
4354 challenge: {
4355 id: row.id,
4356 scoreToBeat: row.scoreToBeat,
4357 scoreMeta: row.scoreMeta,
4358 challengerName: row.challengerName
4359 }
4360 });
4361 }
4362 function currentUserId$1() {
4363 const wpGlobal = window.wp;
4364 return Number(wpGlobal?.desktop?.config?.currentUserId) || 0;
4365 }
4366 function describeRow(row, viewerId) {
4367 const incoming = row.recipientId === viewerId;
4368 const other = incoming ? row.challengerName : row.recipientName;
4369 const title = gameTitle(row.game);
4370 const target = String(row.scoreToBeat);
4371 if ("pending" === row.state) {
4372 if (incoming) {
4373 return sprintf(
4374 /* translators: 1: challenger name, 2: game title, 3: score. */
4375 __("%1$s challenged you to %2$s — beat %3$s."),
4376 other,
4377 title,
4378 target
4379 );
4380 }
4381 return sprintf(
4382 /* translators: 1: recipient name, 2: game title, 3: score. */
4383 __("Waiting for %1$s to accept your %2$s challenge (%3$s)."),
4384 other,
4385 title,
4386 target
4387 );
4388 }
4389 if ("accepted" === row.state) {
4390 if (incoming) {
4391 return sprintf(
4392 /* translators: 1: game title, 2: score. */
4393 __("You accepted — play %1$s and beat %2$s!"),
4394 title,
4395 target
4396 );
4397 }
4398 return sprintf(
4399 /* translators: 1: recipient name, 2: game title. */
4400 __("%1$s accepted your %2$s challenge and is playing."),
4401 other,
4402 title
4403 );
4404 }
4405 if ("declined" === row.state) {
4406 if (incoming) {
4407 return sprintf(
4408 /* translators: 1: challenger name, 2: game title. */
4409 __("You declined %1$s’s %2$s challenge."),
4410 other,
4411 title
4412 );
4413 }
4414 return sprintf(
4415 /* translators: 1: recipient name, 2: game title. */
4416 __("%1$s declined your %2$s challenge."),
4417 other,
4418 title
4419 );
4420 }
4421 const beaten = "beaten" === row.result;
4422 const result = String(row.resultScore ?? 0);
4423 if (incoming) {
4424 if (beaten) {
4425 return sprintf(
4426 /* translators: 1: game title, 2: result score, 3: target score. */
4427 __("You beat the %1$s challenge: %2$s vs %3$s."),
4428 title,
4429 result,
4430 target
4431 );
4432 }
4433 return sprintf(
4434 /* translators: 1: game title, 2: result score, 3: target score. */
4435 __("You missed the %1$s challenge: %2$s vs %3$s."),
4436 title,
4437 result,
4438 target
4439 );
4440 }
4441 if (beaten) {
4442 return sprintf(
4443 /* translators: 1: recipient name, 2: result score, 3: target score. */
4444 __("%1$s beat your score: %2$s vs %3$s."),
4445 other,
4446 result,
4447 target
4448 );
4449 }
4450 return sprintf(
4451 /* translators: 1: recipient name, 2: result score, 3: target score. */
4452 __("%1$s did not beat your score: %2$s vs %3$s."),
4453 other,
4454 result,
4455 target
4456 );
4457 }
4458 function buildRow(row, viewerId) {
4459 const incoming = row.recipientId === viewerId;
4460 const item = document.createElement("li");
4461 item.className = `desktop-mode-games__challenge desktop-mode-games__challenge--${row.state}`;
4462 const avatar = document.createElement("wpd-avatar");
4463 const otherId = incoming ? row.challengerId : row.recipientId;
4464 avatar.setAttribute(
4465 "src",
4466 incoming ? row.challengerAvatar : row.recipientAvatar
4467 );
4468 avatar.setAttribute(
4469 "name",
4470 incoming ? row.challengerName : row.recipientName
4471 );
4472 avatar.setAttribute("size", "sm");
4473 avatar.setAttribute("user-id", String(otherId));
4474 item.appendChild(avatar);
4475 const main = document.createElement("div");
4476 main.className = "desktop-mode-games__challenge-main";
4477 const text = document.createElement("p");
4478 text.textContent = describeRow(row, viewerId);
4479 main.appendChild(text);
4480 const when = document.createElement("wpd-relative-time");
4481 when.setAttribute("datetime", new Date(row.updatedAtMs).toISOString());
4482 main.appendChild(when);
4483 item.appendChild(main);
4484 if (incoming && "pending" === row.state) {
4485 const actions = document.createElement("div");
4486 actions.className = "desktop-mode-games__challenge-actions";
4487 const accept = document.createElement("wpd-button");
4488 accept.setAttribute("variant", "primary");
4489 accept.setAttribute("size", "sm");
4490 accept.textContent = __("Accept & Play");
4491 accept.addEventListener("click", () => {
4492 accept.setAttribute("disabled", "");
4493 void acceptAndPlay(row).catch((err) => {
4494 accept.removeAttribute("disabled");
4495 showToast({
4496 message: err instanceof Error ? err.message : __("Could not accept the challenge.")
4497 });
4498 });
4499 });
4500 actions.appendChild(accept);
4501 const decline = document.createElement("wpd-button");
4502 decline.setAttribute("variant", "ghost");
4503 decline.setAttribute("size", "sm");
4504 decline.textContent = __("Decline");
4505 decline.addEventListener("click", () => {
4506 decline.setAttribute("disabled", "");
4507 void declineChallenge(row.id).then(({ challenge }) => ingestChallenges([challenge])).catch((err) => {
4508 decline.removeAttribute("disabled");
4509 showToast({
4510 message: err instanceof Error ? err.message : __("Could not decline the challenge.")
4511 });
4512 });
4513 });
4514 actions.appendChild(decline);
4515 item.appendChild(actions);
4516 }
4517 return item;
4518 }
4519 function renderChallengesView(container, gameId) {
4520 container.innerHTML = "";
4521 const list = document.createElement("ul");
4522 list.className = "desktop-mode-games__challenge-list";
4523 container.appendChild(list);
4524 const viewerId = currentUserId$1();
4525 const paint = () => {
4526 list.innerHTML = "";
4527 const rows = allChallenges().filter(
4528 (row) => !gameId || row.game === gameId
4529 );
4530 if (rows.length === 0) {
4531 const empty = document.createElement("wpd-empty-state");
4532 empty.setAttribute("icon", "awards");
4533 empty.setAttribute("heading", __("No challenges yet"));
4534 empty.setAttribute(
4535 "description",
4536 __(
4537 "Press Challenge to throw down one of your scores, or pick a row from the scoreboard."
4538 )
4539 );
4540 list.appendChild(empty);
4541 return;
4542 }
4543 for (const row of rows) {
4544 list.appendChild(buildRow(row, viewerId));
4545 }
4546 };
4547 const unsubscribe = subscribeChallenges(paint);
4548 paint();
4549 void fetchChallenges({ box: "all" }).then(({ challenges }) => ingestChallenges(challenges)).catch((err) => {
4550 if (typeof console !== "undefined") {
4551 console.error(
4552 "[desktop-mode] challenges resync failed:",
4553 err
4554 );
4555 }
4556 });
4557 return unsubscribe;
4558 }
4559 const ROOT = "[data-desktop-mode-games-root]";
4560 const GRID = "[data-desktop-mode-games-grid]";
4561 const DETAIL = "[data-desktop-mode-games-detail]";
4562 function currentUserId() {
4563 const wpGlobal = window.wp;
4564 return Number(wpGlobal?.desktop?.config?.currentUserId) || 0;
4565 }
4566 function buildGameIcon(icon) {
4567 if (icon.startsWith("data:") || /^https?:\/\//.test(icon)) {
4568 const img = document.createElement("img");
4569 img.src = icon;
4570 img.alt = "";
4571 img.className = "desktop-mode-games__icon-img";
4572 return img;
4573 }
4574 const span = document.createElement("span");
4575 span.className = `dashicons ${icon || "dashicons-admin-generic"} desktop-mode-games__icon-dashicon`;
4576 span.setAttribute("aria-hidden", "true");
4577 return span;
4578 }
4579 function renderGamesHub(body) {
4580 const root = body.querySelector(ROOT);
4581 const grid = body.querySelector(GRID);
4582 const detail = body.querySelector(DETAIL);
4583 if (!root || !grid || !detail) {
4584 return;
4585 }
4586 const teardowns = [];
4587 let detailTeardowns = [];
4588 let selectedId = null;
4589 const disposeDetail = () => {
4590 for (const fn of detailTeardowns) {
4591 try {
4592 fn();
4593 } catch {
4594 }
4595 }
4596 detailTeardowns = [];
4597 };
4598 const challengeFromBest = async (game) => {
4599 const viewerId = currentUserId();
4600 const mine = await fetchScores(game.id, {
4601 perPage: 1,
4602 userId: viewerId
4603 });
4604 const best = mine.scores[0];
4605 if (!best) {
4606 showToast({
4607 message: sprintf(
4608 /* translators: %s: game title. */
4609 __("Play %s first — you need a score to challenge with."),
4610 game.title
4611 )
4612 });
4613 return;
4614 }
4615 await openChallengeDialog({
4616 game: game.id,
4617 gameTitle: game.title,
4618 score: best.score,
4619 meta: best.meta
4620 });
4621 };
4622 const renderDetail = (game) => {
4623 disposeDetail();
4624 detail.hidden = false;
4625 detail.innerHTML = "";
4626 const hero = document.createElement("div");
4627 hero.className = "desktop-mode-games__hero";
4628 const visual = document.createElement("div");
4629 visual.className = "desktop-mode-games__hero-visual";
4630 visual.appendChild(buildGameIcon(game.icon));
4631 hero.appendChild(visual);
4632 const info = document.createElement("div");
4633 info.className = "desktop-mode-games__hero-info";
4634 const title = document.createElement("h2");
4635 title.className = "desktop-mode-games__hero-title";
4636 title.textContent = game.title;
4637 info.appendChild(title);
4638 if (game.description) {
4639 const desc = document.createElement("p");
4640 desc.className = "desktop-mode-games__hero-desc";
4641 desc.textContent = game.description;
4642 info.appendChild(desc);
4643 }
4644 const playtime = document.createElement("div");
4645 playtime.className = "desktop-mode-games__hero-playtime";
4646 playtime.hidden = true;
4647 info.appendChild(playtime);
4648 let playtimeStale = false;
4649 detailTeardowns.push(() => {
4650 playtimeStale = true;
4651 });
4652 const playtimeStat = (label, value) => {
4653 const stat = document.createElement("span");
4654 stat.className = "desktop-mode-games__playtime-stat";
4655 const labelEl = document.createElement("span");
4656 labelEl.className = "desktop-mode-games__playtime-label";
4657 labelEl.textContent = label;
4658 stat.appendChild(labelEl);
4659 const valueEl = document.createElement("span");
4660 valueEl.className = "desktop-mode-games__playtime-value";
4661 valueEl.textContent = value;
4662 stat.appendChild(valueEl);
4663 return stat;
4664 };
4665 void fetchPlaytime().then((res) => {
4666 const total = Number(res.playtime[game.id]) || 0;
4667 if (playtimeStale || total < 1) {
4668 return;
4669 }
4670 const recent = sumPlaytimeSince(
4671 res.daily?.[game.id] ?? {},
4672 res.today,
4673 14
4674 );
4675 if (recent > 0) {
4676 playtime.appendChild(
4677 playtimeStat(
4678 __("Play time (last two weeks)"),
4679 formatPlaytime(recent)
4680 )
4681 );
4682 }
4683 playtime.appendChild(
4684 playtimeStat(
4685 __("Play time (total)"),
4686 formatPlaytime(total)
4687 )
4688 );
4689 playtime.hidden = false;
4690 }).catch(() => {
4691 });
4692 hero.appendChild(info);
4693 const actions = document.createElement("div");
4694 actions.className = "desktop-mode-games__hero-actions";
4695 const play = document.createElement("wpd-button");
4696 play.setAttribute("variant", "primary");
4697 play.setAttribute("size", "lg");
4698 play.textContent = __("Play");
4699 play.addEventListener("click", () => {
4700 play.setAttribute("disabled", "");
4701 void launchGame(game.id).catch((err) => {
4702 if (typeof console !== "undefined") {
4703 console.error(
4704 "[desktop-mode] game launch failed:",
4705 err
4706 );
4707 }
4708 }).finally(() => {
4709 play.removeAttribute("disabled");
4710 });
4711 });
4712 actions.appendChild(play);
4713 const challenge = document.createElement("wpd-button");
4714 challenge.setAttribute("variant", "secondary");
4715 challenge.textContent = __("Challenge…");
4716 challenge.addEventListener("click", () => {
4717 challenge.setAttribute("disabled", "");
4718 void challengeFromBest(game).finally(() => {
4719 challenge.removeAttribute("disabled");
4720 });
4721 });
4722 actions.appendChild(challenge);
4723 hero.appendChild(actions);
4724 detail.appendChild(hero);
4725 const scoreboardSection = document.createElement("section");
4726 scoreboardSection.className = "desktop-mode-games__section";
4727 const scoreboardHeading = document.createElement("h3");
4728 scoreboardHeading.className = "desktop-mode-games__section-heading";
4729 scoreboardHeading.textContent = __("Scoreboard");
4730 scoreboardSection.appendChild(scoreboardHeading);
4731 const scoreboardHost = document.createElement("div");
4732 scoreboardSection.appendChild(scoreboardHost);
4733 detail.appendChild(scoreboardSection);
4734 detailTeardowns.push(renderScoreboard(scoreboardHost, game));
4735 const challengesSection = document.createElement("section");
4736 challengesSection.className = "desktop-mode-games__section";
4737 const challengesHeading = document.createElement("h3");
4738 challengesHeading.className = "desktop-mode-games__section-heading";
4739 challengesHeading.textContent = __("Challenges");
4740 challengesSection.appendChild(challengesHeading);
4741 const challengesHost = document.createElement("div");
4742 challengesSection.appendChild(challengesHost);
4743 detail.appendChild(challengesSection);
4744 detailTeardowns.push(renderChallengesView(challengesHost, game.id));
4745 };
4746 const select = (id) => {
4747 const game = get(id);
4748 if (!game) {
4749 return;
4750 }
4751 selectedId = id;
4752 for (const tile of Array.from(
4753 grid.querySelectorAll("[data-game-id]")
4754 )) {
4755 const isSelected = tile.getAttribute("data-game-id") === id;
4756 tile.classList.toggle(
4757 "desktop-mode-games__tile--selected",
4758 isSelected
4759 );
4760 tile.setAttribute("aria-selected", isSelected ? "true" : "false");
4761 }
4762 renderDetail(game);
4763 };
4764 const buildTile = (entry) => {
4765 const tile = document.createElement("button");
4766 tile.type = "button";
4767 tile.className = "desktop-mode-games__tile";
4768 tile.setAttribute("data-game-id", entry.id);
4769 tile.setAttribute("role", "option");
4770 tile.setAttribute("aria-selected", "false");
4771 const visual = document.createElement("span");
4772 visual.className = "desktop-mode-games__tile-visual";
4773 visual.appendChild(buildGameIcon(entry.icon));
4774 tile.appendChild(visual);
4775 const title = document.createElement("span");
4776 title.className = "desktop-mode-games__tile-title";
4777 title.textContent = entry.title;
4778 tile.appendChild(title);
4779 tile.addEventListener("click", () => select(entry.id));
4780 return tile;
4781 };
4782 const paintGrid = () => {
4783 grid.innerHTML = "";
4784 const games = all();
4785 if (games.length === 0) {
4786 const empty = document.createElement("wpd-empty-state");
4787 empty.setAttribute("icon", "games");
4788 empty.setAttribute("heading", __("No games installed"));
4789 empty.setAttribute(
4790 "description",
4791 __("Plugins can add games with desktop_mode_register_game().")
4792 );
4793 grid.appendChild(empty);
4794 disposeDetail();
4795 detail.hidden = true;
4796 detail.innerHTML = "";
4797 selectedId = null;
4798 return;
4799 }
4800 for (const entry of games) {
4801 grid.appendChild(buildTile(entry));
4802 }
4803 const keep = selectedId && games.some((game) => game.id === selectedId) ? selectedId : games[0].id;
4804 select(keep);
4805 };
4806 paintGrid();
4807 teardowns.push(subscribe(paintGrid));
4808 return () => {
4809 disposeDetail();
4810 for (const fn of teardowns) {
4811 try {
4812 fn();
4813 } catch {
4814 }
4815 }
4816 };
4817 }
4818 const registry = window.desktopModeNativeWindows ?? (window.desktopModeNativeWindows = {});
4819 registry["desktop-mode-games"] = renderGamesHub;
4820 })();
4821