PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.8
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 / comments-window.js

comments-window.js in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.8.8, at assets/js/comments-window.js

4,472 lines 156.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 (function() {
2 "use strict";
3 const TEXT_DOMAIN = "desktop-mode";
4 function i18n() {
5 return window.wp?.i18n;
6 }
7 function __(text, domain = TEXT_DOMAIN) {
8 return i18n()?.__(text, domain) ?? text;
9 }
10 function sprintf(format, ...args) {
11 const impl = i18n()?.sprintf;
12 if (impl) {
13 return impl(format, ...args);
14 }
15 let i = 0;
16 return format.replace(/%[sd]/g, () => String(args[i++] ?? ""));
17 }
18 const NONCE_HEADER = "X-WP-Nonce";
19 function injectRestNonce(input, init) {
20 const nonce = readRestNonce();
21 if (!nonce) {
22 return init;
23 }
24 const url = resolveUrl(input);
25 if (!url || !isSameOriginRestUrl(url)) {
26 return init;
27 }
28 const baseHeaders = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0);
29 const headers = new Headers(baseHeaders ?? {});
30 if (headers.has(NONCE_HEADER)) {
31 return init;
32 }
33 headers.set(NONCE_HEADER, nonce);
34 return { ...init ?? {}, headers };
35 }
36 function readRestNonce() {
37 if (typeof window === "undefined") {
38 return void 0;
39 }
40 const cfg = window.desktopModeConfig;
41 const value = cfg?.restNonce;
42 return typeof value === "string" && value.length > 0 ? value : void 0;
43 }
44 function resolveUrl(input) {
45 try {
46 const base = typeof window !== "undefined" && window.location ? window.location.href : void 0;
47 if (typeof input === "string") {
48 return new URL(input, base);
49 }
50 if (input instanceof URL) {
51 return input;
52 }
53 if (typeof Request !== "undefined" && input instanceof Request) {
54 return new URL(input.url, base);
55 }
56 return null;
57 } catch {
58 return null;
59 }
60 }
61 function isSameOriginRestUrl(url) {
62 if (typeof window === "undefined" || !window.location || url.origin !== window.location.origin) {
63 return false;
64 }
65 if (url.pathname.includes("/wp-json/")) {
66 return true;
67 }
68 if (url.searchParams.has("rest_route")) {
69 return true;
70 }
71 return false;
72 }
73 function trackedFetch(input, init, opts = {}) {
74 const fn = window.wp?.desktop?.fetch;
75 if (typeof fn === "function") {
76 return fn(input, init, opts);
77 }
78 const finalInit = injectRestNonce(input, init);
79 return fetch(input, finalInit);
80 }
81 const gravatarCache = /* @__PURE__ */ new Map();
82 async function resolveAvatarUrl(raw) {
83 if (!raw) {
84 return null;
85 }
86 let parsed;
87 try {
88 parsed = new URL(raw, window.location.href);
89 } catch {
90 return raw;
91 }
92 if (!/gravatar\.com$/i.test(parsed.hostname)) {
93 return raw;
94 }
95 parsed.searchParams.delete("d");
96 parsed.searchParams.delete("s");
97 const cacheKey = parsed.toString();
98 const cached = gravatarCache.get(cacheKey);
99 if (cached !== void 0) {
100 return cached instanceof Promise ? cached : cached;
101 }
102 const probeUrl = new URL(raw, window.location.href);
103 probeUrl.searchParams.set("d", "blank");
104 const probe = new Promise((resolve) => {
105 const img = new Image();
106 img.crossOrigin = "anonymous";
107 img.onload = () => {
108 try {
109 const canvas = document.createElement("canvas");
110 canvas.width = 1;
111 canvas.height = 1;
112 const ctx = canvas.getContext("2d", { willReadFrequently: true });
113 if (!ctx) {
114 resolve(raw);
115 return;
116 }
117 ctx.drawImage(img, 0, 0, 1, 1);
118 const pixel = ctx.getImageData(0, 0, 1, 1).data;
119 resolve(pixel[3] === 0 ? null : raw);
120 } catch {
121 resolve(raw);
122 }
123 };
124 img.onerror = () => resolve(null);
125 img.src = probeUrl.toString();
126 }).then((next) => {
127 gravatarCache.set(cacheKey, next);
128 return next;
129 });
130 gravatarCache.set(cacheKey, probe);
131 return probe;
132 }
133 function applyAvatarSrc(avatar, raw) {
134 if (!raw) {
135 return;
136 }
137 void resolveAvatarUrl(raw).then((url) => {
138 if (!avatar.isConnected) {
139 return;
140 }
141 if (url) {
142 avatar.setAttribute("src", url);
143 } else {
144 avatar.removeAttribute("src");
145 }
146 });
147 }
148 function html(strings, ...values) {
149 return { __wpdHtml: true, strings, values };
150 }
151 function isTemplateResult$1(v) {
152 return !!v && v.__wpdHtml === true;
153 }
154 const MARKER_PREFIX = "$$wpd$$";
155 const MARKER_RE = /\$\$wpd\$\$(\d+)\$\$/g;
156 function joinWithMarkers(strings) {
157 let out = strings[0];
158 for (let i = 1; i < strings.length; i++) {
159 out += `${MARKER_PREFIX}${i - 1}$$` + strings[i];
160 }
161 return out;
162 }
163 const compiledCache = /* @__PURE__ */ new WeakMap();
164 function compile(strings) {
165 const cached = compiledCache.get(strings);
166 if (cached) {
167 return cached;
168 }
169 const template = document.createElement("template");
170 template.innerHTML = joinWithMarkers(strings);
171 const recipes = [];
172 const walk = (node, path) => {
173 if (node.nodeType === Node.ELEMENT_NODE) {
174 const el = node;
175 for (const attr of Array.from(el.attributes)) {
176 const rawName = attr.name;
177 const rawValue = attr.value;
178 const prefix = rawName[0];
179 if (MARKER_RE.test(rawValue)) {
180 MARKER_RE.lastIndex = 0;
181 if (prefix === "@") {
182 const match = MARKER_RE.exec(rawValue);
183 MARKER_RE.lastIndex = 0;
184 recipes.push({
185 path,
186 kind: "event",
187 name: rawName.slice(1),
188 valueIndex: match ? Number(match[1]) : 0
189 });
190 el.removeAttribute(rawName);
191 } else if (prefix === ".") {
192 const match = MARKER_RE.exec(rawValue);
193 MARKER_RE.lastIndex = 0;
194 recipes.push({
195 path,
196 kind: "prop",
197 name: rawName.slice(1),
198 valueIndex: match ? Number(match[1]) : 0
199 });
200 el.removeAttribute(rawName);
201 } else if (prefix === "?") {
202 const match = MARKER_RE.exec(rawValue);
203 MARKER_RE.lastIndex = 0;
204 recipes.push({
205 path,
206 kind: "bool",
207 name: rawName.slice(1),
208 valueIndex: match ? Number(match[1]) : 0
209 });
210 el.removeAttribute(rawName);
211 } else {
212 const fragments = [];
213 const indices = [];
214 let lastEnd = 0;
215 let m;
216 MARKER_RE.lastIndex = 0;
217 while ((m = MARKER_RE.exec(rawValue)) !== null) {
218 fragments.push(rawValue.slice(lastEnd, m.index));
219 indices.push(Number(m[1]));
220 lastEnd = m.index + m[0].length;
221 }
222 fragments.push(rawValue.slice(lastEnd));
223 recipes.push({
224 path,
225 kind: "attr",
226 name: rawName,
227 template: fragments,
228 valueIndices: indices
229 });
230 el.setAttribute(rawName, "");
231 }
232 }
233 }
234 }
235 const children = Array.from(node.childNodes);
236 let shift = 0;
237 for (let i = 0; i < children.length; i++) {
238 const child = children[i];
239 const liveIndex = i + shift;
240 if (child.nodeType === Node.TEXT_NODE) {
241 const text = child.textContent || "";
242 if (!MARKER_RE.test(text)) {
243 MARKER_RE.lastIndex = 0;
244 continue;
245 }
246 MARKER_RE.lastIndex = 0;
247 const parent = child.parentNode;
248 let lastEnd = 0;
249 let m;
250 const newNodes = [];
251 const newRecipes = [];
252 MARKER_RE.lastIndex = 0;
253 while ((m = MARKER_RE.exec(text)) !== null) {
254 if (m.index > lastEnd) {
255 newNodes.push(document.createTextNode(text.slice(lastEnd, m.index)));
256 }
257 const placeholder = document.createTextNode("");
258 newNodes.push(placeholder);
259 newRecipes.push({
260 path: [...path, liveIndex + newNodes.length - 1],
261 kind: "node",
262 valueIndex: Number(m[1])
263 });
264 lastEnd = m.index + m[0].length;
265 }
266 if (lastEnd < text.length) {
267 newNodes.push(document.createTextNode(text.slice(lastEnd)));
268 }
269 for (const nn of newNodes) {
270 parent.insertBefore(nn, child);
271 }
272 parent.removeChild(child);
273 shift += newNodes.length - 1;
274 recipes.push(...newRecipes);
275 } else {
276 walk(child, [...path, liveIndex]);
277 }
278 }
279 };
280 walk(template.content, []);
281 const buildParts = (fragment) => {
282 const out = [];
283 for (const r of recipes) {
284 let node = fragment;
285 for (const idx of r.path) {
286 node = node.childNodes[idx];
287 }
288 if (r.kind === "node") {
289 out.push({
290 kind: "node",
291 valueIndex: r.valueIndex,
292 child: {
293 anchor: node,
294 state: null
295 }
296 });
297 } else if (r.kind === "attr") {
298 out.push({
299 kind: "attr",
300 element: node,
301 name: r.name,
302 template: r.template,
303 valueIndices: r.valueIndices
304 });
305 } else if (r.kind === "event") {
306 out.push({
307 kind: "event",
308 valueIndex: r.valueIndex,
309 element: node,
310 name: r.name
311 });
312 } else if (r.kind === "prop") {
313 out.push({
314 kind: "prop",
315 valueIndex: r.valueIndex,
316 element: node,
317 name: r.name
318 });
319 } else if (r.kind === "bool") {
320 out.push({
321 kind: "bool",
322 valueIndex: r.valueIndex,
323 element: node,
324 name: r.name
325 });
326 }
327 }
328 return out;
329 };
330 const entry = { template, buildParts };
331 compiledCache.set(strings, entry);
332 return entry;
333 }
334 const mountState = /* @__PURE__ */ new WeakMap();
335 function render(result, container) {
336 const existing = mountState.get(container);
337 if (existing && existing.strings === result.strings) {
338 applyValues(existing.parts, result.values);
339 return;
340 }
341 const compiled = compile(result.strings);
342 const fragment = compiled.template.content.cloneNode(true);
343 const parts = compiled.buildParts(fragment);
344 while (container.firstChild) {
345 container.removeChild(container.firstChild);
346 }
347 container.appendChild(fragment);
348 applyValues(parts, result.values);
349 mountState.set(container, { strings: result.strings, parts });
350 }
351 function applyValues(parts, values) {
352 for (const part of parts) {
353 if (part.kind === "node") {
354 updateChildPart(part.child, values[part.valueIndex]);
355 } else if (part.kind === "attr") {
356 let composed = part.template[0];
357 for (let i = 0; i < part.valueIndices.length; i++) {
358 composed += formatText(values[part.valueIndices[i]]);
359 composed += part.template[i + 1];
360 }
361 if (composed !== part.last) {
362 part.last = composed;
363 if (composed === "") {
364 part.element.removeAttribute(part.name);
365 } else {
366 part.element.setAttribute(part.name, composed);
367 }
368 }
369 } else if (part.kind === "event") {
370 const next = values[part.valueIndex];
371 if (next !== part.current) {
372 if (part.current) {
373 part.element.removeEventListener(part.name, part.current);
374 }
375 if (next) {
376 part.element.addEventListener(part.name, next);
377 }
378 part.current = next;
379 }
380 } else if (part.kind === "prop") {
381 const next = values[part.valueIndex];
382 if (next !== part.last) {
383 part.last = next;
384 part.element[part.name] = next;
385 }
386 } else if (part.kind === "bool") {
387 const next = !!values[part.valueIndex];
388 if (next !== part.last) {
389 part.last = next;
390 if (next) {
391 part.element.setAttribute(part.name, "");
392 } else {
393 part.element.removeAttribute(part.name);
394 }
395 }
396 }
397 }
398 }
399 function updateChildPart(child, value) {
400 if (value === null || value === void 0 || value === false) {
401 if (child.state) {
402 disposeChildState(child.state);
403 child.state = null;
404 }
405 return;
406 }
407 if (Array.isArray(value)) {
408 updateArrayChild(child, value);
409 return;
410 }
411 if (isTemplateResult$1(value)) {
412 updateTemplateChild(child, value);
413 return;
414 }
415 if (value instanceof Node) {
416 updateNodeChild(child, value);
417 return;
418 }
419 updateTextChild(child, formatText(value));
420 }
421 function updateNodeChild(child, node) {
422 const old = child.state;
423 if (old?.shape === "node" && old.node === node) {
424 return;
425 }
426 if (old) {
427 disposeChildState(old);
428 }
429 insertBeforeAnchor(child, [node]);
430 child.state = { shape: "node", node };
431 }
432 function updateTextChild(child, text) {
433 const old = child.state;
434 if (old?.shape === "text") {
435 if (old.text !== text) {
436 old.node.textContent = text;
437 old.text = text;
438 }
439 return;
440 }
441 if (old) {
442 disposeChildState(old);
443 }
444 const node = document.createTextNode(text);
445 insertBeforeAnchor(child, [node]);
446 child.state = { shape: "text", node, text };
447 }
448 function updateTemplateChild(child, result) {
449 const old = child.state;
450 if (old?.shape === "template" && old.strings === result.strings) {
451 applyValues(old.parts, result.values);
452 return;
453 }
454 if (old) {
455 disposeChildState(old);
456 }
457 const compiled = compile(result.strings);
458 const fragment = compiled.template.content.cloneNode(true);
459 const parts = compiled.buildParts(fragment);
460 const topNodes = Array.from(fragment.childNodes);
461 insertBeforeAnchor(child, [fragment]);
462 applyValues(parts, result.values);
463 child.state = {
464 shape: "template",
465 strings: result.strings,
466 parts,
467 nodes: topNodes
468 };
469 }
470 function updateArrayChild(child, arr) {
471 const old = child.state;
472 if (old?.shape === "array" && old.entries.length === arr.length) {
473 for (let i = 0; i < arr.length; i++) {
474 updateChildPart(old.entries[i], arr[i]);
475 }
476 return;
477 }
478 if (old) {
479 disposeChildState(old);
480 }
481 const entries = [];
482 for (const v of arr) {
483 const entryAnchor = document.createTextNode("");
484 insertBeforeAnchor(child, [entryAnchor]);
485 const entry = { anchor: entryAnchor, state: null };
486 updateChildPart(entry, v);
487 entries.push(entry);
488 }
489 child.state = { shape: "array", entries };
490 }
491 function insertBeforeAnchor(child, nodes) {
492 const parent = child.anchor.parentNode;
493 if (!parent) {
494 return;
495 }
496 for (const node of nodes) {
497 parent.insertBefore(node, child.anchor);
498 }
499 }
500 function disposeChildState(state) {
501 if (state.shape === "text") {
502 state.node.remove();
503 return;
504 }
505 if (state.shape === "template") {
506 for (const node of state.nodes) {
507 if (node.parentNode) {
508 node.parentNode.removeChild(node);
509 }
510 }
511 return;
512 }
513 if (state.shape === "node") {
514 if (state.node.parentNode) {
515 state.node.parentNode.removeChild(state.node);
516 }
517 return;
518 }
519 for (const entry of state.entries) {
520 if (entry.state) {
521 disposeChildState(entry.state);
522 }
523 entry.anchor.remove();
524 }
525 }
526 function formatText(v) {
527 if (v === null || v === void 0 || v === false) {
528 return "";
529 }
530 return String(v);
531 }
532 const _Component = class _Component extends HTMLElement {
533 constructor() {
534 super();
535 this._renderScheduled = false;
536 this._propValues = {};
537 const ctor = this.constructor;
538 if (ctor.shadow) {
539 this.attachShadow({ mode: "open" });
540 this._renderRoot = this.shadowRoot;
541 } else {
542 this._renderRoot = this;
543 }
544 this._installPropAccessors();
545 }
546 static get observedAttributes() {
547 return this.props.map(kebab);
548 }
549 connectedCallback() {
550 this._adoptStyles();
551 this.requestUpdate();
552 }
553 attributeChangedCallback(name, oldValue, newValue) {
554 if (oldValue === newValue) {
555 return;
556 }
557 const prop = camel(name);
558 this._propValues[prop] = newValue;
559 this.requestUpdate();
560 }
561 /**
562 * Declarative class-name setter. Assign an array (or a
563 * space-separated string) and the host's `class` attribute is
564 * rewritten to match. Intended for programmatic styling — when
565 * a plugin has enqueued its own stylesheet and wants to apply
566 * one of those classes to a shell component:
567 *
568 * ```js
569 * element.classNames = [ 'my-plugin-brand', 'is-active' ];
570 * // → <wpd-select class="my-plugin-brand is-active">
571 * ```
572 *
573 * The plain HTML `class="…"` attribute works just the same and
574 * is always preferred when writing markup by hand — this setter
575 * exists for the JS-API case where the caller has an array of
576 * conditional classes in hand.
577 *
578 * Getter returns the current `classList` as a plain array for
579 * symmetric read/write.
580 *
581 * @since 0.13.0
582 */
583 get classNames() {
584 return Array.from(this.classList);
585 }
586 set classNames(next) {
587 if (next === null || next === void 0) {
588 this.removeAttribute("class");
589 return;
590 }
591 const list = Array.isArray(next) ? next : String(next).split(/\s+/);
592 const cleaned = list.map((s) => String(s).trim()).filter((s) => s !== "");
593 this.className = cleaned.join(" ");
594 }
595 /**
596 * Request a re-render explicitly. Components rarely need this —
597 * declare state via props + attribute observers and the render
598 * loop picks up changes automatically.
599 */
600 requestUpdate() {
601 this._scheduleRender();
602 }
603 /**
604 * Dispatch a `CustomEvent` with a `detail`. Bubbles + composed
605 * by default (matches typical WC UX — events cross shadow
606 * boundaries, parents can listen without knowing about internal
607 * structure).
608 */
609 emit(name, detail) {
610 return this.dispatchEvent(
611 new CustomEvent(name, {
612 detail,
613 bubbles: true,
614 composed: true
615 })
616 );
617 }
618 // ------------------------------------------------------------------
619 // Internals
620 // ------------------------------------------------------------------
621 /**
622 * Wire every `static props` entry to a matched property getter +
623 * setter on the element. Setting the property reflects into the
624 * attribute (so downstream observers + CSS selectors see it);
625 * reading the property falls back to the attribute.
626 */
627 _installPropAccessors() {
628 const ctor = this.constructor;
629 for (const prop of ctor.props) {
630 if (Object.getOwnPropertyDescriptor(this, prop)) {
631 continue;
632 }
633 const attr = kebab(prop);
634 Object.defineProperty(this, prop, {
635 get: () => {
636 if (prop in this._propValues) {
637 return this._propValues[prop];
638 }
639 return this.getAttribute(attr);
640 },
641 set: (value) => {
642 let str;
643 if (value === null || value === void 0 || value === false) {
644 str = null;
645 } else if (value === true) {
646 str = "";
647 } else {
648 str = String(value);
649 }
650 this._propValues[prop] = str;
651 if (str === null) {
652 this.removeAttribute(attr);
653 } else {
654 this.setAttribute(attr, str);
655 }
656 this.requestUpdate();
657 },
658 enumerable: true,
659 configurable: true
660 });
661 }
662 }
663 /**
664 * Schedule a render on the next microtask. Multiple property
665 * assignments in the same tick collapse into a single render.
666 */
667 _scheduleRender() {
668 if (this._renderScheduled || !this.isConnected) {
669 return;
670 }
671 this._renderScheduled = true;
672 queueMicrotask(() => {
673 this._renderScheduled = false;
674 if (!this.isConnected) {
675 return;
676 }
677 render(this.render(), this._renderRoot);
678 });
679 }
680 /**
681 * Mount adoptable stylesheets onto the shadow root (via
682 * `adoptedStyleSheets`) or the light DOM (via one `<style>`
683 * tag per def). No-op if `static styles` is empty.
684 */
685 _adoptStyles() {
686 const ctor = this.constructor;
687 if (ctor.styles.length === 0) {
688 return;
689 }
690 if (ctor.shadow && this.shadowRoot) {
691 const sheets = ctor.styles.map((s) => s.sheet).filter((s) => s !== null);
692 this.shadowRoot.adoptedStyleSheets = sheets;
693 if (sheets.length !== ctor.styles.length) {
694 for (const s of ctor.styles) {
695 if (!s.sheet) {
696 const tag = document.createElement("style");
697 tag.textContent = s.cssText;
698 this.shadowRoot.appendChild(tag);
699 }
700 }
701 }
702 } else {
703 this._adoptLightStyles(ctor);
704 }
705 }
706 _adoptLightStyles(ctor) {
707 if (_Component._lightStylesAdopted.has(ctor)) {
708 return;
709 }
710 _Component._lightStylesAdopted.add(ctor);
711 for (const s of ctor.styles) {
712 const tag = document.createElement("style");
713 tag.dataset.wpdUi = this.tagName.toLowerCase();
714 tag.textContent = s.cssText;
715 document.head.appendChild(tag);
716 }
717 }
718 };
719 _Component.props = [];
720 _Component.styles = [];
721 _Component.shadow = true;
722 _Component._lightStylesAdopted = /* @__PURE__ */ new WeakSet();
723 let Component = _Component;
724 function defineComponent(tag, ctor) {
725 if (customElements.get(tag)) {
726 return;
727 }
728 customElements.define(tag, ctor);
729 }
730 function kebab(s) {
731 return s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
732 }
733 function camel(s) {
734 return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
735 }
736 const SUPPORTS_CONSTRUCTABLE_SHEETS = (() => {
737 try {
738 const s = new CSSStyleSheet();
739 return typeof s.replaceSync === "function";
740 } catch {
741 return false;
742 }
743 })();
744 function css(strings, ...values) {
745 let text = strings[0];
746 for (let i = 1; i < strings.length; i++) {
747 const v = values[i - 1];
748 if (typeof v === "string" || typeof v === "number") {
749 text += String(v);
750 } else if (v && v.__wpdCss) {
751 text += v.cssText;
752 } else {
753 throw new TypeError(
754 "[wpd-ui] css`` interpolations must be strings, numbers, or other css`` results. Got: " + typeof v
755 );
756 }
757 text += strings[i];
758 }
759 if (SUPPORTS_CONSTRUCTABLE_SHEETS) {
760 const sheet = new CSSStyleSheet();
761 sheet.replaceSync(text);
762 return { __wpdCss: true, sheet, cssText: text };
763 }
764 return { __wpdCss: true, sheet: null, cssText: text };
765 }
766 const styles$2 = 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}}`;
767 const EXPANDER_KEY = "__wpd_expander__";
768 const SELECT_KEY = "__wpd_select__";
769 const _WpdTable = class _WpdTable extends Component {
770 constructor() {
771 super(...arguments);
772 this._data = [];
773 this._columns = [];
774 this._filters = {};
775 this._expanded = /* @__PURE__ */ new Set();
776 this._subTable = null;
777 this._sort = null;
778 this._selection = /* @__PURE__ */ new Set();
779 this._getRowId = (_row, index) => index;
780 this._filterCache = /* @__PURE__ */ new Map();
781 this._paintScheduled = false;
782 this._stickyHeaderWarned = false;
783 this._stickyRaceWarned = false;
784 this._resizeObserver = null;
785 this._stickyMicroScheduled = false;
786 this._stickyRafHandle = null;
787 this._loadingDesyncWarned = false;
788 this._lastStickyIndex = -1;
789 }
790 // ------------------------------------------------------------------
791 // Public properties — set from JS (use `.data=${...}` in templates).
792 // ------------------------------------------------------------------
793 /** The row buffer. Reassigning replaces (and clears expansion state). */
794 get data() {
795 return this._data;
796 }
797 set data(next) {
798 this._data = Array.isArray(next) ? next.slice() : [];
799 this._expanded.clear();
800 this._schedulePaint();
801 }
802 /** Column descriptors. See {@link WpdTableColumn}. */
803 get columns() {
804 return this._columns;
805 }
806 set columns(next) {
807 this._columns = Array.isArray(next) ? next.slice() : [];
808 const keys = new Set(this._columns.map((c) => c.key));
809 for (const k of Object.keys(this._filters)) {
810 if (!keys.has(k)) {
811 delete this._filters[k];
812 }
813 }
814 for (const k of Array.from(this._filterCache.keys())) {
815 if (!keys.has(k)) {
816 this._filterCache.delete(k);
817 }
818 }
819 if (this._sort && !keys.has(this._sort.key)) {
820 this._sort = null;
821 }
822 this._schedulePaint();
823 }
824 /** Read or replace the current filter map. */
825 get filters() {
826 return { ...this._filters };
827 }
828 set filters(next) {
829 this._filters = next ? { ...next } : {};
830 this._schedulePaint();
831 }
832 /** Read or set the active sort. `null` clears it. */
833 get sort() {
834 return this._sort ? { ...this._sort } : null;
835 }
836 set sort(next) {
837 this._sort = next ? { ...next } : null;
838 this._schedulePaint();
839 }
840 /** Read or replace the selection (set of row ids). */
841 get selection() {
842 return new Set(this._selection);
843 }
844 set selection(next) {
845 this._selection = new Set(next ?? []);
846 this._schedulePaint();
847 }
848 /** The currently-selected rows (resolved from `selection` + `data`). */
849 get selectedRows() {
850 const out = [];
851 this._data.forEach((row, i) => {
852 if (this._selection.has(this._getRowId(row, i))) {
853 out.push(row);
854 }
855 });
856 return out;
857 }
858 /** Stable row-id extractor. Default is row index. */
859 get getRowId() {
860 return this._getRowId;
861 }
862 set getRowId(fn) {
863 this._getRowId = typeof fn === "function" ? fn : (_r, i) => i;
864 this._schedulePaint();
865 }
866 /**
867 * Sub-table accessor. Return `null` (or omit) for rows with no
868 * children. Return `{ columns, data }` to render a nested
869 * `<wpd-table>` inline; or return any `Node` / `html\`\`` template
870 * for fully custom expanded content.
871 */
872 get subTable() {
873 return this._subTable;
874 }
875 set subTable(fn) {
876 this._subTable = typeof fn === "function" ? fn : null;
877 this._expanded.clear();
878 this._schedulePaint();
879 }
880 /** Read or replace the expansion set (row indices that are open). */
881 get expanded() {
882 return new Set(this._expanded);
883 }
884 set expanded(next) {
885 this._expanded = new Set(next ?? []);
886 this._schedulePaint();
887 }
888 // ------------------------------------------------------------------
889 // Programmatic methods
890 // ------------------------------------------------------------------
891 /** Open a row's sub-table by index. No-op if the index is out of range. */
892 expand(index) {
893 if (index < 0 || index >= this._data.length) {
894 return;
895 }
896 if (this._expanded.has(index)) {
897 return;
898 }
899 this._expanded.add(index);
900 this.emit("wpd-table-expand-change", {
901 row: this._data[index],
902 index,
903 expanded: true
904 });
905 this._schedulePaint();
906 }
907 /** Close a row's sub-table by index. No-op if it wasn't open. */
908 collapse(index) {
909 if (!this._expanded.has(index)) {
910 return;
911 }
912 this._expanded.delete(index);
913 this.emit("wpd-table-expand-change", {
914 row: this._data[index],
915 index,
916 expanded: false
917 });
918 this._schedulePaint();
919 }
920 /** Open every row that has children. */
921 expandAll() {
922 if (!this._subTable) {
923 return;
924 }
925 let changed = false;
926 for (let i = 0; i < this._data.length; i++) {
927 if (!this._subTable(this._data[i], i)) {
928 continue;
929 }
930 if (!this._expanded.has(i)) {
931 this._expanded.add(i);
932 changed = true;
933 }
934 }
935 if (changed) {
936 this._schedulePaint();
937 }
938 }
939 /** Close every open row. */
940 collapseAll() {
941 if (this._expanded.size === 0) {
942 return;
943 }
944 this._expanded.clear();
945 this._schedulePaint();
946 }
947 isExpanded(index) {
948 return this._expanded.has(index);
949 }
950 /** Drop every active filter and emit `wpd-table-filter-change`. */
951 clearFilters() {
952 if (Object.keys(this._filters).length === 0) {
953 return;
954 }
955 this._filters = {};
956 this.emit("wpd-table-filter-change", { filters: {} });
957 this._schedulePaint();
958 }
959 /** Drop the active sort and emit `wpd-table-sort-change`. */
960 clearSort() {
961 if (this._sort === null) {
962 return;
963 }
964 this._sort = null;
965 this.emit("wpd-table-sort-change", { sort: null });
966 this._schedulePaint();
967 }
968 /**
969 * Add a row id to the selection. Emits `wpd-table-selection-change`.
970 *
971 * Selection mutators (`select` / `deselect` / `selectAll` /
972 * `clearSelection`) update the affected row in place via
973 * {@link _syncSelectionDom} rather than re-rendering the whole
974 * tbody — a rebuild would tear down the focused checkbox and
975 * (because scroll-anchoring abandons a momentarily empty container)
976 * could snap scroll back to the top.
977 */
978 select(id) {
979 if (this._selection.has(id)) {
980 return;
981 }
982 const mode = this._readSelectable();
983 const previouslySelected = mode === "single" ? Array.from(this._selection) : [];
984 if (mode === "single") {
985 this._selection.clear();
986 }
987 this._selection.add(id);
988 this._emitSelectionChange();
989 this._syncSelectionDom([id, ...previouslySelected]);
990 }
991 /** Remove a row id from the selection. */
992 deselect(id) {
993 if (!this._selection.delete(id)) {
994 return;
995 }
996 this._emitSelectionChange();
997 this._syncSelectionDom([id]);
998 }
999 /** Select every row currently in `data` (multi-mode only). */
1000 selectAll() {
1001 if (this._readSelectable() !== "multi") {
1002 return;
1003 }
1004 this._data.forEach(
1005 (row, i) => this._selection.add(this._getRowId(row, i))
1006 );
1007 this._emitSelectionChange();
1008 this._syncSelectionDom("all");
1009 }
1010 /** Empty the selection. */
1011 clearSelection() {
1012 if (this._selection.size === 0) {
1013 return;
1014 }
1015 this._selection.clear();
1016 this._emitSelectionChange();
1017 this._syncSelectionDom("all");
1018 }
1019 /**
1020 * Apply a selection change to the existing tbody DOM without
1021 * rebuilding it. Updates each affected row's `is-selected` class
1022 * and `select-row-checkbox` `checked` state, then re-syncs the
1023 * header select-all checkbox (checked / indeterminate / empty).
1024 *
1025 * @param ids `'all'` to walk every row, or an iterable of row ids
1026 * whose rows need updating. Unknown ids are silently
1027 * skipped (row may not be in the current filter/page).
1028 */
1029 _syncSelectionDom(ids) {
1030 const root = this.shadowRoot;
1031 if (!root) {
1032 return;
1033 }
1034 const tbody = root.querySelector("tbody");
1035 if (!tbody) {
1036 return;
1037 }
1038 let needle = null;
1039 if (ids !== "all") {
1040 needle = /* @__PURE__ */ new Set();
1041 for (const id of ids) {
1042 needle.add(String(id));
1043 }
1044 }
1045 const rows = tbody.querySelectorAll(
1046 "tr[data-row-id]"
1047 );
1048 for (const tr of rows) {
1049 const rowIdStr = tr.dataset.rowId;
1050 if (rowIdStr === void 0) {
1051 continue;
1052 }
1053 if (needle && !needle.has(rowIdStr)) {
1054 continue;
1055 }
1056 const idx = Number(tr.dataset.rowIndex);
1057 if (!Number.isFinite(idx)) {
1058 continue;
1059 }
1060 const row = this._data[idx];
1061 if (row === void 0) {
1062 continue;
1063 }
1064 const id = this._getRowId(row, idx);
1065 const isSelected = this._selection.has(id);
1066 tr.classList.toggle("is-selected", isSelected);
1067 const cb = tr.querySelector(
1068 "input.select-row-checkbox"
1069 );
1070 if (cb && cb.checked !== isSelected) {
1071 cb.checked = isSelected;
1072 }
1073 }
1074 const headerCb = root.querySelector(
1075 "thead .select-all-checkbox"
1076 );
1077 if (headerCb) {
1078 const total = this._data.length;
1079 const selectedCount = this._countSelectedInData();
1080 headerCb.checked = total > 0 && selectedCount === total;
1081 headerCb.indeterminate = selectedCount > 0 && selectedCount < total;
1082 }
1083 }
1084 /** Scroll the (filtered) row at `index` into view inside the table's scroll container. */
1085 scrollToRow(index) {
1086 const root = this.shadowRoot;
1087 if (!root) {
1088 return;
1089 }
1090 const rows = root.querySelectorAll(
1091 "tbody tr:not(.subtable):not(.empty):not(.skeleton)"
1092 );
1093 const row = rows[index];
1094 if (row) {
1095 row.scrollIntoView({ block: "nearest", inline: "nearest" });
1096 }
1097 }
1098 connectedCallback() {
1099 super.connectedCallback();
1100 this._schedulePaint();
1101 }
1102 disconnectedCallback() {
1103 this._resizeObserver?.disconnect();
1104 this._resizeObserver = null;
1105 if (this._stickyRafHandle !== null && typeof cancelAnimationFrame !== "undefined") {
1106 cancelAnimationFrame(this._stickyRafHandle);
1107 this._stickyRafHandle = null;
1108 }
1109 }
1110 /**
1111 * Force a sticky-offsets recompute. Public escape hatch for the
1112 * rare case where layout settles after every internal hook has
1113 * fired — e.g. an out-of-band font swap or a JS-driven width
1114 * change on an ancestor that doesn't bubble through ResizeObserver.
1115 *
1116 * Usually you don't need this: the component schedules recomputes
1117 * on a microtask + animation frame after every paint, and a
1118 * ResizeObserver on the inner scroll element catches geometry
1119 * changes thereafter. Reach for `recomputeLayout()` only if you've
1120 * confirmed that all of those pathways missed your case.
1121 */
1122 recomputeLayout() {
1123 this._applyStickyOffsets();
1124 this._measureHeaderHeight();
1125 }
1126 // ------------------------------------------------------------------
1127 // Skeleton + paint pipeline
1128 // ------------------------------------------------------------------
1129 render() {
1130 return html`
1131 <div class="scroll" part="scroll">
1132 <table part="table">
1133 <colgroup></colgroup>
1134 <thead></thead>
1135 <tbody></tbody>
1136 </table>
1137 </div>
1138 `;
1139 }
1140 requestUpdate() {
1141 super.requestUpdate();
1142 this._schedulePaint();
1143 }
1144 _schedulePaint() {
1145 if (this._paintScheduled || !this.isConnected) {
1146 return;
1147 }
1148 this._paintScheduled = true;
1149 queueMicrotask(() => {
1150 this._paintScheduled = false;
1151 if (!this.isConnected) {
1152 return;
1153 }
1154 this._paint();
1155 });
1156 }
1157 _paint() {
1158 const root = this.shadowRoot;
1159 if (!root) {
1160 return;
1161 }
1162 if (!root.querySelector("tbody")) {
1163 render(this.render(), root);
1164 }
1165 const colgroup = root.querySelector("colgroup");
1166 const thead = root.querySelector("thead");
1167 const tbody = root.querySelector("tbody");
1168 if (!colgroup || !thead || !tbody) {
1169 return;
1170 }
1171 const cols = this._effectiveColumns();
1172 const stickyN = this._readStickyColumns();
1173 this._lastStickyIndex = this._computeLastStickyIndex(cols, stickyN);
1174 this._paintColgroup(colgroup, cols);
1175 this._paintHead(thead, cols, stickyN);
1176 this._paintBody(tbody, cols, stickyN);
1177 this._applyStickyOffsets();
1178 this._measureHeaderHeight();
1179 this._scheduleStickyOffsets();
1180 this._maybeWarnStickyHeader();
1181 this._maybeWarnLoadingDesync(tbody);
1182 this._ensureResizeObserver();
1183 }
1184 /**
1185 * Diagnostic for the "I set `loading` but the skeleton never
1186 * appeared" footgun. If we get here with the attribute on but no
1187 * `.skeleton` rows in `tbody`, something between attribute set and
1188 * paint went off the rails — historically this happened when the
1189 * base `Component.attributeChangedCallback` called `_scheduleRender`
1190 * directly, bypassing our `requestUpdate` override. Same pattern as
1191 * the sticky-columns 0px tripwire: should never fire, but if it
1192 * does, names the bug instead of leaving the dev guessing.
1193 */
1194 _maybeWarnLoadingDesync(tbody) {
1195 if (this._loadingDesyncWarned) {
1196 return;
1197 }
1198 if (!this.hasAttribute("loading")) {
1199 return;
1200 }
1201 if (tbody.querySelector("tr.skeleton")) {
1202 return;
1203 }
1204 this._loadingDesyncWarned = true;
1205 console.warn(
1206 "[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."
1207 );
1208 }
1209 /**
1210 * Belt-and-braces sticky-offset scheduling.
1211 *
1212 * - Microtask: cheap, fires after the current task drains. Fixes
1213 * mounts where the synchronous read in `_paint` happened before
1214 * a sibling style applied.
1215 * - rAF: fires before the next paint. Catches "layout settles
1216 * after a queued style mutation" races — the most common cause
1217 * of "col 1 ended up at inset-inline-start: 0px".
1218 *
1219 * Both reduce to a no-op when nothing changed. The cost is two
1220 * extra DOM reads per paint; the win is the bug class disappears.
1221 */
1222 _scheduleStickyOffsets() {
1223 if (!this._stickyMicroScheduled) {
1224 this._stickyMicroScheduled = true;
1225 queueMicrotask(() => {
1226 this._stickyMicroScheduled = false;
1227 if (this.isConnected) {
1228 this._applyStickyOffsets();
1229 }
1230 });
1231 }
1232 if (this._stickyRafHandle === null && typeof requestAnimationFrame !== "undefined") {
1233 this._stickyRafHandle = requestAnimationFrame(() => {
1234 this._stickyRafHandle = null;
1235 if (this.isConnected) {
1236 this._applyStickyOffsets();
1237 this._measureHeaderHeight();
1238 }
1239 });
1240 }
1241 }
1242 /**
1243 * Wire a `ResizeObserver` on the inner `.scroll` element (NOT the
1244 * host). Why: the host's outer width is often pinned by its parent
1245 * panel — a vertical scrollbar appearing inside the table changes
1246 * the inner scroll-area width by ~15px without changing the host
1247 * size. Observing the host would miss that reflow and leave sticky
1248 * offsets stale.
1249 *
1250 * Idempotent — runs once after the first paint produces a real
1251 * `.scroll` element. Disconnect happens in `disconnectedCallback`.
1252 */
1253 _ensureResizeObserver() {
1254 if (this._resizeObserver) {
1255 return;
1256 }
1257 if (typeof ResizeObserver === "undefined") {
1258 return;
1259 }
1260 const scroll = this.shadowRoot?.querySelector(
1261 ".scroll"
1262 );
1263 if (!scroll) {
1264 return;
1265 }
1266 this._resizeObserver = new ResizeObserver(() => {
1267 if (!this.isConnected) {
1268 return;
1269 }
1270 this._applyStickyOffsets();
1271 this._measureHeaderHeight();
1272 this._stickyHeaderWarned = false;
1273 this._maybeWarnStickyHeader();
1274 });
1275 this._resizeObserver.observe(scroll);
1276 this._resizeObserver.observe(this);
1277 }
1278 _paintColgroup(colgroup, cols) {
1279 const out = [];
1280 for (const c of cols) {
1281 const col = document.createElement("col");
1282 if (c.width) {
1283 col.style.width = c.width;
1284 }
1285 out.push(col);
1286 }
1287 colgroup.replaceChildren(...out);
1288 }
1289 _paintHead(thead, cols, stickyN) {
1290 const newHeaderRow = document.createElement("tr");
1291 newHeaderRow.setAttribute("part", "header-row");
1292 for (let i = 0; i < cols.length; i++) {
1293 newHeaderRow.appendChild(this._buildHeaderCell(cols[i], i, stickyN));
1294 }
1295 const existingHeader = thead.querySelector(
1296 ':scope > tr[part="header-row"]'
1297 );
1298 if (existingHeader) {
1299 thead.replaceChild(newHeaderRow, existingHeader);
1300 } else {
1301 thead.insertBefore(newHeaderRow, thead.firstChild);
1302 }
1303 const hasFilter = cols.some(
1304 (c) => c.filter || Array.isArray(c.filterOptions) || typeof c.filterRender === "function"
1305 );
1306 let existingFilter = thead.querySelector(
1307 ":scope > tr.filter-row"
1308 );
1309 if (hasFilter) {
1310 const cells = [];
1311 for (let i = 0; i < cols.length; i++) {
1312 cells.push(this._buildFilterCell(cols[i], i, stickyN));
1313 }
1314 if (!existingFilter) {
1315 existingFilter = document.createElement("tr");
1316 existingFilter.classList.add("filter-row");
1317 existingFilter.setAttribute("part", "filter-row");
1318 thead.appendChild(existingFilter);
1319 }
1320 const current = Array.from(existingFilter.children);
1321 let same = current.length === cells.length;
1322 if (same) {
1323 for (let i = 0; i < cells.length; i++) {
1324 if (current[i] !== cells[i]) {
1325 same = false;
1326 break;
1327 }
1328 }
1329 }
1330 if (!same) {
1331 const wanted = new Set(cells);
1332 for (const cell of cells) {
1333 existingFilter.appendChild(cell);
1334 }
1335 for (const child of Array.from(existingFilter.children)) {
1336 if (!wanted.has(child)) {
1337 existingFilter.removeChild(child);
1338 }
1339 }
1340 }
1341 } else if (existingFilter) {
1342 existingFilter.remove();
1343 }
1344 }
1345 _buildHeaderCell(col, index, stickyN) {
1346 const th = document.createElement("th");
1347 th.setAttribute("scope", "col");
1348 th.dataset.key = col.key;
1349 this._applyCellClasses(th, col, index, stickyN);
1350 if (col.minWidth) {
1351 th.style.minWidth = col.minWidth;
1352 }
1353 if (col.key === SELECT_KEY) {
1354 const mode = this._readSelectable();
1355 if (mode === "multi") {
1356 const cb = document.createElement("input");
1357 cb.type = "checkbox";
1358 cb.className = "select-all-checkbox";
1359 cb.setAttribute("data-noclick", "");
1360 cb.setAttribute("aria-label", "Select all rows");
1361 const total = this._data.length;
1362 const selectedCount = this._countSelectedInData();
1363 cb.checked = total > 0 && selectedCount === total;
1364 cb.indeterminate = selectedCount > 0 && selectedCount < total;
1365 cb.addEventListener("change", () => {
1366 if (cb.checked) {
1367 this.selectAll();
1368 } else {
1369 this.clearSelection();
1370 }
1371 });
1372 th.appendChild(cb);
1373 }
1374 return th;
1375 }
1376 th.textContent = col.label ?? (col.key === EXPANDER_KEY ? "" : col.key);
1377 if (col.sortable) {
1378 th.classList.add("is-sortable");
1379 const isActive = this._sort?.key === col.key;
1380 const indicator = document.createElement("span");
1381 indicator.className = "sort-indicator";
1382 let arrow = "";
1383 if (isActive) {
1384 arrow = this._sort.direction === "asc" ? " â–²" : " â–¼";
1385 }
1386 indicator.textContent = arrow;
1387 th.appendChild(indicator);
1388 if (isActive) {
1389 th.classList.add(
1390 this._sort.direction === "asc" ? "sort-asc" : "sort-desc"
1391 );
1392 }
1393 th.addEventListener("click", () => this._cycleSort(col.key));
1394 }
1395 return th;
1396 }
1397 _buildFilterCell(col, index, stickyN) {
1398 const cached = this._filterCache.get(col.key);
1399 const hasExplicitOptions = Array.isArray(col.filterOptions);
1400 const hasCustomRender = typeof col.filterRender === "function";
1401 let desiredKind;
1402 if (!col.filter && !hasExplicitOptions && !hasCustomRender || col.key === EXPANDER_KEY || col.key === SELECT_KEY) {
1403 desiredKind = "none";
1404 } else if (hasCustomRender) {
1405 desiredKind = "custom";
1406 } else if (col.filter === "select" || hasExplicitOptions) {
1407 desiredKind = "select";
1408 } else {
1409 desiredKind = "text";
1410 }
1411 if (cached && cached.kind === desiredKind) {
1412 cached.th.className = "";
1413 this._applyCellClasses(cached.th, col, index, stickyN);
1414 if (desiredKind === "select") {
1415 const select = cached.control;
1416 const opts = this._resolveFilterOptions(col);
1417 const optsKey = opts.map((o) => o.value).join("|");
1418 if (optsKey !== cached.optionsKey) {
1419 this._populateSelect(select, opts, this._filters[col.key] ?? "");
1420 cached.optionsKey = optsKey;
1421 } else {
1422 select.value = this._filters[col.key] ?? "";
1423 }
1424 } else if (desiredKind === "text") {
1425 const input = cached.control;
1426 const want = this._filters[col.key] ?? "";
1427 if (input.value !== want && input.ownerDocument.activeElement !== input) {
1428 input.value = want;
1429 }
1430 } else if (desiredKind === "custom" && col.filterRender) {
1431 col.filterRender(cached.th, {
1432 value: this._filters[col.key] ?? "",
1433 setValue: (next) => this._onFilterChange(col.key, next),
1434 col
1435 });
1436 }
1437 return cached.th;
1438 }
1439 const th = document.createElement("th");
1440 this._applyCellClasses(th, col, index, stickyN);
1441 if (desiredKind === "none") {
1442 this._filterCache.set(col.key, {
1443 th,
1444 control: null,
1445 optionsKey: "",
1446 kind: "none"
1447 });
1448 return th;
1449 }
1450 if (desiredKind === "custom" && col.filterRender) {
1451 col.filterRender(th, {
1452 value: this._filters[col.key] ?? "",
1453 setValue: (next) => this._onFilterChange(col.key, next),
1454 col
1455 });
1456 this._filterCache.set(col.key, {
1457 th,
1458 control: null,
1459 optionsKey: "",
1460 kind: "custom"
1461 });
1462 return th;
1463 }
1464 let control;
1465 let optionsKey = "";
1466 if (desiredKind === "select") {
1467 const select = document.createElement("select");
1468 select.classList.add("filter-select");
1469 select.setAttribute("data-noclick", "");
1470 select.setAttribute(
1471 "aria-label",
1472 `Filter ${col.label ?? col.key}`
1473 );
1474 const opts = this._resolveFilterOptions(col);
1475 this._populateSelect(select, opts, this._filters[col.key] ?? "");
1476 optionsKey = opts.map((o) => o.value).join("|");
1477 select.addEventListener("change", () => {
1478 this._onFilterChange(col.key, select.value);
1479 });
1480 control = select;
1481 } else {
1482 const input = document.createElement("input");
1483 input.type = "search";
1484 input.classList.add("filter-input");
1485 input.setAttribute("data-noclick", "");
1486 input.setAttribute("placeholder", "Filter…");
1487 input.setAttribute("aria-label", `Filter ${col.label ?? col.key}`);
1488 input.value = this._filters[col.key] ?? "";
1489 input.addEventListener("input", () => {
1490 this._onFilterChange(col.key, input.value);
1491 });
1492 control = input;
1493 }
1494 th.appendChild(control);
1495 this._filterCache.set(col.key, {
1496 th,
1497 control,
1498 optionsKey,
1499 kind: desiredKind
1500 });
1501 return th;
1502 }
1503 _populateSelect(select, options, current) {
1504 select.replaceChildren();
1505 const all = document.createElement("option");
1506 all.value = "";
1507 all.textContent = "All";
1508 select.appendChild(all);
1509 for (const opt of options) {
1510 const el = document.createElement("option");
1511 el.value = opt.value;
1512 el.textContent = opt.label;
1513 if (opt.value === current) {
1514 el.selected = true;
1515 }
1516 select.appendChild(el);
1517 }
1518 select.value = current;
1519 }
1520 /**
1521 * Resolve the option list for a select-filter column. Explicit
1522 * `filterOptions` win — that's the contract for server-driven
1523 * tables that need the dropdown to list values not present on
1524 * the current page. Without `filterOptions`, fall back to the
1525 * unique row values in the column (legacy behaviour for
1526 * client-side tables).
1527 */
1528 _resolveFilterOptions(col) {
1529 if (Array.isArray(col.filterOptions)) {
1530 return col.filterOptions;
1531 }
1532 return this._uniqueValues(col.key).map((v) => ({
1533 value: v,
1534 label: v
1535 }));
1536 }
1537 // ------------------------------------------------------------------
1538 // Body
1539 // ------------------------------------------------------------------
1540 _paintBody(tbody, cols, stickyN) {
1541 tbody.replaceChildren();
1542 if (this.hasAttribute("loading")) {
1543 const count = this._readLoadingRows();
1544 for (let i = 0; i < count; i++) {
1545 tbody.appendChild(this._buildSkeletonRow(cols, i));
1546 }
1547 return;
1548 }
1549 const filtered = this._sortedRows(this._filteredRows());
1550 if (filtered.length === 0) {
1551 tbody.appendChild(this._buildEmptyRow(cols.length));
1552 return;
1553 }
1554 for (const { row, index } of filtered) {
1555 tbody.appendChild(this._buildBodyRow(row, index, cols, stickyN));
1556 if (this._expanded.has(index) && this._subTable) {
1557 const sub = this._subTable(row, index);
1558 if (sub) {
1559 tbody.appendChild(this._buildSubTableRow(sub, cols.length));
1560 }
1561 }
1562 }
1563 }
1564 _buildEmptyRow(colspan) {
1565 const tr = document.createElement("tr");
1566 tr.classList.add("empty");
1567 const td = document.createElement("td");
1568 td.colSpan = colspan;
1569 const slot = document.createElement("slot");
1570 slot.name = "empty";
1571 slot.textContent = this.getAttribute("empty") || "No data";
1572 td.appendChild(slot);
1573 tr.appendChild(td);
1574 return tr;
1575 }
1576 _buildSkeletonRow(cols, seed) {
1577 const tr = document.createElement("tr");
1578 tr.classList.add("skeleton");
1579 tr.setAttribute("aria-hidden", "true");
1580 for (const _c of cols) {
1581 const td = document.createElement("td");
1582 const bar = document.createElement("span");
1583 bar.className = "skeleton-bar";
1584 const widthPct = 50 + (seed * 7 + tr.children.length * 13) % 40;
1585 bar.style.width = `${widthPct}%`;
1586 td.appendChild(bar);
1587 tr.appendChild(td);
1588 }
1589 return tr;
1590 }
1591 _buildBodyRow(row, rowIndex, cols, stickyN) {
1592 const tr = document.createElement("tr");
1593 tr.setAttribute("part", "row");
1594 tr.dataset.rowIndex = String(rowIndex);
1595 const id = this._getRowId(row, rowIndex);
1596 tr.dataset.rowId = String(id);
1597 if (this._selection.has(id)) {
1598 tr.classList.add("is-selected");
1599 }
1600 tr.addEventListener("click", (e) => {
1601 this._onRowClick(row, rowIndex, e);
1602 });
1603 for (let i = 0; i < cols.length; i++) {
1604 tr.appendChild(
1605 this._buildBodyCell(cols[i], i, row, rowIndex, stickyN)
1606 );
1607 }
1608 return tr;
1609 }
1610 _buildBodyCell(col, colIndex, row, rowIndex, stickyN) {
1611 const td = document.createElement("td");
1612 this._applyCellClasses(td, col, colIndex, stickyN);
1613 if (col.minWidth) {
1614 td.style.minWidth = col.minWidth;
1615 }
1616 if (col.key === SELECT_KEY) {
1617 const id = this._getRowId(row, rowIndex);
1618 const cb = document.createElement("input");
1619 cb.type = "checkbox";
1620 cb.className = "select-row-checkbox";
1621 cb.setAttribute("data-noclick", "");
1622 cb.setAttribute("aria-label", "Select row");
1623 cb.checked = this._selection.has(id);
1624 cb.addEventListener("change", () => {
1625 if (cb.checked) {
1626 this.select(id);
1627 } else {
1628 this.deselect(id);
1629 }
1630 });
1631 td.appendChild(cb);
1632 return td;
1633 }
1634 if (col.key === EXPANDER_KEY) {
1635 const hasChildren = this._subTable ? !!this._subTable(row, rowIndex) : false;
1636 if (!hasChildren) {
1637 return td;
1638 }
1639 const isOpen = this._expanded.has(rowIndex);
1640 const btn = document.createElement("button");
1641 btn.type = "button";
1642 btn.className = "expander";
1643 btn.setAttribute("data-noclick", "");
1644 btn.setAttribute("aria-expanded", isOpen ? "true" : "false");
1645 btn.setAttribute(
1646 "aria-label",
1647 isOpen ? "Collapse row" : "Expand row"
1648 );
1649 btn.textContent = isOpen ? "â–¾" : "â–¸";
1650 btn.addEventListener("click", (e) => {
1651 this._toggleRow(rowIndex, row, e);
1652 });
1653 td.appendChild(btn);
1654 return td;
1655 }
1656 const value = row[col.key];
1657 if (col.render) {
1658 const out = col.render(value, row, rowIndex);
1659 this._mountCellContent(td, out);
1660 } else if (value !== null && value !== void 0) {
1661 td.textContent = String(value);
1662 }
1663 return td;
1664 }
1665 _buildSubTableRow(sub, colspan) {
1666 const tr = document.createElement("tr");
1667 tr.classList.add("subtable");
1668 tr.setAttribute("part", "subtable-row");
1669 const td = document.createElement("td");
1670 td.colSpan = colspan;
1671 const inner = document.createElement("div");
1672 inner.classList.add("subtable-inner");
1673 if (sub instanceof Node) {
1674 inner.appendChild(sub);
1675 } else if (isTemplateResult(sub)) {
1676 render(sub, inner);
1677 } else {
1678 const nested = document.createElement("wpd-table");
1679 nested.columns = sub.columns;
1680 nested.data = sub.data;
1681 if (sub.subTable) {
1682 nested.subTable = sub.subTable;
1683 }
1684 inner.appendChild(nested);
1685 }
1686 td.appendChild(inner);
1687 tr.appendChild(td);
1688 return tr;
1689 }
1690 _mountCellContent(td, out) {
1691 if (typeof out === "string") {
1692 td.textContent = out;
1693 return;
1694 }
1695 if (out instanceof Node) {
1696 td.appendChild(out);
1697 return;
1698 }
1699 if (isTemplateResult(out)) {
1700 render(out, td);
1701 }
1702 }
1703 // ------------------------------------------------------------------
1704 // Behavior
1705 // ------------------------------------------------------------------
1706 _onFilterChange(key, value) {
1707 if (value === "") {
1708 delete this._filters[key];
1709 } else {
1710 this._filters[key] = value;
1711 }
1712 this.emit("wpd-table-filter-change", { filters: { ...this._filters } });
1713 const root = this.shadowRoot;
1714 const tbody = root?.querySelector("tbody");
1715 if (tbody) {
1716 const cols = this._effectiveColumns();
1717 const stickyN = this._readStickyColumns();
1718 this._lastStickyIndex = this._computeLastStickyIndex(cols, stickyN);
1719 this._paintBody(tbody, cols, stickyN);
1720 this._applyStickyOffsets();
1721 }
1722 }
1723 _onRowClick(row, index, e) {
1724 const path = e.composedPath?.() ?? [];
1725 for (const node of path) {
1726 if (node instanceof Element && node.hasAttribute("data-noclick")) {
1727 return;
1728 }
1729 if (node === this) {
1730 break;
1731 }
1732 }
1733 this.emit("wpd-table-row-click", { row, index, originalEvent: e });
1734 }
1735 _toggleRow(index, row, e) {
1736 e.stopPropagation();
1737 const isOpen = this._expanded.has(index);
1738 if (isOpen) {
1739 this._expanded.delete(index);
1740 } else {
1741 this._expanded.add(index);
1742 }
1743 this.emit("wpd-table-expand-change", {
1744 row,
1745 index,
1746 expanded: !isOpen
1747 });
1748 this._schedulePaint();
1749 }
1750 _cycleSort(key) {
1751 if (!this._sort || this._sort.key !== key) {
1752 this._sort = { key, direction: "asc" };
1753 } else if (this._sort.direction === "asc") {
1754 this._sort = { key, direction: "desc" };
1755 } else {
1756 this._sort = null;
1757 }
1758 this.emit("wpd-table-sort-change", {
1759 sort: this._sort ? { ...this._sort } : null
1760 });
1761 this._schedulePaint();
1762 }
1763 _emitSelectionChange() {
1764 this.emit("wpd-table-selection-change", {
1765 selection: Array.from(this._selection),
1766 rows: this.selectedRows
1767 });
1768 }
1769 // ------------------------------------------------------------------
1770 // Filtering + sorting
1771 // ------------------------------------------------------------------
1772 _filteredRows() {
1773 const out = [];
1774 const active = Object.keys(this._filters).filter(
1775 (k) => this._filters[k] !== ""
1776 );
1777 for (let i = 0; i < this._data.length; i++) {
1778 const row = this._data[i];
1779 let pass = true;
1780 for (const key of active) {
1781 const col = this._columns.find((c) => c.key === key);
1782 if (col && typeof col.filterRender === "function") {
1783 continue;
1784 }
1785 const filter = this._filters[key] ?? "";
1786 const cell = row[key];
1787 const cellStr = cell === null || cell === void 0 ? "" : String(cell);
1788 if (col?.filter === "select") {
1789 if (cellStr !== filter) {
1790 pass = false;
1791 break;
1792 }
1793 } else if (!cellStr.toLowerCase().includes(filter.toLowerCase())) {
1794 pass = false;
1795 break;
1796 }
1797 }
1798 if (pass) {
1799 out.push({ row, index: i });
1800 }
1801 }
1802 return out;
1803 }
1804 _sortedRows(rows) {
1805 if (!this._sort) {
1806 return rows;
1807 }
1808 const col = this._columns.find((c) => c.key === this._sort.key);
1809 if (!col) {
1810 return rows;
1811 }
1812 const dir = this._sort.direction === "desc" ? -1 : 1;
1813 const out = rows.slice();
1814 out.sort((a, b) => {
1815 const av = col.sortValue ? col.sortValue(a.row, a.row[col.key]) : a.row[col.key];
1816 const bv = col.sortValue ? col.sortValue(b.row, b.row[col.key]) : b.row[col.key];
1817 return compareValues(av, bv) * dir;
1818 });
1819 return out;
1820 }
1821 _uniqueValues(key) {
1822 const seen = /* @__PURE__ */ new Set();
1823 for (const row of this._data) {
1824 const v = row[key];
1825 if (v === null || v === void 0) {
1826 continue;
1827 }
1828 seen.add(String(v));
1829 }
1830 return Array.from(seen).sort();
1831 }
1832 _countSelectedInData() {
1833 let n = 0;
1834 this._data.forEach((row, i) => {
1835 if (this._selection.has(this._getRowId(row, i))) {
1836 n++;
1837 }
1838 });
1839 return n;
1840 }
1841 // ------------------------------------------------------------------
1842 // Sticky columns + attribute reads
1843 // ------------------------------------------------------------------
1844 _readStickyColumns() {
1845 const raw = parseInt(this.getAttribute("sticky-columns") || "0", 10);
1846 return Number.isFinite(raw) && raw > 0 ? raw : 0;
1847 }
1848 _readLoadingRows() {
1849 const raw = parseInt(this.getAttribute("loading-rows") || "5", 10);
1850 return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 100) : 5;
1851 }
1852 _readSelectable() {
1853 const v = this.getAttribute("selectable");
1854 if (v === "single") {
1855 return "single";
1856 }
1857 if (v === "multi" || v === "") {
1858 return "multi";
1859 }
1860 return null;
1861 }
1862 /**
1863 * Sticky-band membership. The first N columns get pinned, with two
1864 * per-column overrides: `column.sticky = true` opts in even outside
1865 * the band; `column.sticky = false` opts out within it.
1866 */
1867 _isStickyIndex(index, stickyN, col) {
1868 if (col.sticky === false) {
1869 return false;
1870 }
1871 if (col.sticky === true) {
1872 return true;
1873 }
1874 return index < stickyN;
1875 }
1876 _computeLastStickyIndex(cols, stickyN) {
1877 let last = -1;
1878 for (let i = 0; i < cols.length; i++) {
1879 if (this._isStickyIndex(i, stickyN, cols[i])) {
1880 last = i;
1881 }
1882 }
1883 return last;
1884 }
1885 _applyCellClasses(cell, col, index, stickyN) {
1886 if (col.key === EXPANDER_KEY) {
1887 cell.classList.add("col-expander");
1888 }
1889 if (col.key === SELECT_KEY) {
1890 cell.classList.add("col-select");
1891 }
1892 if (col.align === "center") {
1893 cell.classList.add("align-center");
1894 }
1895 if (col.align === "end") {
1896 cell.classList.add("align-end");
1897 }
1898 const sticky = this._isStickyIndex(index, stickyN, col);
1899 if (sticky) {
1900 cell.classList.add("is-sticky");
1901 if (index === this._lastStickyIndex) {
1902 cell.classList.add("is-sticky-edge");
1903 }
1904 }
1905 }
1906 _effectiveColumns() {
1907 const out = [];
1908 if (this._readSelectable()) {
1909 out.push({
1910 key: SELECT_KEY,
1911 label: "",
1912 // The descriptor width is painted onto a `<col>`
1913 // element and is the authoritative column-width
1914 // source in table-layout: auto — CSS `td { width }`
1915 // is ignored once `<col>` has a value. Pair with
1916 // the matching `td.col-select` rule (zero
1917 // `padding-inline`, `text-align: center`) so the
1918 // checkbox sits with breathing room on both sides.
1919 width: "40px",
1920 align: "center"
1921 });
1922 }
1923 if (this._subTable) {
1924 out.push({
1925 key: EXPANDER_KEY,
1926 label: "",
1927 // Same contract as col-select. 36px column +
1928 // 20px button + zero padding centers the chevron
1929 // with ~8px on each side.
1930 width: "36px",
1931 align: "center"
1932 });
1933 }
1934 out.push(...this._columns);
1935 return out;
1936 }
1937 /**
1938 * Walk the header row, sum the natural widths of the sticky cells,
1939 * then write cumulative `inset-inline-start` offsets onto every
1940 * row's matching cells.
1941 */
1942 _applyStickyOffsets() {
1943 const root = this.shadowRoot;
1944 if (!root) {
1945 return;
1946 }
1947 const headRow = root.querySelector("thead tr");
1948 if (!headRow) {
1949 return;
1950 }
1951 const ths = Array.from(headRow.children);
1952 const offsets = [];
1953 let acc = 0;
1954 for (let i = 0; i < ths.length; i++) {
1955 offsets[i] = acc;
1956 if (ths[i].classList.contains("is-sticky")) {
1957 acc += ths[i].offsetWidth;
1958 }
1959 }
1960 const rows = root.querySelectorAll(
1961 "thead tr, tbody tr:not(.subtable):not(.empty):not(.skeleton)"
1962 );
1963 rows.forEach((r) => {
1964 const cells = Array.from(r.children);
1965 for (let i = 0; i < cells.length; i++) {
1966 if (cells[i].classList.contains("is-sticky")) {
1967 cells[i].style.insetInlineStart = `${offsets[i]}px`;
1968 }
1969 }
1970 });
1971 this._maybeWarnStickyOffsetRace(ths, offsets);
1972 }
1973 _maybeWarnStickyOffsetRace(ths, offsets) {
1974 if (this._stickyRaceWarned) {
1975 return;
1976 }
1977 const stickyN = this._readStickyColumns();
1978 if (stickyN < 2) {
1979 return;
1980 }
1981 const lastIdx = Math.min(stickyN - 1, ths.length - 1);
1982 if (lastIdx <= 0) {
1983 return;
1984 }
1985 if (offsets[lastIdx] !== 0) {
1986 return;
1987 }
1988 if (this.offsetWidth === 0) {
1989 return;
1990 }
1991 this._stickyRaceWarned = true;
1992 const w0 = ths[0]?.offsetWidth ?? 0;
1993 console.warn(
1994 `[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.`
1995 );
1996 }
1997 _measureHeaderHeight() {
1998 const root = this.shadowRoot;
1999 if (!root) {
2000 return;
2001 }
2002 const headRow = root.querySelector("thead tr");
2003 if (!headRow) {
2004 return;
2005 }
2006 const h = headRow.offsetHeight;
2007 if (h > 0) {
2008 this.style.setProperty("--wpd-table-header-height", `${h}px`);
2009 }
2010 }
2011 /**
2012 * Once-per-element warning for the most common sticky-header
2013 * mistake: forgetting to give the table a scroll container. Without
2014 * a max-height (or a scrolling ancestor), `position: sticky`
2015 * silently does nothing because there's no scrollport for it to
2016 * stick within.
2017 */
2018 _maybeWarnStickyHeader() {
2019 if (this._stickyHeaderWarned) {
2020 return;
2021 }
2022 if (!this.hasAttribute("sticky-header")) {
2023 return;
2024 }
2025 if (this.hasAttribute("loading") || this._data.length < 8) {
2026 return;
2027 }
2028 const scroll = this.shadowRoot?.querySelector(
2029 ".scroll"
2030 );
2031 if (!scroll) {
2032 return;
2033 }
2034 if (scroll.offsetWidth === 0) {
2035 return;
2036 }
2037 if (scroll.scrollHeight <= scroll.clientHeight + 1) {
2038 this._stickyHeaderWarned = true;
2039 console.warn(
2040 "[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."
2041 );
2042 }
2043 }
2044 };
2045 _WpdTable.props = [
2046 "stickyColumns",
2047 "stickyHeader",
2048 "striped",
2049 "hover",
2050 "compact",
2051 "bordered",
2052 "empty",
2053 "loading",
2054 "loadingRows",
2055 "selectable"
2056 ];
2057 _WpdTable.styles = [styles$2];
2058 _WpdTable.help = {
2059 title: "Table",
2060 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.",
2061 status: "experimental",
2062 since: "0.18.0",
2063 props: [
2064 {
2065 name: "sticky-columns",
2066 type: "integer",
2067 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."
2068 },
2069 {
2070 name: "sticky-header",
2071 type: "boolean",
2072 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."
2073 },
2074 { name: "striped", type: "boolean", description: "Zebra rows." },
2075 { name: "hover", type: "boolean", description: "Highlight rows on hover." },
2076 { name: "compact", type: "boolean", description: "Tighter padding + smaller font." },
2077 { name: "bordered", type: "boolean", description: "Vertical cell borders." },
2078 {
2079 name: "empty",
2080 type: "string",
2081 description: "Fallback text shown when there are no rows. For richer empty states, project light-DOM content into the `empty` slot."
2082 },
2083 {
2084 name: "loading",
2085 type: "boolean",
2086 description: "Paint shimmering skeleton rows in place of body content. Filters / sort headers stay live."
2087 },
2088 {
2089 name: "loading-rows",
2090 type: "integer",
2091 description: "Number of skeleton rows when loading. Default 5."
2092 },
2093 {
2094 name: "selectable",
2095 type: '"single" | "multi"',
2096 description: "Auto-prepend a checkbox column. `multi` puts a select-all checkbox in the header; `single` enforces at-most-one selected."
2097 }
2098 ],
2099 events: [
2100 { name: "wpd-table-filter-change", description: "Filter input changed." },
2101 { name: "wpd-table-sort-change", description: "Header click cycled the sort." },
2102 { name: "wpd-table-selection-change", description: "Selection set changed." },
2103 { name: "wpd-table-row-click", description: "Body row clicked (skips data-noclick descendants)." },
2104 { name: "wpd-table-expand-change", description: "Sub-table toggled." }
2105 ],
2106 slots: [
2107 { name: "empty", description: "Custom empty-state content (CTA, illustration, etc.)." }
2108 ],
2109 cssProps: [
2110 { name: "--wpd-table-bg" },
2111 { name: "--wpd-table-border" },
2112 { name: "--wpd-table-column-border" },
2113 { name: "--wpd-table-header-bg" },
2114 { name: "--wpd-table-row-hover" },
2115 { name: "--wpd-table-stripe" },
2116 { name: "--wpd-table-cell-padding" },
2117 { name: "--wpd-table-font-size" },
2118 { name: "--wpd-table-max-height" },
2119 { name: "--wpd-table-skeleton-color" }
2120 ],
2121 example: html`
2122 <wpd-table id="sample-table" sticky-header striped hover></wpd-table>
2123 `
2124 };
2125 let WpdTable = _WpdTable;
2126 function isTemplateResult(v) {
2127 return !!v && v.__wpdHtml === true;
2128 }
2129 function compareValues(a, b) {
2130 if (a === b) {
2131 return 0;
2132 }
2133 if (a === null || a === void 0) {
2134 return -1;
2135 }
2136 if (b === null || b === void 0) {
2137 return 1;
2138 }
2139 if (typeof a === "number" && typeof b === "number") {
2140 return a - b;
2141 }
2142 if (a instanceof Date && b instanceof Date) {
2143 return a.getTime() - b.getTime();
2144 }
2145 const an = Number(a);
2146 const bn = Number(b);
2147 if (Number.isFinite(an) && Number.isFinite(bn)) {
2148 return an - bn;
2149 }
2150 return String(a).localeCompare(String(b));
2151 }
2152 defineComponent("wpd-table", WpdTable);
2153 function hashTitleToHue(input) {
2154 if (!input) {
2155 return 214;
2156 }
2157 let hash = 5381;
2158 for (let i = 0; i < input.length; i++) {
2159 hash = Math.imul(hash, 33) + input.charCodeAt(i);
2160 }
2161 return (hash % 360 + 360) % 360;
2162 }
2163 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}}`;
2164 const SIZE_MAP = {
2165 xs: 20,
2166 sm: 24,
2167 md: 40,
2168 lg: 64,
2169 xl: 96
2170 };
2171 const VALID_PRESENCE = /* @__PURE__ */ new Set(["online", "inactive", "offline"]);
2172 const _WpdAvatar = class _WpdAvatar extends Component {
2173 constructor() {
2174 super(...arguments);
2175 this._presenceHandler = null;
2176 this._imgFailed = false;
2177 this._onPointerMove = null;
2178 this._onPointerEnter = null;
2179 this._onPointerLeave = null;
2180 this._tiltRaf = 0;
2181 this._pendingTiltX = "0deg";
2182 this._pendingTiltY = "0deg";
2183 this._pendingGlareX = "50%";
2184 this._pendingGlareY = "50%";
2185 }
2186 connectedCallback() {
2187 super.connectedCallback();
2188 this._maybeAttachPresenceListener();
2189 this._attachHoverEffect();
2190 }
2191 disconnectedCallback() {
2192 if (this._presenceHandler) {
2193 document.removeEventListener(
2194 "desktop-mode-presence-changed",
2195 this._presenceHandler
2196 );
2197 this._presenceHandler = null;
2198 }
2199 this._detachHoverEffect();
2200 }
2201 attributeChangedCallback(name, oldValue, newValue) {
2202 super.attributeChangedCallback(name, oldValue, newValue);
2203 if (name === "src") {
2204 this._imgFailed = false;
2205 }
2206 if (name === "user-id" || name === "presence") {
2207 this._maybeAttachPresenceListener();
2208 }
2209 }
2210 render() {
2211 const src = this._attr("src");
2212 const name = this._attr("name") || "";
2213 const altRaw = this._attr("alt");
2214 const alt = altRaw !== null ? altRaw : name;
2215 const sizeRaw = this._attr("size");
2216 const size = this._resolveSize(sizeRaw);
2217 const presence = this._presenceForRender();
2218 const clickable = this._attr("clickable") !== null;
2219 this.style.setProperty("--wpd-avatar-size", `${size}px`);
2220 const initialsBg = src && !this._imgFailed ? "" : this._initialsBg(name);
2221 const inner = src && !this._imgFailed ? html`<img
2222 src=${src}
2223 alt=${alt}
2224 @error=${() => this._onImgError()}
2225 loading="lazy"
2226 />` : this._initials(name);
2227 const dot = presence ? html`<span
2228 class=${`wpd-avatar__dot wpd-avatar__dot--${presence}`}
2229 aria-label=${this._presenceLabel(presence)}
2230 ></span>` : html``;
2231 if (clickable) {
2232 return html`
2233 <button
2234 type="button"
2235 class="wpd-avatar__tile"
2236 aria-label=${alt || "User"}
2237 style=${initialsBg ? `background:${initialsBg};` : ""}
2238 @click=${(e) => this._onClick(e)}
2239 >${inner}</button>
2240 ${dot}
2241 `;
2242 }
2243 return html`
2244 <div
2245 class="wpd-avatar__tile"
2246 role="img"
2247 aria-label=${alt || "User"}
2248 style=${initialsBg ? `background:${initialsBg};` : ""}
2249 >${inner}</div>
2250 ${dot}
2251 `;
2252 }
2253 _attr(name) {
2254 return this.getAttribute(name);
2255 }
2256 _resolveSize(raw) {
2257 if (!raw) {
2258 return 32;
2259 }
2260 if (raw in SIZE_MAP) {
2261 return SIZE_MAP[raw];
2262 }
2263 const n = Number(raw);
2264 return Number.isFinite(n) && n > 0 ? n : 32;
2265 }
2266 _initials(name) {
2267 const trimmed = name.trim();
2268 if (!trimmed) {
2269 return "?";
2270 }
2271 return Array.from(trimmed)[0]?.toUpperCase() ?? "?";
2272 }
2273 _initialsBg(name) {
2274 const hue = hashTitleToHue(name);
2275 return `linear-gradient(135deg, hsl(${hue} 62% 55%), hsl(${(hue + 24) % 360} 58% 42%))`;
2276 }
2277 _presenceForRender() {
2278 const raw = this._attr("presence");
2279 if (raw && VALID_PRESENCE.has(raw)) {
2280 return raw;
2281 }
2282 return null;
2283 }
2284 _presenceLabel(p) {
2285 switch (p) {
2286 case "online":
2287 return "Online";
2288 case "inactive":
2289 return "Inactive";
2290 case "offline":
2291 return "Offline";
2292 }
2293 }
2294 _onImgError() {
2295 this._imgFailed = true;
2296 this.requestUpdate();
2297 }
2298 _onClick(e) {
2299 const userId = this._attr("user-id");
2300 const detail = {
2301 userId: userId !== null ? Number(userId) || null : null,
2302 originalEvent: e
2303 };
2304 this.emit("wpd-avatar-click", detail);
2305 }
2306 /**
2307 * Wire up the pointer-driven tilt + glare. Listens on the host so
2308 * one set of bindings covers both the clickable `<button>` and
2309 * the decorative `<div>` rendering branches. The actual math
2310 * runs in `_handlePointerMove`; this method just owns the
2311 * bind/unbind plumbing.
2312 *
2313 * Bails entirely when `prefers-reduced-motion: reduce` is set —
2314 * the CSS has its own `@media` guard for the visual layer, but
2315 * skipping the JS too saves the per-event work for users who
2316 * won't benefit from it.
2317 */
2318 _attachHoverEffect() {
2319 const reduceMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
2320 if (reduceMotion) {
2321 return;
2322 }
2323 this._onPointerEnter = () => {
2324 this.style.setProperty("--wpd-avatar-hover", "1");
2325 };
2326 this._onPointerLeave = () => {
2327 this.style.setProperty("--wpd-avatar-hover", "0");
2328 this._pendingTiltX = "0deg";
2329 this._pendingTiltY = "0deg";
2330 this._pendingGlareX = "50%";
2331 this._pendingGlareY = "50%";
2332 this._flushTilt();
2333 };
2334 this._onPointerMove = (e) => {
2335 const rect = this.getBoundingClientRect();
2336 if (rect.width === 0 || rect.height === 0) {
2337 return;
2338 }
2339 const nx = (e.clientX - rect.left) / rect.width - 0.5;
2340 const ny = (e.clientY - rect.top) / rect.height - 0.5;
2341 const MAX = 14;
2342 this._pendingTiltY = `${(nx * MAX).toFixed(2)}deg`;
2343 this._pendingTiltX = `${(-ny * MAX).toFixed(2)}deg`;
2344 const gx = Math.max(0, Math.min(100, (nx + 0.5) * 100));
2345 const gy = Math.max(0, Math.min(100, (ny + 0.5) * 100));
2346 this._pendingGlareX = `${gx.toFixed(1)}%`;
2347 this._pendingGlareY = `${gy.toFixed(1)}%`;
2348 if (!this._tiltRaf) {
2349 this._tiltRaf = requestAnimationFrame(() => this._flushTilt());
2350 }
2351 };
2352 this.addEventListener("pointerenter", this._onPointerEnter);
2353 this.addEventListener("pointerleave", this._onPointerLeave);
2354 this.addEventListener("pointermove", this._onPointerMove);
2355 }
2356 _flushTilt() {
2357 this._tiltRaf = 0;
2358 this.style.setProperty("--wpd-avatar-tilt-x", this._pendingTiltX);
2359 this.style.setProperty("--wpd-avatar-tilt-y", this._pendingTiltY);
2360 this.style.setProperty("--wpd-avatar-glare-x", this._pendingGlareX);
2361 this.style.setProperty("--wpd-avatar-glare-y", this._pendingGlareY);
2362 }
2363 _detachHoverEffect() {
2364 if (this._onPointerMove) {
2365 this.removeEventListener("pointermove", this._onPointerMove);
2366 this._onPointerMove = null;
2367 }
2368 if (this._onPointerEnter) {
2369 this.removeEventListener("pointerenter", this._onPointerEnter);
2370 this._onPointerEnter = null;
2371 }
2372 if (this._onPointerLeave) {
2373 this.removeEventListener("pointerleave", this._onPointerLeave);
2374 this._onPointerLeave = null;
2375 }
2376 if (this._tiltRaf) {
2377 cancelAnimationFrame(this._tiltRaf);
2378 this._tiltRaf = 0;
2379 }
2380 }
2381 _maybeAttachPresenceListener() {
2382 const userId = this._attr("user-id");
2383 const explicit = this._attr("presence");
2384 const wantsListener = !!userId && !explicit;
2385 if (wantsListener && !this._presenceHandler) {
2386 this._presenceHandler = (e) => {
2387 const detail = e.detail;
2388 if (!detail) {
2389 return;
2390 }
2391 if (String(detail.userId) !== String(userId)) {
2392 return;
2393 }
2394 if (detail.newStatus && VALID_PRESENCE.has(detail.newStatus)) {
2395 this.setAttribute("presence", detail.newStatus);
2396 }
2397 };
2398 document.addEventListener(
2399 "desktop-mode-presence-changed",
2400 this._presenceHandler
2401 );
2402 } else if (!wantsListener && this._presenceHandler) {
2403 document.removeEventListener(
2404 "desktop-mode-presence-changed",
2405 this._presenceHandler
2406 );
2407 this._presenceHandler = null;
2408 }
2409 }
2410 };
2411 _WpdAvatar.props = ["src", "alt", "name", "size", "presence", "userId", "clickable"];
2412 _WpdAvatar.styles = [avatarStyles];
2413 _WpdAvatar.help = {
2414 title: "Avatar",
2415 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.",
2416 status: "stable",
2417 since: "0.22.0",
2418 props: [
2419 { name: "src", type: "string", description: "Image URL. Falls back to initials when empty or load fails." },
2420 { name: "alt", type: "string", description: "Alt text for the image. Defaults to `name` when omitted." },
2421 { name: "name", type: "string", description: "Used for initials + hue fallback when no src." },
2422 {
2423 name: "size",
2424 type: 'number | "xs" | "sm" | "md" | "lg" | "xl"',
2425 description: "Pixel size or named preset. Default 32 (sm-ish). Sets --wpd-avatar-size."
2426 },
2427 {
2428 name: "presence",
2429 type: '"online" | "inactive" | "offline"',
2430 description: "Presence dot color. Omit for no dot."
2431 },
2432 {
2433 name: "user-id",
2434 type: "number",
2435 description: "When set AND presence is unset, auto-subscribes to desktop-mode-presence-changed and updates the dot."
2436 }
2437 ],
2438 events: [
2439 {
2440 name: "wpd-avatar-click",
2441 description: "Fires on click of the tile. Detail carries userId when set.",
2442 detail: "{ userId: number | null }"
2443 }
2444 ],
2445 cssProps: [
2446 { name: "--wpd-avatar-size", description: "Tile size in any CSS length. Set automatically by the size attribute." },
2447 { name: "--wpd-avatar-dot-ring", description: "Background color used as the dot ring (matches surrounding panel by default)." }
2448 ],
2449 example: html`
2450 <wpd-avatar name="Daniel" size="40" presence="online"></wpd-avatar>
2451 `
2452 };
2453 let WpdAvatar = _WpdAvatar;
2454 defineComponent("wpd-avatar", WpdAvatar);
2455 const styles$1 = css`:host{display:inline-flex;max-width:100%;vertical-align:middle}:host( [ hidden ] ){display:none}.wpd-chip{display:inline-flex;align-items:center;gap:var( --wpd-chip-gap,4px );padding:var( --wpd-chip-padding,2px 8px );border-radius:var( --wpd-chip-radius,999px );font-size:var( --wpd-chip-font-size,12px );line-height:var( --wpd-chip-line-height,1.6 );font-weight:var( --wpd-chip-font-weight,500 );background:var( --wpd-chip-bg,#f0f0f1 );color:var( --wpd-chip-fg,#1d2327 );border:var( --wpd-chip-border,1px solid transparent );max-width:100%;box-sizing:border-box;transition:background-color 0.12s ease,color 0.12s ease,border-color 0.12s ease,transform 0.12s ease,opacity 0.12s ease}:host( [ tone='accent' ] ) .wpd-chip{background:var( --wpd-chip-bg,color-mix( in srgb,var( --wp-admin-theme-color,#2271b1 ) 14%,transparent ) );color:var( --wpd-chip-fg,var( --wp-admin-theme-color,#2271b1 ) )}:host( [ tone='positive' ] ) .wpd-chip{background:var( --wpd-chip-bg,rgba( 30,132,73,0.14 ) );color:var( --wpd-chip-fg,#1d6f42 )}:host( [ tone='warning' ] ) .wpd-chip{background:var( --wpd-chip-bg,rgba( 217,119,6,0.18 ) );color:var( --wpd-chip-fg,#8a4a06 )}:host( [ tone='danger' ] ) .wpd-chip{background:var( --wpd-chip-bg,rgba( 214,54,56,0.14 ) );color:var( --wpd-chip-fg,#a02622 )}:host( [ pending ] ) .wpd-chip{opacity:0.65;animation:wpd-chip-pulse 1.2s ease-in-out infinite}@keyframes wpd-chip-pulse{0%,100%{opacity:0.55}50%{opacity:0.95}}.wpd-chip__label{max-width:var( --wpd-chip-label-max,220px );overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wpd-chip__icon{display:inline-flex;align-items:center;flex-shrink:0}.wpd-chip__icon::slotted( * ){display:inline-flex}.wpd-chip__dismiss{appearance:none;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:16px;height:16px;margin-inline-start:2px;padding:0;border:0;border-radius:50%;background:transparent;color:inherit;cursor:pointer;opacity:0.55;transition:opacity 0.12s ease,background-color 0.12s ease}.wpd-chip__dismiss:hover,.wpd-chip__dismiss:focus-visible{opacity:1;background:rgba( 0,0,0,0.12 );outline:none}.wpd-chip__dismiss:focus-visible{box-shadow:0 0 0 2px var( --wp-admin-theme-color,#2271b1 )}.wpd-chip__dismiss[ disabled ]{opacity:0.35;cursor:not-allowed}.wpd-chip__dismiss svg{display:block;width:10px;height:10px}:host( [ disabled ] ) .wpd-chip{opacity:0.55;cursor:not-allowed}:host( [ size='compact' ] ) .wpd-chip{padding:var( --wpd-chip-padding,1px 6px );font-size:var( --wpd-chip-font-size,11px )}`;
2456 const _WpdChip = class _WpdChip extends Component {
2457 constructor() {
2458 super(...arguments);
2459 this._onHostKeyDown = (e) => {
2460 const dismissible = this.dismissible !== null;
2461 if (!dismissible) {
2462 return;
2463 }
2464 if (e.key === "Backspace" || e.key === "Delete") {
2465 e.preventDefault();
2466 const disabled = this.disabled !== null;
2467 if (disabled) {
2468 return;
2469 }
2470 const label = this.label ?? "";
2471 this.emit("wpd-chip-dismiss", { label });
2472 }
2473 };
2474 }
2475 connectedCallback() {
2476 super.connectedCallback();
2477 this.addEventListener("keydown", this._onHostKeyDown);
2478 }
2479 disconnectedCallback() {
2480 this.removeEventListener("keydown", this._onHostKeyDown);
2481 }
2482 render() {
2483 const label = this.label ?? "";
2484 const dismissible = this.dismissible !== null;
2485 const disabled = this.disabled !== null;
2486 return html`
2487 <span part="chip" class="wpd-chip">
2488 <span class="wpd-chip__icon">
2489 <slot name="icon"></slot>
2490 </span>
2491 <span class="wpd-chip__label">
2492 ${label === "" ? html`<slot></slot>` : label}
2493 </span>
2494 ${dismissible ? html`
2495 <button
2496 part="dismiss"
2497 class="wpd-chip__dismiss"
2498 type="button"
2499 aria-label=${`Remove ${label || "chip"}`}
2500 ?disabled=${disabled}
2501 @click=${(e) => this._onDismiss(e)}
2502 >
2503 ${_iconCross()}
2504 </button>
2505 ` : html``}
2506 </span>
2507 `;
2508 }
2509 _onDismiss(e) {
2510 e.stopPropagation();
2511 const disabled = this.disabled !== null;
2512 if (disabled) {
2513 return;
2514 }
2515 const label = this.label ?? "";
2516 this.emit("wpd-chip-dismiss", { label });
2517 }
2518 };
2519 _WpdChip.props = [
2520 "label",
2521 "tone",
2522 "size",
2523 "dismissible",
2524 "disabled",
2525 "pending"
2526 ];
2527 _WpdChip.styles = [styles$1];
2528 _WpdChip.help = {
2529 title: "Chip",
2530 summary: "Labelled pill primitive with optional leading icon and trailing dismiss button. Tones mirror <wpd-badge>; pair with <wpd-tag-input> for full add/remove ergonomics.",
2531 status: "experimental",
2532 since: "0.8.0",
2533 props: [
2534 {
2535 name: "label",
2536 type: "string",
2537 description: "Visible text. Falls back to the default slot when omitted."
2538 },
2539 {
2540 name: "tone",
2541 type: "'neutral' | 'accent' | 'positive' | 'warning' | 'danger'",
2542 default: "neutral",
2543 description: "Color variant. Mirrors <wpd-badge> tones."
2544 },
2545 {
2546 name: "size",
2547 type: "'default' | 'compact'",
2548 default: "default",
2549 description: "Vertical density. Compact halves horizontal padding for dense lists."
2550 },
2551 {
2552 name: "dismissible",
2553 type: "boolean attribute",
2554 description: "Renders a trailing × button. Click / Enter / Space emits wpd-chip-dismiss."
2555 },
2556 {
2557 name: "disabled",
2558 type: "boolean attribute",
2559 description: "Visually mutes the chip and blocks the dismiss button. Useful while a parent is mid-update."
2560 },
2561 {
2562 name: "pending",
2563 type: "boolean attribute",
2564 description: "Renders a subtle pulse animation while a REST mutation is in flight. Auto-applied by <wpd-tag-input>; safe to set by hand."
2565 }
2566 ],
2567 slots: [
2568 { name: "(default)", description: "Fallback label when `label` is unset." },
2569 {
2570 name: "icon",
2571 description: "Leading icon (Dashicon, SVG, image). Inherits text color."
2572 }
2573 ],
2574 parts: [
2575 { name: "chip", description: "The pill container." },
2576 {
2577 name: "dismiss",
2578 description: "The trailing × button (when `dismissible`)."
2579 }
2580 ],
2581 events: [
2582 {
2583 name: "wpd-chip-dismiss",
2584 description: "Fires when the dismiss button is activated. Detail carries the chip's label so a delegated listener can act without DOM walking.",
2585 detail: "{ label: string }"
2586 }
2587 ],
2588 cssProps: [
2589 { name: "--wpd-chip-bg", description: "Background color." },
2590 { name: "--wpd-chip-fg", description: "Text color." },
2591 { name: "--wpd-chip-border", description: "Border shorthand." },
2592 {
2593 name: "--wpd-chip-padding",
2594 description: "Padding shorthand.",
2595 default: "2px 8px"
2596 },
2597 {
2598 name: "--wpd-chip-radius",
2599 description: "Corner radius.",
2600 default: "999px"
2601 },
2602 {
2603 name: "--wpd-chip-label-max",
2604 description: "Max width of the inner label before ellipsis.",
2605 default: "220px"
2606 }
2607 ],
2608 example: html`
2609 <wpd-cluster gap="6">
2610 <wpd-chip label="Neutral"></wpd-chip>
2611 <wpd-chip label="Accent" tone="accent"></wpd-chip>
2612 <wpd-chip label="Positive" tone="positive"></wpd-chip>
2613 <wpd-chip label="Warning" tone="warning"></wpd-chip>
2614 <wpd-chip label="Danger" tone="danger"></wpd-chip>
2615 <wpd-chip label="Dismissible" dismissible></wpd-chip>
2616 </wpd-cluster>
2617 `
2618 };
2619 let WpdChip = _WpdChip;
2620 defineComponent("wpd-chip", WpdChip);
2621 function _iconCross() {
2622 return html`
2623 <svg
2624 viewBox="0 0 12 12"
2625 width="10"
2626 height="10"
2627 aria-hidden="true"
2628 focusable="false"
2629 fill="none"
2630 stroke="currentColor"
2631 stroke-width="1.5"
2632 stroke-linecap="round"
2633 >
2634 <path d="M3 3 L9 9 M9 3 L3 9" />
2635 </svg>
2636 `;
2637 }
2638 const tabsStyles = css`:host{display:flex;gap:4px;margin-bottom:10px;border-bottom:1px solid var( --desktop-mode-border,#dcdcde )}`;
2639 const tabPanelStyles = css`:host{display:block}:host( [ hidden ] ){display:none}:host(:focus-visible ){outline:2px solid var( --wp-admin-theme-color,#2271b1 );outline-offset:4px;border-radius:4px}`;
2640 const tabStyles = css`:host{display:inline-block}button{appearance:none;padding:6px 10px;border:none;background:transparent;color:var( --desktop-mode-muted,#50575e );font:inherit;font-size:12px;font-weight:500;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color 0.15s ease,border-color 0.15s ease}button:hover{color:var( --wp-admin-theme-color,#2271b1 )}button:focus-visible{outline:2px solid var( --wp-admin-theme-color,#2271b1 );outline-offset:2px}:host( [ aria-selected='true' ] ) button{color:var( --wp-admin-theme-color,#2271b1 );border-bottom-color:var( --wp-admin-theme-color,#2271b1 )}`;
2641 const _WpdTab = class _WpdTab extends Component {
2642 render() {
2643 this.setAttribute("role", "tab");
2644 return html`
2645 <button type="button" @click=${() => this._onPick()}>
2646 <slot></slot>
2647 </button>
2648 `;
2649 }
2650 _onPick() {
2651 this.emit("wpd-tab-pick", {
2652 value: this.value
2653 });
2654 }
2655 };
2656 _WpdTab.props = ["value"];
2657 _WpdTab.styles = [tabStyles];
2658 _WpdTab.help = {
2659 title: "Tab",
2660 summary: "Single tab inside a <wpd-tabs> strip. Carries its identifier via `value`; aria-selected + tabindex are mirrored by the parent.",
2661 status: "stable",
2662 since: "0.7.0",
2663 props: [
2664 {
2665 name: "value",
2666 type: "string",
2667 description: "Identifier the tab contributes to the parent strip selection."
2668 }
2669 ],
2670 slots: [
2671 { name: "(default)", description: "Visible tab label." }
2672 ],
2673 events: [
2674 {
2675 name: "wpd-tab-pick",
2676 description: "Internal event bubbled to the parent <wpd-tabs>. Consumers should listen for wpd-tab-change on the strip instead.",
2677 detail: "{ value: string | null }"
2678 }
2679 ]
2680 };
2681 let WpdTab = _WpdTab;
2682 defineComponent("wpd-tab", WpdTab);
2683 const _WpdTabs = class _WpdTabs extends Component {
2684 connectedCallback() {
2685 super.connectedCallback();
2686 this.addEventListener("wpd-tab-pick", (e) => {
2687 const detail = e.detail;
2688 e.stopPropagation();
2689 this.value = detail.value;
2690 this.emit("wpd-tab-change", { value: detail.value });
2691 });
2692 }
2693 /**
2694 * Declarative item-list setter. Replaces the existing `<wpd-tab>`
2695 * children with a fresh set built from a `{ value, label }`
2696 * array. The `value` prop is preserved if it still matches a new
2697 * entry; otherwise it falls back to the first item.
2698 *
2699 * Lets plugins that populate tabs dynamically (route-driven
2700 * admin screens, filtered lists) replace the declarative
2701 * markup with a one-liner:
2702 *
2703 * ```js
2704 * tabs.items = [
2705 * { value: 'calc', label: 'Calc' },
2706 * { value: 'convert', label: 'Convert' },
2707 * ];
2708 * ```
2709 *
2710 * @since 0.11.0
2711 */
2712 set items(list) {
2713 replaceChildren(this, "wpd-tab", list);
2714 const current = this.value;
2715 const stillValid = current !== null && list.some((i) => i.value === current);
2716 if (!stillValid && list.length > 0) {
2717 this.value = list[0].value;
2718 } else {
2719 this.requestUpdate();
2720 }
2721 }
2722 render() {
2723 this.setAttribute("role", "tablist");
2724 const label = this.label || "";
2725 if (label) {
2726 this.setAttribute("aria-label", label);
2727 }
2728 const current = this.value;
2729 queueMicrotask(() => {
2730 const tabs = this.querySelectorAll("wpd-tab");
2731 for (const tab of Array.from(tabs)) {
2732 const v = tab.getAttribute("value");
2733 tab.setAttribute(
2734 "aria-selected",
2735 v === current ? "true" : "false"
2736 );
2737 tab.setAttribute("tabindex", v === current ? "0" : "-1");
2738 }
2739 syncTabpanels(this, current);
2740 });
2741 return html`<slot></slot>`;
2742 }
2743 };
2744 _WpdTabs.props = ["value", "label"];
2745 _WpdTabs.styles = [tabsStyles];
2746 _WpdTabs.help = {
2747 title: "Tabs",
2748 summary: 'Underline-accent tab strip. Pair with sibling <wpd-tabpanel for="…"> elements and the strip auto-toggles their hidden attribute on selection.',
2749 status: "stable",
2750 since: "0.7.0",
2751 props: [
2752 {
2753 name: "value",
2754 type: "string",
2755 description: "Currently active tab value. Mirrored to child <wpd-tab> aria-selected."
2756 },
2757 {
2758 name: "label",
2759 type: "string",
2760 description: "aria-label for the tablist — describe the tab group for assistive tech."
2761 }
2762 ],
2763 slots: [
2764 {
2765 name: "(default)",
2766 description: '<wpd-tab value="…"> children forming the strip.'
2767 }
2768 ],
2769 events: [
2770 {
2771 name: "wpd-tab-change",
2772 description: "Fires when the active tab changes.",
2773 detail: "{ value: string }"
2774 }
2775 ],
2776 example: html`
2777 <wpd-tabs value="one" label="Demo tabs">
2778 <wpd-tab value="one">One</wpd-tab>
2779 <wpd-tab value="two">Two</wpd-tab>
2780 <wpd-tab value="three">Three</wpd-tab>
2781 </wpd-tabs>
2782 <wpd-tabpanel for="one">First panel.</wpd-tabpanel>
2783 <wpd-tabpanel for="two">Second panel.</wpd-tabpanel>
2784 <wpd-tabpanel for="three">Third panel.</wpd-tabpanel>
2785 `
2786 };
2787 let WpdTabs = _WpdTabs;
2788 defineComponent("wpd-tabs", WpdTabs);
2789 const _WpdTabPanel = class _WpdTabPanel extends Component {
2790 // Shadow DOM — the render target for this component is its
2791 // own shadow root, which holds a single `<slot>` that projects
2792 // whatever the caller placed between the `<wpd-tabpanel>` open
2793 // and close tags. Slotted children remain light-DOM descendants
2794 // of the panel element (the slot rendering mechanism doesn't
2795 // move them), so `panel.querySelector(...)` from plugin render
2796 // callbacks keeps working.
2797 //
2798 // Earlier 0.11.0 builds of this component used light DOM with
2799 // a `<slot>` render, which wiped the panel's server-rendered
2800 // template content on first mount — every `render()` writes
2801 // into `_renderRoot`, and with light DOM that's the panel
2802 // itself. Shadow DOM isolates the render surface.
2803 connectedCallback() {
2804 super.connectedCallback();
2805 this.setAttribute("role", "tabpanel");
2806 if (!this.hasAttribute("tabindex")) {
2807 this.setAttribute("tabindex", "0");
2808 }
2809 const owner = findOwningTabs(this);
2810 if (owner) {
2811 syncTabpanels(owner, owner.getAttribute("value"));
2812 }
2813 }
2814 render() {
2815 return html`<slot></slot>`;
2816 }
2817 };
2818 _WpdTabPanel.props = ["for"];
2819 _WpdTabPanel.styles = [tabPanelStyles];
2820 _WpdTabPanel.help = {
2821 title: "Tab panel",
2822 summary: 'Auto-managed panel paired with a sibling <wpd-tabs>. Declares which tab it belongs to via `for="<tab-value>"`; the parent strip toggles `hidden` whenever the active tab changes. role="tabpanel" and tabindex="0" are set automatically.',
2823 status: "stable",
2824 since: "0.11.0",
2825 props: [
2826 {
2827 name: "for",
2828 type: "string",
2829 description: "Matches the `value` of the owning <wpd-tab>. Panel is shown when its parent tabs strip is on that value."
2830 }
2831 ],
2832 slots: [
2833 { name: "(default)", description: "Panel body content." }
2834 ]
2835 };
2836 let WpdTabPanel = _WpdTabPanel;
2837 defineComponent("wpd-tabpanel", WpdTabPanel);
2838 function replaceChildren(host, tag, items) {
2839 const existing = host.querySelectorAll(`:scope > ${tag}`);
2840 for (const el of Array.from(existing)) {
2841 el.remove();
2842 }
2843 for (const item of items) {
2844 const el = document.createElement(tag);
2845 el.setAttribute("value", item.value);
2846 el.textContent = item.label;
2847 host.appendChild(el);
2848 }
2849 }
2850 function findOwningTabs(panel) {
2851 const parent = panel.parentElement;
2852 if (!parent) {
2853 return null;
2854 }
2855 const sibling = parent.querySelector(":scope > wpd-tabs");
2856 if (sibling) {
2857 return sibling;
2858 }
2859 return panel.closest("wpd-tabs");
2860 }
2861 function syncTabpanels(tabs, value) {
2862 const panels = /* @__PURE__ */ new Set();
2863 const parent = tabs.parentElement;
2864 if (parent) {
2865 for (const p of Array.from(
2866 parent.querySelectorAll(":scope > wpd-tabpanel")
2867 )) {
2868 panels.add(p);
2869 }
2870 }
2871 for (const p of Array.from(
2872 tabs.querySelectorAll(":scope > wpd-tabpanel")
2873 )) {
2874 panels.add(p);
2875 }
2876 for (const panel of panels) {
2877 const pfor = panel.getAttribute("for");
2878 const active = pfor !== null && pfor === value;
2879 if (active) {
2880 panel.removeAttribute("hidden");
2881 } else {
2882 panel.setAttribute("hidden", "");
2883 }
2884 panel.setAttribute("aria-hidden", active ? "false" : "true");
2885 }
2886 }
2887 const HIGHLIGHTS = [
2888 {
2889 icon: "dashicons-yes-alt",
2890 title: __("Triage in one place"),
2891 body: __(
2892 "Pending / All / Spam / Trash / Mine tabs — every status surface in a single window with live counts."
2893 )
2894 },
2895 {
2896 icon: "dashicons-controls-repeat",
2897 title: __("Bulk moderation with undo"),
2898 body: __(
2899 "Multi-select and approve, spam, or trash dozens at once. Every action shows an 8-second undo toast."
2900 )
2901 },
2902 {
2903 icon: "dashicons-format-chat",
2904 title: __("Inline reply"),
2905 body: __(
2906 "Reply right inside the row — no modal, no full-page navigation. Press R on any row to jump straight to the editor."
2907 )
2908 },
2909 {
2910 icon: "dashicons-warning",
2911 title: __("Spam confidence score"),
2912 body: __(
2913 "Every comment gets a 0–100 score from Akismet + heuristics. Optionally turn on AI scoring in OS Settings → Features so each new comment is also scored by your configured AI provider on arrival."
2914 )
2915 },
2916 {
2917 icon: "dashicons-admin-users",
2918 title: __("Author insights drawer"),
2919 body: __(
2920 "Click an avatar to see the author's full history — total comments, spam rate, first seen, and one-click block."
2921 )
2922 },
2923 {
2924 icon: "dashicons-keyboard-hide",
2925 title: __("Keyboard moderation"),
2926 body: __(
2927 "J/K to navigate, A approve, S spam, D trash, R reply, E edit, U undo. Press ? any time for the cheat sheet."
2928 )
2929 }
2930 ];
2931 async function showCommentsIntroDialog() {
2932 return new Promise((resolve) => {
2933 const backdrop = document.createElement("div");
2934 backdrop.className = "wpd-intro-backdrop";
2935 const dialog = document.createElement("div");
2936 dialog.className = "wpd-intro wpd-intro--comments";
2937 dialog.setAttribute("role", "dialog");
2938 dialog.setAttribute("aria-modal", "true");
2939 dialog.setAttribute("aria-labelledby", "wpd-comments-intro-title");
2940 dialog.tabIndex = -1;
2941 backdrop.appendChild(dialog);
2942 const titleEl = document.createElement("h2");
2943 titleEl.id = "wpd-comments-intro-title";
2944 titleEl.className = "wpd-intro__title";
2945 titleEl.textContent = __("Welcome to the new Comments");
2946 dialog.appendChild(titleEl);
2947 const lede = document.createElement("p");
2948 lede.className = "wpd-intro__lede";
2949 lede.textContent = __(
2950 "A moderation surface built around how you actually triage: bulk actions with undo, an inline reply editor, keyboard shortcuts, and a spam score that surfaces the obvious junk first."
2951 );
2952 dialog.appendChild(lede);
2953 const grid = document.createElement("div");
2954 grid.className = "wpd-intro__grid";
2955 HIGHLIGHTS.forEach((h) => {
2956 const card = document.createElement("div");
2957 card.className = "wpd-intro__card";
2958 const icon = document.createElement("span");
2959 icon.className = `dashicons ${h.icon} wpd-intro__card-icon`;
2960 icon.setAttribute("aria-hidden", "true");
2961 const heading = document.createElement("h3");
2962 heading.className = "wpd-intro__card-title";
2963 heading.textContent = h.title;
2964 const body = document.createElement("p");
2965 body.className = "wpd-intro__card-body";
2966 body.textContent = h.body;
2967 card.append(icon, heading, body);
2968 grid.appendChild(card);
2969 });
2970 dialog.appendChild(grid);
2971 const escape = document.createElement("p");
2972 escape.className = "wpd-intro__escape";
2973 escape.textContent = __(
2974 "Prefer the classic Comments screen? You can switch back any time from OS Settings → Features."
2975 );
2976 dialog.appendChild(escape);
2977 const actions = document.createElement("div");
2978 actions.className = "wpd-intro__actions";
2979 const settingsBtn = document.createElement("button");
2980 settingsBtn.type = "button";
2981 settingsBtn.className = "wpd-intro__btn wpd-intro__btn--secondary";
2982 settingsBtn.textContent = __("Take me to settings");
2983 const confirmBtn = document.createElement("button");
2984 confirmBtn.type = "button";
2985 confirmBtn.className = "wpd-intro__btn wpd-intro__btn--primary";
2986 confirmBtn.textContent = __("Let me moderate");
2987 actions.append(settingsBtn, confirmBtn);
2988 dialog.appendChild(actions);
2989 document.body.appendChild(backdrop);
2990 const cleanup = (result) => {
2991 document.removeEventListener("keydown", onKey);
2992 backdrop.remove();
2993 resolve(result);
2994 };
2995 const onKey = (e) => {
2996 if (e.key === "Escape") {
2997 e.preventDefault();
2998 cleanup("cancel");
2999 }
3000 };
3001 document.addEventListener("keydown", onKey);
3002 confirmBtn.addEventListener("click", () => cleanup("confirm"));
3003 settingsBtn.addEventListener("click", () => cleanup("settings"));
3004 backdrop.addEventListener("click", (e) => {
3005 if (e.target === backdrop) {
3006 cleanup("cancel");
3007 }
3008 });
3009 requestAnimationFrame(() => dialog.focus());
3010 });
3011 }
3012 function statusForTab(tab) {
3013 switch (tab) {
3014 case "pending":
3015 return "hold";
3016 case "all":
3017 return "approve";
3018 case "spam":
3019 return "spam";
3020 case "trash":
3021 return "trash";
3022 case "mine":
3023 return "approve,hold,spam";
3024 }
3025 }
3026 let activeWindowId = "desktop-mode-comments";
3027 function setActiveWindowId(id) {
3028 activeWindowId = id;
3029 }
3030 let activeConfig = null;
3031 function setActiveConfig(config) {
3032 activeConfig = config;
3033 }
3034 function getActiveConfig() {
3035 return activeConfig;
3036 }
3037 function authHeaders(cfg) {
3038 return {
3039 "X-WP-Nonce": cfg.restNonce,
3040 "Content-Type": "application/json"
3041 };
3042 }
3043 async function fetchComments(cfg, params) {
3044 const url = new URL(cfg.commentsUrl);
3045 const qa = cfg.queryArgs ?? {};
3046 Object.entries(qa).forEach(([k, v]) => {
3047 if (k === "status") {
3048 return;
3049 }
3050 if (Array.isArray(v)) {
3051 v.forEach((item) => url.searchParams.append(k, String(item)));
3052 } else if (v !== null && v !== void 0) {
3053 url.searchParams.set(k, String(v));
3054 }
3055 });
3056 url.searchParams.set("status", statusForTab(params.tab));
3057 url.searchParams.set("page", String(params.page));
3058 url.searchParams.set("per_page", String(params.perPage));
3059 if (params.search && params.search.trim() !== "") {
3060 url.searchParams.set("search", params.search.trim());
3061 }
3062 if (params.tab === "mine" && params.currentUserId > 0) {
3063 url.searchParams.set("author", String(params.currentUserId));
3064 }
3065 const response = await trackedFetch(
3066 url.toString(),
3067 {
3068 method: "GET",
3069 credentials: "same-origin",
3070 headers: authHeaders(cfg)
3071 },
3072 {
3073 windowId: activeWindowId,
3074 source: "desktop-mode/comments/list"
3075 }
3076 );
3077 if (!response.ok) {
3078 throw new Error(`Comments list failed: ${response.status}`);
3079 }
3080 const rows = await response.json();
3081 const total = parseInt(
3082 response.headers.get("X-WP-Total") ?? String(rows.length),
3083 10
3084 );
3085 const totalPages = parseInt(
3086 response.headers.get("X-WP-TotalPages") ?? "1",
3087 10
3088 );
3089 return { rows, total, totalPages };
3090 }
3091 async function bulkModerate(cfg, ids, action) {
3092 const response = await trackedFetch(
3093 cfg.bulkUrl,
3094 {
3095 method: "POST",
3096 credentials: "same-origin",
3097 headers: authHeaders(cfg),
3098 body: JSON.stringify({ ids, action })
3099 },
3100 {
3101 windowId: activeWindowId,
3102 source: `desktop-mode/comments/bulk/${action}`
3103 }
3104 );
3105 if (!response.ok) {
3106 throw new Error(`Bulk action ${action} failed: ${response.status}`);
3107 }
3108 return await response.json();
3109 }
3110 async function updateCommentContent(cfg, id, content) {
3111 const url = `${cfg.commentsUrl}/${id}`;
3112 const response = await trackedFetch(
3113 url,
3114 {
3115 method: "POST",
3116 credentials: "same-origin",
3117 headers: authHeaders(cfg),
3118 body: JSON.stringify({ content })
3119 },
3120 {
3121 windowId: activeWindowId,
3122 source: "desktop-mode/comments/edit"
3123 }
3124 );
3125 if (!response.ok) {
3126 throw new Error(`Comment edit failed: ${response.status}`);
3127 }
3128 return await response.json();
3129 }
3130 async function postReply(cfg, parentId, content) {
3131 const response = await trackedFetch(
3132 cfg.replyUrl,
3133 {
3134 method: "POST",
3135 credentials: "same-origin",
3136 headers: authHeaders(cfg),
3137 body: JSON.stringify({ parent: parentId, content })
3138 },
3139 {
3140 windowId: activeWindowId,
3141 source: "desktop-mode/comments/reply"
3142 }
3143 );
3144 if (!response.ok) {
3145 throw new Error(`Reply failed: ${response.status}`);
3146 }
3147 return await response.json();
3148 }
3149 async function fetchAuthorInsights(cfg, email) {
3150 const url = `${cfg.insightsUrlBase}${encodeURIComponent(email)}`;
3151 const response = await trackedFetch(
3152 url,
3153 {
3154 method: "GET",
3155 credentials: "same-origin",
3156 headers: authHeaders(cfg)
3157 },
3158 {
3159 windowId: activeWindowId,
3160 source: "desktop-mode/comments/insights"
3161 }
3162 );
3163 if (!response.ok) {
3164 throw new Error(`Insights failed: ${response.status}`);
3165 }
3166 return await response.json();
3167 }
3168 async function fetchCounts(cfg) {
3169 const response = await trackedFetch(
3170 cfg.countsUrl,
3171 {
3172 method: "GET",
3173 credentials: "same-origin",
3174 headers: authHeaders(cfg)
3175 },
3176 {
3177 windowId: activeWindowId,
3178 source: "desktop-mode/comments/counts",
3179 silent: true
3180 }
3181 );
3182 if (!response.ok) {
3183 throw new Error(`Counts failed: ${response.status}`);
3184 }
3185 return await response.json();
3186 }
3187 async function fetchReplies(cfg, parentId) {
3188 const url = new URL(cfg.commentsUrl);
3189 url.searchParams.set("parent", String(parentId));
3190 url.searchParams.set("per_page", "50");
3191 url.searchParams.set("orderby", "date");
3192 url.searchParams.set("order", "asc");
3193 url.searchParams.set("status", "approve,hold");
3194 const response = await trackedFetch(
3195 url.toString(),
3196 {
3197 method: "GET",
3198 credentials: "same-origin",
3199 headers: authHeaders(cfg)
3200 },
3201 {
3202 windowId: activeWindowId,
3203 source: "desktop-mode/comments/replies"
3204 }
3205 );
3206 if (!response.ok) {
3207 throw new Error(`Replies fetch failed: ${response.status}`);
3208 }
3209 return await response.json();
3210 }
3211 const styles = 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: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}`;
3212 const _WpdButton = class _WpdButton extends Component {
3213 render() {
3214 const disabled = this.disabled !== null;
3215 const type = this.type || "button";
3216 return html`
3217 <button part="button" type=${type} ?disabled=${disabled}>
3218 <slot></slot>
3219 </button>
3220 `;
3221 }
3222 };
3223 _WpdButton.props = ["variant", "disabled", "type", "busy", "fill-cell"];
3224 _WpdButton.styles = [styles];
3225 _WpdButton.help = {
3226 title: "Button",
3227 summary: "Thin wrapper around <button> with consistent variant styling and a slot for the label.",
3228 status: "stable",
3229 since: "0.9.0",
3230 props: [
3231 {
3232 name: "variant",
3233 type: "'primary' | 'secondary' | 'ghost' | 'danger' | 'link'",
3234 default: "ghost",
3235 description: "Visual weight of the button. Use primary for the single attention-grabbing action per surface."
3236 },
3237 {
3238 name: "disabled",
3239 type: "boolean attribute",
3240 description: "Disable pointer + keyboard interaction and dim the chrome."
3241 },
3242 {
3243 name: "type",
3244 type: "'button' | 'submit' | 'reset'",
3245 default: "button",
3246 description: "Forwarded to the underlying native <button>."
3247 },
3248 {
3249 name: "busy",
3250 type: "boolean attribute",
3251 description: "Marks the button as in-progress (e.g., awaiting a fetch)."
3252 },
3253 {
3254 name: "fill-cell",
3255 type: "boolean attribute",
3256 description: "Grow to fill the parent flex/grid cell. Useful for tiled keypads."
3257 }
3258 ],
3259 slots: [{ name: "(default)", description: "Button label." }],
3260 parts: [{ name: "button", description: "Underlying <button> element." }],
3261 cssProps: [
3262 { name: "--wpd-button-bg", description: "Background color." },
3263 { name: "--wpd-button-fg", description: "Text color." },
3264 { name: "--wpd-button-border", description: "Border shorthand." },
3265 { name: "--wpd-button-border-radius", default: "6px" },
3266 { name: "--wpd-button-padding", default: "6px 12px" },
3267 {
3268 name: "--wpd-button-min-height",
3269 description: "Minimum height when fill-cell is set."
3270 }
3271 ],
3272 example: html`
3273 <wpd-cluster gap="8">
3274 <wpd-button variant="primary">Primary</wpd-button>
3275 <wpd-button variant="secondary">Secondary</wpd-button>
3276 <wpd-button variant="ghost">Ghost</wpd-button>
3277 <wpd-button variant="danger">Danger</wpd-button>
3278 <wpd-button variant="link">Link</wpd-button>
3279 </wpd-cluster>
3280 `
3281 };
3282 let WpdButton = _WpdButton;
3283 defineComponent("wpd-button", WpdButton);
3284 function getApi() {
3285 return window.wp?.desktop;
3286 }
3287 function showToast(message, duration = 4e3, actions) {
3288 const api = getApi();
3289 if (api?.showToast) {
3290 api.showToast({ message, duration, actions });
3291 return;
3292 }
3293 console.info("[comments-window]", message);
3294 }
3295 function publish(channel, payload) {
3296 getApi()?.activity?.publish?.(channel, payload);
3297 }
3298 function updateDockBadge(count) {
3299 const api = getApi();
3300 api?.dock?.setBadge?.("desktop-mode-comments", count);
3301 api?.taskbar?.setBadge?.("desktop-mode-comments", count);
3302 api?.icons?.setBadge?.("desktop-mode-comments", count);
3303 }
3304 function readConfig() {
3305 const cfg = window;
3306 const fromShared = cfg.desktopModeWindowConfig?.["desktop-mode-comments"];
3307 if (fromShared) {
3308 return fromShared;
3309 }
3310 const fromLazy = cfg.desktopModeNativeWindowConfig?.["desktop-mode-comments"];
3311 return fromLazy ?? null;
3312 }
3313 function spamChipFor(row) {
3314 const score = Math.max(0, Math.min(100, row.desktop_mode_spam_score));
3315 let tone = "positive";
3316 if (score >= 70) {
3317 tone = "danger";
3318 } else if (score >= 40) {
3319 tone = "warning";
3320 }
3321 const chip = document.createElement("wpd-chip");
3322 chip.setAttribute("label", String(score));
3323 chip.setAttribute("tone", tone);
3324 chip.dataset.score = String(score);
3325 chip.dataset.tone = tone;
3326 chip.style.cssText = [
3327 "--wpd-chip-gap:0",
3328 "--wpd-chip-padding:2px 12px",
3329 "--wpd-chip-font-weight:700",
3330 "min-inline-size:44px",
3331 "justify-content:center",
3332 "font-variant-numeric:tabular-nums"
3333 ].join(";");
3334 if (row.desktop_mode_ai_verdict) {
3335 chip.dataset.ai = "1";
3336 chip.style.boxShadow = "0 0 0 2px rgba(99,102,241,0.5)";
3337 chip.style.position = "relative";
3338 chip.style.borderRadius = "999px";
3339 const dot = document.createElement("span");
3340 dot.style.cssText = [
3341 "position:absolute",
3342 "top:-3px",
3343 "inset-inline-end:-3px",
3344 "width:8px",
3345 "height:8px",
3346 "border-radius:50%",
3347 "background:linear-gradient(135deg,#818cf8,#6366f1)",
3348 "box-shadow:0 0 0 2px #fff",
3349 "pointer-events:none"
3350 ].join(";");
3351 chip.appendChild(dot);
3352 }
3353 const notes = [];
3354 if (row.desktop_mode_akismet === "true") {
3355 notes.push(__("Akismet flagged this comment as spam."));
3356 } else if (row.desktop_mode_akismet === "false") {
3357 notes.push(__("Akismet cleared this comment."));
3358 }
3359 const verdict = row.desktop_mode_ai_verdict;
3360 if (verdict) {
3361 if (verdict.spam) {
3362 notes.push(__("AI: looks like promotional spam."));
3363 }
3364 if (verdict.harmful) {
3365 notes.push(__("AI: hostile / abusive tone."));
3366 }
3367 if (!verdict.spam && !verdict.harmful) {
3368 notes.push(__("AI: looks safe."));
3369 }
3370 if (verdict.summary) {
3371 notes.push(verdict.summary);
3372 }
3373 }
3374 chip.title = notes.length > 0 ? sprintf(
3375 /* translators: 1: spam score 0–100, 2: extra moderation notes. */
3376 __("Spam score: %1$d / 100. %2$s"),
3377 score,
3378 notes.join(" ")
3379 ) : sprintf(
3380 /* translators: %d: spam score 0–100. */
3381 __("Spam score: %d / 100."),
3382 score
3383 );
3384 return chip;
3385 }
3386 function mountRichEditor(placeholder) {
3387 const wrap = document.createElement("div");
3388 wrap.className = "desktop-mode-comments__reply";
3389 const toolbar = document.createElement("div");
3390 toolbar.className = "desktop-mode-comments__reply-toolbar";
3391 const cmds = [
3392 { cmd: "bold", icon: "dashicons-editor-bold", label: __("Bold") },
3393 { cmd: "italic", icon: "dashicons-editor-italic", label: __("Italic") },
3394 { cmd: "insertUnorderedList", icon: "dashicons-editor-ul", label: __("Bulleted list") },
3395 { cmd: "insertOrderedList", icon: "dashicons-editor-ol", label: __("Numbered list") }
3396 ];
3397 cmds.forEach((c) => {
3398 const btn = document.createElement("button");
3399 btn.type = "button";
3400 btn.className = "desktop-mode-comments__reply-tool";
3401 btn.title = c.label;
3402 btn.setAttribute("aria-label", c.label);
3403 btn.innerHTML = `<span class="dashicons ${c.icon}" aria-hidden="true"></span>`;
3404 btn.addEventListener("mousedown", (e) => e.preventDefault());
3405 btn.addEventListener("click", () => {
3406 document.execCommand(c.cmd);
3407 editable.focus();
3408 });
3409 toolbar.appendChild(btn);
3410 });
3411 const linkBtn = document.createElement("button");
3412 linkBtn.type = "button";
3413 linkBtn.className = "desktop-mode-comments__reply-tool";
3414 linkBtn.title = __("Wrap selection in a link");
3415 linkBtn.setAttribute("aria-label", __("Wrap selection in a link"));
3416 linkBtn.innerHTML = '<span class="dashicons dashicons-admin-links" aria-hidden="true"></span>';
3417 linkBtn.addEventListener("mousedown", (e) => e.preventDefault());
3418 linkBtn.addEventListener("click", () => {
3419 const selection = editable.ownerDocument.getSelection?.()?.toString().trim() ?? "";
3420 if (/^https?:\/\//i.test(selection)) {
3421 document.execCommand("createLink", false, selection);
3422 } else {
3423 showToast(
3424 __("Select a full URL (https://…) in your reply, then click the link button.")
3425 );
3426 }
3427 });
3428 toolbar.appendChild(linkBtn);
3429 const editable = document.createElement("div");
3430 editable.className = "desktop-mode-comments__reply-input";
3431 editable.contentEditable = "true";
3432 editable.setAttribute("role", "textbox");
3433 editable.setAttribute("aria-multiline", "true");
3434 editable.setAttribute("aria-label", placeholder);
3435 editable.dataset.placeholder = placeholder;
3436 wrap.append(toolbar, editable);
3437 return {
3438 root: wrap,
3439 getValue: () => editable.innerHTML.trim(),
3440 focus: () => editable.focus(),
3441 destroy: () => wrap.remove()
3442 };
3443 }
3444 function mountPlainEditor(placeholder) {
3445 const wrap = document.createElement("div");
3446 wrap.className = "desktop-mode-comments__reply desktop-mode-comments__reply--plain";
3447 const ta = document.createElement("textarea");
3448 ta.className = "desktop-mode-comments__reply-input";
3449 ta.placeholder = placeholder;
3450 ta.rows = 3;
3451 wrap.appendChild(ta);
3452 return {
3453 root: wrap,
3454 getValue: () => ta.value.trim(),
3455 focus: () => ta.focus(),
3456 destroy: () => wrap.remove()
3457 };
3458 }
3459 function mountReplyEditor(flavor, placeholder) {
3460 if (flavor === "plain") {
3461 return mountPlainEditor(placeholder);
3462 }
3463 return mountRichEditor(placeholder);
3464 }
3465 function ensureBackdrop(host) {
3466 const windowRoot = host.closest(".desktop-mode-window") ?? host.parentElement;
3467 if (!windowRoot) {
3468 return null;
3469 }
3470 let backdrop = windowRoot.querySelector(
3471 ":scope > [data-desktop-mode-comments-drawer-backdrop]"
3472 );
3473 if (!backdrop) {
3474 backdrop = document.createElement("div");
3475 backdrop.className = "desktop-mode-comments__drawer-backdrop";
3476 backdrop.setAttribute("data-desktop-mode-comments-drawer-backdrop", "");
3477 windowRoot.insertBefore(backdrop, windowRoot.firstChild);
3478 }
3479 return backdrop;
3480 }
3481 function closeAuthorDrawer(host) {
3482 host.removeAttribute("data-open");
3483 host.setAttribute("aria-hidden", "true");
3484 const backdrop = ensureBackdrop(host);
3485 backdrop?.removeAttribute("data-open");
3486 const tearDown = host.__teardown;
3487 if (tearDown) {
3488 tearDown();
3489 delete host.__teardown;
3490 }
3491 }
3492 async function openAuthorDrawer(cfg, host, email) {
3493 const backdrop = ensureBackdrop(host);
3494 const wasOpen = host.getAttribute("data-open") === "true";
3495 host.replaceChildren();
3496 const loading = document.createElement("p");
3497 loading.className = "desktop-mode-comments__drawer-loading";
3498 loading.textContent = __("Loading author insights…");
3499 host.appendChild(loading);
3500 if (!wasOpen) {
3501 host.setAttribute("aria-hidden", "false");
3502 backdrop?.setAttribute("data-open", "false");
3503 requestAnimationFrame(() => {
3504 host.setAttribute("data-open", "true");
3505 backdrop?.setAttribute("data-open", "true");
3506 });
3507 const onEsc = (e) => {
3508 if (e.key === "Escape") {
3509 e.preventDefault();
3510 closeAuthorDrawer(host);
3511 }
3512 };
3513 const onBackdropClick = () => closeAuthorDrawer(host);
3514 document.addEventListener("keydown", onEsc);
3515 backdrop?.addEventListener("click", onBackdropClick);
3516 host.__teardown = () => {
3517 document.removeEventListener("keydown", onEsc);
3518 backdrop?.removeEventListener("click", onBackdropClick);
3519 };
3520 }
3521 let data;
3522 try {
3523 data = await fetchAuthorInsights(cfg, email);
3524 } catch (err) {
3525 host.replaceChildren();
3526 const errEl = document.createElement("p");
3527 errEl.className = "desktop-mode-comments__drawer-error";
3528 errEl.textContent = err instanceof Error ? err.message : __("Could not load insights.");
3529 host.appendChild(errEl);
3530 return;
3531 }
3532 host.replaceChildren();
3533 const header = document.createElement("header");
3534 header.className = "desktop-mode-comments__drawer-header";
3535 const avatar = document.createElement("wpd-avatar");
3536 avatar.setAttribute("size", "64");
3537 if (data.userName) {
3538 avatar.setAttribute("name", data.userName);
3539 }
3540 if (data.avatarUrl) {
3541 applyAvatarSrc(avatar, data.avatarUrl);
3542 }
3543 if (data.userId > 0) {
3544 avatar.setAttribute("user-id", String(data.userId));
3545 }
3546 avatar.className = "desktop-mode-comments__drawer-avatar";
3547 const headerText = document.createElement("div");
3548 const name = document.createElement("h2");
3549 name.textContent = data.userName || data.email;
3550 const sub = document.createElement("p");
3551 sub.textContent = data.email;
3552 sub.className = "desktop-mode-comments__drawer-sub";
3553 headerText.append(name, sub);
3554 header.append(avatar, headerText);
3555 host.appendChild(header);
3556 const reliability = document.createElement("div");
3557 reliability.className = "desktop-mode-comments__drawer-meter";
3558 const reliabilityLabel = document.createElement("span");
3559 reliabilityLabel.textContent = sprintf(
3560 /* translators: %d: 0–100 reliability score. */
3561 __("Reliability: %d / 100"),
3562 data.reliability
3563 );
3564 const meter = document.createElement("div");
3565 meter.className = "desktop-mode-comments__drawer-bar";
3566 meter.style.setProperty("--value", `${data.reliability}%`);
3567 reliability.append(reliabilityLabel, meter);
3568 host.appendChild(reliability);
3569 const stats = document.createElement("dl");
3570 stats.className = "desktop-mode-comments__drawer-stats";
3571 const lines = [
3572 [__("Total comments"), String(data.total)],
3573 [__("Approved"), String(data.counts.approve)],
3574 [__("Pending"), String(data.counts.hold)],
3575 [__("Spam"), String(data.counts.spam)],
3576 [__("Trash"), String(data.counts.trash)],
3577 [
3578 __("First seen"),
3579 data.oldest ? (/* @__PURE__ */ new Date(data.oldest + "Z")).toLocaleDateString() : "—"
3580 ],
3581 [
3582 __("Last seen"),
3583 data.newest ? (/* @__PURE__ */ new Date(data.newest + "Z")).toLocaleDateString() : "—"
3584 ]
3585 ];
3586 lines.forEach(([label, value]) => {
3587 const dt = document.createElement("dt");
3588 dt.textContent = label;
3589 const dd = document.createElement("dd");
3590 dd.textContent = value;
3591 stats.append(dt, dd);
3592 });
3593 host.appendChild(stats);
3594 const closeBtn = document.createElement("button");
3595 closeBtn.type = "button";
3596 closeBtn.className = "desktop-mode-comments__drawer-close";
3597 closeBtn.textContent = __("Close");
3598 closeBtn.addEventListener("click", () => closeAuthorDrawer(host));
3599 host.appendChild(closeBtn);
3600 publish("desktop-mode-comments/insights-opened", { email: data.email });
3601 }
3602 const undoStack = [];
3603 function inverseAction(action) {
3604 switch (action) {
3605 case "approve":
3606 return "unapprove";
3607 case "unapprove":
3608 return "approve";
3609 case "spam":
3610 return "unspam";
3611 case "unspam":
3612 return "spam";
3613 case "trash":
3614 return "untrash";
3615 case "untrash":
3616 return "trash";
3617 }
3618 }
3619 function actionPastTense(action, count) {
3620 switch (action) {
3621 case "approve":
3622 return sprintf(__("Approved %d."), count);
3623 case "unapprove":
3624 return sprintf(__("Unapproved %d."), count);
3625 case "spam":
3626 return sprintf(__("Marked %d as spam."), count);
3627 case "unspam":
3628 return sprintf(__("Un-spammed %d."), count);
3629 case "trash":
3630 return sprintf(__("Trashed %d."), count);
3631 case "untrash":
3632 return sprintf(__("Restored %d."), count);
3633 }
3634 }
3635 async function renderCommentsWindow(body) {
3636 const cfg = readConfig();
3637 if (!cfg) {
3638 body.innerHTML = `<p class="desktop-mode-comments__fatal">${__(
3639 "Comments window configuration missing."
3640 )}</p>`;
3641 return;
3642 }
3643 setActiveConfig(cfg);
3644 const tabsEl = body.querySelector(
3645 "[data-desktop-mode-comments-tabs]"
3646 );
3647 const newPillEl = body.querySelector(
3648 "[data-desktop-mode-comments-new-pill]"
3649 );
3650 const drawerEl = body.querySelector(
3651 "[data-desktop-mode-comments-drawer]"
3652 );
3653 if (!tabsEl || !newPillEl || !drawerEl) {
3654 return;
3655 }
3656 const helpEl = body.querySelector(
3657 "[data-desktop-mode-comments-help]"
3658 );
3659 const panels = {
3660 pending: makePanel(body, "pending", cfg),
3661 all: makePanel(body, "all", cfg),
3662 spam: makePanel(body, "spam", cfg),
3663 trash: makePanel(body, "trash", cfg),
3664 mine: makePanel(body, "mine", cfg)
3665 };
3666 let activeTab = "pending";
3667 let lastSeenPending = 0;
3668 const refresh = async (tab, opts = {}) => {
3669 const state = panels[tab];
3670 if (!state.table || !state.tableHost) {
3671 return;
3672 }
3673 state.table.setAttribute("loading", "");
3674 try {
3675 const params = {
3676 tab,
3677 page: state.page,
3678 perPage: state.perPage,
3679 search: state.search,
3680 currentUserId: cfg.currentUserId
3681 };
3682 const result = await fetchComments(cfg, params);
3683 state.rows = result.rows;
3684 state.total = result.total;
3685 state.totalPages = result.totalPages;
3686 state.repliesByParent.clear();
3687 state.openReplies.clear();
3688 await customElements.whenDefined("wpd-table");
3689 state.table.data = state.rows;
3690 updatePager(state);
3691 if (tab === "pending" && !opts.force) {
3692 if (lastSeenPending === 0) {
3693 lastSeenPending = result.total;
3694 }
3695 }
3696 } catch (err) {
3697 console.error("[comments-window] refresh failed:", err);
3698 showToast(
3699 err instanceof Error ? err.message : __("Could not load comments.")
3700 );
3701 } finally {
3702 state.table.removeAttribute("loading");
3703 }
3704 };
3705 const setActive = (tab) => {
3706 activeTab = tab;
3707 tabsEl.setAttribute("value", tab);
3708 void refresh(tab);
3709 };
3710 tabsEl.addEventListener("wpd-tab-change", (e) => {
3711 const next = e.detail?.value;
3712 if (next) {
3713 setActive(next);
3714 }
3715 });
3716 Object.values(panels).forEach((state) => {
3717 wirePanel(state, cfg, async (ids, action) => {
3718 await runBulk(ids, action, state, refresh, cfg);
3719 }, drawerEl);
3720 });
3721 setActive("pending");
3722 let countsTimer = null;
3723 const pollCounts = async () => {
3724 try {
3725 const counts = await fetchCounts(cfg);
3726 updateDockBadge(counts.pending);
3727 if (activeTab === "pending") {
3728 const diff = counts.pending - lastSeenPending;
3729 if (diff > 0) {
3730 newPillEl.hidden = false;
3731 newPillEl.replaceChildren();
3732 const label = document.createElement("span");
3733 label.textContent = sprintf(
3734 /* translators: %d: number of new pending comments. */
3735 __("%d new pending — reload"),
3736 diff
3737 );
3738 const btn = document.createElement("button");
3739 btn.type = "button";
3740 btn.textContent = __("Reload");
3741 btn.addEventListener("click", () => {
3742 newPillEl.hidden = true;
3743 lastSeenPending = counts.pending;
3744 void refresh("pending", { force: true });
3745 });
3746 newPillEl.append(label, btn);
3747 }
3748 }
3749 } catch {
3750 }
3751 };
3752 countsTimer = window.setInterval(pollCounts, 3e4);
3753 void pollCounts();
3754 const onKey = (e) => {
3755 const ownerDoc = body.ownerDocument;
3756 if (!body.contains(ownerDoc.activeElement)) {
3757 return;
3758 }
3759 const target = ownerDoc.activeElement;
3760 const editing = !!target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable || target.tagName === "WPD-TEXT-FIELD");
3761 if (editing) {
3762 return;
3763 }
3764 const state = panels[activeTab];
3765 if (!state.table) {
3766 return;
3767 }
3768 const ids = Array.from(state.table.selection).map((v) => Number(v)).filter(Boolean);
3769 switch (e.key) {
3770 case "j":
3771 case "k":
3772 e.preventDefault();
3773 moveFocus(state, e.key === "j" ? 1 : -1);
3774 break;
3775 case "a":
3776 if (ids.length > 0) {
3777 e.preventDefault();
3778 const targetAction = activeTab === "pending" ? "approve" : "unapprove";
3779 void runBulk(ids, targetAction, state, refresh, cfg);
3780 }
3781 break;
3782 case "s":
3783 if (ids.length > 0) {
3784 e.preventDefault();
3785 void runBulk(
3786 ids,
3787 activeTab === "spam" ? "unspam" : "spam",
3788 state,
3789 refresh,
3790 cfg
3791 );
3792 }
3793 break;
3794 case "d":
3795 if (ids.length > 0) {
3796 e.preventDefault();
3797 void runBulk(
3798 ids,
3799 activeTab === "trash" ? "untrash" : "trash",
3800 state,
3801 refresh,
3802 cfg
3803 );
3804 }
3805 break;
3806 case "u":
3807 e.preventDefault();
3808 void undoLast(cfg, refresh, activeTab);
3809 break;
3810 case "r":
3811 if (ids.length === 1) {
3812 e.preventDefault();
3813 openReplyFor(state, ids[0], cfg);
3814 }
3815 break;
3816 case "e":
3817 if (ids.length === 1) {
3818 e.preventDefault();
3819 openEditFor(state, ids[0], cfg, refresh);
3820 }
3821 break;
3822 case "?":
3823 if (helpEl) {
3824 e.preventDefault();
3825 helpEl.hidden = !helpEl.hidden;
3826 helpEl.querySelector("[data-desktop-mode-comments-help-close]")?.addEventListener(
3827 "click",
3828 () => {
3829 helpEl.hidden = true;
3830 },
3831 { once: true }
3832 );
3833 }
3834 break;
3835 }
3836 };
3837 document.addEventListener("keydown", onKey);
3838 if (!cfg.introSeen) {
3839 void (async () => {
3840 const outcome = await showCommentsIntroDialog();
3841 if (outcome !== "cancel") {
3842 try {
3843 await trackedFetch(
3844 cfg.introUrl,
3845 {
3846 method: "POST",
3847 credentials: "same-origin",
3848 headers: {
3849 "X-WP-Nonce": cfg.restNonce,
3850 "Content-Type": "application/json"
3851 },
3852 body: JSON.stringify({ slug: cfg.introSlug })
3853 },
3854 { source: "desktop-mode/comments/intro-seen", silent: true }
3855 );
3856 } catch {
3857 }
3858 }
3859 if (outcome === "settings") {
3860 getApi()?.openWindow?.({ id: "desktop-mode-os-settings" });
3861 }
3862 })();
3863 }
3864 const onClosed = (e) => {
3865 const detail = e.detail;
3866 if (detail?.windowId !== "desktop-mode-comments") {
3867 return;
3868 }
3869 if (countsTimer) {
3870 window.clearInterval(countsTimer);
3871 countsTimer = null;
3872 }
3873 document.removeEventListener("keydown", onKey);
3874 document.removeEventListener("desktop-mode-window-closed", onClosed);
3875 setActiveConfig(null);
3876 };
3877 document.addEventListener("desktop-mode-window-closed", onClosed);
3878 }
3879 function makePanel(body, tab, cfg) {
3880 const root = body.querySelector(
3881 `[data-desktop-mode-comments-panel="${tab}"]`
3882 );
3883 if (!root) {
3884 throw new Error(`[comments-window] panel ${tab} not found`);
3885 }
3886 root.innerHTML = `
3887 <header class="desktop-mode-comments__toolbar">
3888 <div class="desktop-mode-comments__toolbar-left">
3889 <wpd-text-field
3890 data-desktop-mode-comments-search
3891 placeholder="${__("Search comments…")}"
3892 ></wpd-text-field>
3893 </div>
3894 <div class="desktop-mode-comments__toolbar-right" data-desktop-mode-comments-bulk hidden>
3895 <span class="desktop-mode-comments__count" data-desktop-mode-comments-count></span>
3896 <span class="desktop-mode-comments__bulk-actions" data-desktop-mode-comments-bulk-actions></span>
3897 </div>
3898 <div class="desktop-mode-comments__toolbar-trailing">
3899 <wpd-button variant="ghost" data-desktop-mode-comments-refresh title="${__(
3900 "Refresh"
3901 )}">
3902 <span class="dashicons dashicons-update" aria-hidden="true"></span>
3903 </wpd-button>
3904 </div>
3905 </header>
3906 <div class="desktop-mode-comments__body" data-desktop-mode-comments-body>
3907 <wpd-table
3908 data-desktop-mode-comments-table
3909 selectable="multi"
3910 sticky-header
3911 hover
3912 striped
3913 bordered
3914 loading
3915 >
3916 <div slot="empty" class="desktop-mode-comments__empty">
3917 <span class="dashicons dashicons-admin-comments" aria-hidden="true"></span>
3918 <p>${__("No comments to moderate here.")}</p>
3919 </div>
3920 </wpd-table>
3921 </div>
3922 <footer class="desktop-mode-comments__pager">
3923 <div class="desktop-mode-comments__pager-meta" data-desktop-mode-comments-page-indicator>—</div>
3924 <div class="desktop-mode-comments__pager-nav">
3925 <wpd-button variant="ghost" data-desktop-mode-comments-prev disabled>
3926 <span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>
3927 ${__("Previous")}
3928 </wpd-button>
3929 <wpd-button variant="ghost" data-desktop-mode-comments-next disabled>
3930 ${__("Next")}
3931 <span class="dashicons dashicons-arrow-right-alt2" aria-hidden="true"></span>
3932 </wpd-button>
3933 <label class="desktop-mode-comments__pager-perpage">
3934 ${__("Per page")}
3935 <select data-desktop-mode-comments-per-page>
3936 <option value="10">10</option>
3937 <option value="20" selected>20</option>
3938 <option value="50">50</option>
3939 <option value="100">100</option>
3940 </select>
3941 </label>
3942 </div>
3943 </footer>
3944 `;
3945 return {
3946 root,
3947 tab,
3948 page: 1,
3949 perPage: cfg.defaultPerPage,
3950 search: "",
3951 total: 0,
3952 totalPages: 1,
3953 rows: [],
3954 repliesByParent: /* @__PURE__ */ new Map(),
3955 openReplies: /* @__PURE__ */ new Set()
3956 };
3957 }
3958 function buildColumns(cfg, state, drawerEl) {
3959 const cols = [];
3960 cols.push({
3961 key: "author_name",
3962 label: __("Author"),
3963 sticky: true,
3964 minWidth: "180px",
3965 render: (_v, row) => {
3966 const wrap = document.createElement("div");
3967 wrap.style.cssText = "display:flex;gap:10px;align-items:center;min-width:0;";
3968 const avatar = document.createElement("wpd-avatar");
3969 avatar.setAttribute("size", "32");
3970 avatar.setAttribute("clickable", "");
3971 avatar.setAttribute("title", __("Show author insights"));
3972 if (row.author_name) {
3973 avatar.setAttribute("name", row.author_name);
3974 }
3975 const rawAvatarUrl = row.author_avatar_urls?.["48"] ?? "";
3976 if (rawAvatarUrl) {
3977 applyAvatarSrc(avatar, rawAvatarUrl);
3978 }
3979 if (row.author > 0) {
3980 avatar.setAttribute("user-id", String(row.author));
3981 }
3982 avatar.addEventListener("wpd-avatar-click", (e) => {
3983 e.stopPropagation();
3984 void openAuthorDrawer(cfg, drawerEl, row.author_email);
3985 });
3986 const meta = document.createElement("div");
3987 meta.style.cssText = "display:flex;flex-direction:column;gap:2px;min-width:0;line-height:1.3;";
3988 const name = document.createElement("strong");
3989 name.style.cssText = "font-weight:600;color:#1d2327;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
3990 name.textContent = row.author_name || __("Anonymous");
3991 const email = document.createElement("small");
3992 email.style.cssText = "color:#646970;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
3993 email.textContent = row.author_email;
3994 meta.append(name, email);
3995 wrap.append(avatar, meta);
3996 return wrap;
3997 }
3998 });
3999 cols.push({
4000 key: "content",
4001 label: __("Comment"),
4002 minWidth: "320px",
4003 render: (_v, row) => {
4004 const wrap = document.createElement("div");
4005 wrap.className = "desktop-mode-comments__content";
4006 const body = document.createElement("div");
4007 body.className = "desktop-mode-comments__content-body";
4008 body.innerHTML = row.content?.rendered ?? "";
4009 wrap.appendChild(body);
4010 if (row.desktop_mode_replies_count > 0) {
4011 const tog = document.createElement("button");
4012 tog.type = "button";
4013 tog.className = "desktop-mode-comments__replies-toggle";
4014 tog.textContent = sprintf(
4015 /* translators: %d: number of direct replies. */
4016 __("+ %d replies"),
4017 row.desktop_mode_replies_count
4018 );
4019 tog.addEventListener("click", (e) => {
4020 e.stopPropagation();
4021 void toggleReplies(state, row.id, cfg, wrap);
4022 });
4023 wrap.appendChild(tog);
4024 }
4025 return wrap;
4026 }
4027 });
4028 cols.push({
4029 key: "desktop_mode_post_title",
4030 label: __("In response to"),
4031 minWidth: "180px",
4032 render: (_v, row) => {
4033 if (!row.desktop_mode_post_link) {
4034 return row.desktop_mode_post_title;
4035 }
4036 const a = document.createElement("a");
4037 a.href = row.desktop_mode_post_link;
4038 a.target = "_blank";
4039 a.rel = "noopener";
4040 a.textContent = row.desktop_mode_post_title;
4041 return a;
4042 }
4043 });
4044 cols.push({
4045 key: "desktop_mode_spam_score",
4046 label: __("Spam"),
4047 align: "center",
4048 sortable: true,
4049 width: "78px",
4050 render: (_v, row) => spamChipFor(row)
4051 });
4052 cols.push({
4053 key: "date_gmt",
4054 label: __("Submitted on"),
4055 sortable: true,
4056 width: "160px",
4057 render: (_v, row) => {
4058 try {
4059 return (/* @__PURE__ */ new Date(row.date_gmt + "Z")).toLocaleString();
4060 } catch {
4061 return row.date_gmt;
4062 }
4063 }
4064 });
4065 return cols;
4066 }
4067 function wirePanel(state, cfg, runBulkLocal, drawerEl) {
4068 const table = state.root.querySelector(
4069 "[data-desktop-mode-comments-table]"
4070 );
4071 const body = state.root.querySelector(
4072 "[data-desktop-mode-comments-body]"
4073 );
4074 const bulkBar = state.root.querySelector(
4075 "[data-desktop-mode-comments-bulk]"
4076 );
4077 const bulkActionsHost = state.root.querySelector(
4078 "[data-desktop-mode-comments-bulk-actions]"
4079 );
4080 const countEl = state.root.querySelector(
4081 "[data-desktop-mode-comments-count]"
4082 );
4083 if (!table || !body || !bulkBar || !bulkActionsHost || !countEl) {
4084 return;
4085 }
4086 const searchEl = state.root.querySelector(
4087 "[data-desktop-mode-comments-search]"
4088 );
4089 const refreshBtn = state.root.querySelector(
4090 "[data-desktop-mode-comments-refresh]"
4091 );
4092 const prevBtn = state.root.querySelector(
4093 "[data-desktop-mode-comments-prev]"
4094 );
4095 const nextBtn = state.root.querySelector(
4096 "[data-desktop-mode-comments-next]"
4097 );
4098 const perPageSel = state.root.querySelector(
4099 "[data-desktop-mode-comments-per-page]"
4100 );
4101 state.table = table;
4102 state.tableHost = body;
4103 void customElements.whenDefined("wpd-table").then(() => {
4104 table.columns = buildColumns(cfg, state, drawerEl);
4105 table.getRowId = (row) => row.id;
4106 if (state.rows.length > 0) {
4107 table.data = state.rows;
4108 }
4109 });
4110 const renderBulkActions = () => {
4111 bulkActionsHost.replaceChildren();
4112 const actions = [];
4113 if (state.tab === "pending" || state.tab === "all" || state.tab === "mine") {
4114 actions.push({ label: __("Approve"), action: "approve" });
4115 actions.push({ label: __("Unapprove"), action: "unapprove" });
4116 }
4117 if (state.tab === "spam") {
4118 actions.push({ label: __("Not spam"), action: "unspam" });
4119 } else {
4120 actions.push({ label: __("Spam"), action: "spam" });
4121 }
4122 if (state.tab === "trash") {
4123 actions.push({ label: __("Restore"), action: "untrash" });
4124 } else {
4125 actions.push({ label: __("Trash"), action: "trash", danger: true });
4126 }
4127 actions.forEach((a) => {
4128 const btn = document.createElement("wpd-button");
4129 btn.setAttribute("variant", a.danger ? "danger" : "ghost");
4130 btn.textContent = a.label;
4131 btn.addEventListener("click", () => {
4132 const sel = Array.from(table.selection).map((v) => Number(v)).filter(Boolean);
4133 if (sel.length > 0) {
4134 void runBulkLocal(sel, a.action);
4135 }
4136 });
4137 bulkActionsHost.appendChild(btn);
4138 });
4139 };
4140 renderBulkActions();
4141 table.addEventListener("wpd-table-selection-change", () => {
4142 const count = table.selection.size;
4143 bulkBar.hidden = count === 0;
4144 countEl.textContent = sprintf(
4145 /* translators: %d: count of selected rows. */
4146 __("%d selected"),
4147 count
4148 );
4149 });
4150 let searchDebounce = null;
4151 searchEl?.addEventListener("wpd-input-change", (e) => {
4152 const val = e.detail?.value ?? "";
4153 if (searchDebounce) {
4154 window.clearTimeout(searchDebounce);
4155 }
4156 searchDebounce = window.setTimeout(() => {
4157 state.search = String(val);
4158 state.page = 1;
4159 void reloadActivePanel(state);
4160 }, 300);
4161 });
4162 refreshBtn?.addEventListener("click", () => {
4163 void reloadActivePanel(state);
4164 });
4165 prevBtn?.addEventListener("click", () => {
4166 if (state.page > 1) {
4167 state.page -= 1;
4168 void reloadActivePanel(state);
4169 }
4170 });
4171 nextBtn?.addEventListener("click", () => {
4172 if (state.page < state.totalPages) {
4173 state.page += 1;
4174 void reloadActivePanel(state);
4175 }
4176 });
4177 perPageSel?.addEventListener("change", () => {
4178 state.perPage = parseInt(perPageSel.value, 10) || 20;
4179 state.page = 1;
4180 void reloadActivePanel(state);
4181 });
4182 }
4183 async function reloadActivePanel(state) {
4184 const cfg = getActiveConfig();
4185 if (!cfg || !state.table) {
4186 return;
4187 }
4188 state.table.setAttribute("loading", "");
4189 try {
4190 const result = await fetchComments(cfg, {
4191 tab: state.tab,
4192 page: state.page,
4193 perPage: state.perPage,
4194 search: state.search,
4195 currentUserId: cfg.currentUserId
4196 });
4197 state.rows = result.rows;
4198 state.total = result.total;
4199 state.totalPages = result.totalPages;
4200 await customElements.whenDefined("wpd-table");
4201 state.table.data = state.rows;
4202 updatePager(state);
4203 } catch (err) {
4204 console.error("[comments-window] reload failed:", err);
4205 showToast(
4206 err instanceof Error ? err.message : __("Could not load comments.")
4207 );
4208 } finally {
4209 state.table.removeAttribute("loading");
4210 }
4211 }
4212 function updatePager(state) {
4213 const indicator = state.root.querySelector(
4214 "[data-desktop-mode-comments-page-indicator]"
4215 );
4216 const prevBtn = state.root.querySelector(
4217 "[data-desktop-mode-comments-prev]"
4218 );
4219 const nextBtn = state.root.querySelector(
4220 "[data-desktop-mode-comments-next]"
4221 );
4222 if (indicator) {
4223 indicator.textContent = sprintf(
4224 /* translators: 1: current page, 2: total pages, 3: total rows. */
4225 __("Page %1$d of %2$d (%3$d total)"),
4226 state.page,
4227 state.totalPages,
4228 state.total
4229 );
4230 }
4231 if (prevBtn) {
4232 prevBtn.disabled = state.page <= 1;
4233 }
4234 if (nextBtn) {
4235 nextBtn.disabled = state.page >= state.totalPages;
4236 }
4237 }
4238 async function toggleReplies(state, parentId, cfg, host) {
4239 const existing = host.querySelector(".desktop-mode-comments__replies");
4240 if (existing) {
4241 existing.remove();
4242 state.openReplies.delete(parentId);
4243 return;
4244 }
4245 state.openReplies.add(parentId);
4246 let replies = state.repliesByParent.get(parentId);
4247 if (!replies) {
4248 try {
4249 replies = await fetchReplies(cfg, parentId);
4250 state.repliesByParent.set(parentId, replies);
4251 } catch (err) {
4252 showToast(
4253 err instanceof Error ? err.message : __("Could not load replies.")
4254 );
4255 return;
4256 }
4257 }
4258 const tree = document.createElement("div");
4259 tree.className = "desktop-mode-comments__replies";
4260 replies.forEach((r) => {
4261 const item = document.createElement("div");
4262 item.className = "desktop-mode-comments__reply-row";
4263 const author = document.createElement("strong");
4264 author.textContent = r.author_name || __("Anonymous");
4265 const sep = document.createTextNode(" — ");
4266 const cnt = document.createElement("span");
4267 cnt.innerHTML = r.content?.rendered ?? "";
4268 item.append(author, sep, cnt);
4269 tree.appendChild(item);
4270 });
4271 host.appendChild(tree);
4272 }
4273 function openReplyFor(state, id, cfg) {
4274 const row = state.rows.find((r) => r.id === id);
4275 if (!row) {
4276 return;
4277 }
4278 const tr = state.tableHost?.querySelector(
4279 `tr[data-row-id="${id}"]`
4280 );
4281 const host = tr?.nextElementSibling?.classList.contains(
4282 "desktop-mode-comments__inline-host"
4283 ) ? tr.nextElementSibling : (() => {
4284 const ins = document.createElement("div");
4285 ins.className = "desktop-mode-comments__inline-host";
4286 tr?.after(ins);
4287 return ins;
4288 })();
4289 host.replaceChildren();
4290 const editor = mountReplyEditor(
4291 cfg.replyEditor,
4292 __("Write a reply…")
4293 );
4294 host.appendChild(editor.root);
4295 const actions = document.createElement("div");
4296 actions.className = "desktop-mode-comments__inline-actions";
4297 const cancel = document.createElement("wpd-button");
4298 cancel.setAttribute("variant", "ghost");
4299 cancel.textContent = __("Cancel");
4300 cancel.addEventListener("click", () => {
4301 editor.destroy();
4302 host.remove();
4303 });
4304 const send = document.createElement("wpd-button");
4305 send.setAttribute("variant", "primary");
4306 send.textContent = __("Send reply");
4307 send.addEventListener("click", async () => {
4308 const value = editor.getValue();
4309 if (!value) {
4310 showToast(__("Reply is empty."));
4311 return;
4312 }
4313 try {
4314 await postReply(cfg, id, value);
4315 showToast(__("Reply posted."));
4316 publish("desktop-mode-comments/replied", {
4317 parentId: id,
4318 postId: row.post
4319 });
4320 editor.destroy();
4321 host.remove();
4322 } catch (err) {
4323 showToast(
4324 err instanceof Error ? err.message : __("Reply failed.")
4325 );
4326 }
4327 });
4328 actions.append(cancel, send);
4329 host.appendChild(actions);
4330 editor.focus();
4331 }
4332 function openEditFor(state, id, cfg, refresh) {
4333 const row = state.rows.find((r) => r.id === id);
4334 if (!row || !row.desktop_mode_can_edit) {
4335 showToast(__("You can't edit this comment."));
4336 return;
4337 }
4338 const tr = state.tableHost?.querySelector(
4339 `tr[data-row-id="${id}"]`
4340 );
4341 if (!tr) {
4342 return;
4343 }
4344 const host = document.createElement("div");
4345 host.className = "desktop-mode-comments__inline-host";
4346 tr.after(host);
4347 const editor = mountReplyEditor(cfg.replyEditor, __("Edit comment…"));
4348 host.appendChild(editor.root);
4349 const editable = editor.root.querySelector(
4350 ".desktop-mode-comments__reply-input"
4351 );
4352 if (editable) {
4353 if (editable instanceof HTMLTextAreaElement) {
4354 editable.value = row.content?.raw ?? "";
4355 } else {
4356 editable.innerHTML = row.content?.rendered ?? "";
4357 }
4358 }
4359 const actions = document.createElement("div");
4360 actions.className = "desktop-mode-comments__inline-actions";
4361 const cancel = document.createElement("wpd-button");
4362 cancel.setAttribute("variant", "ghost");
4363 cancel.textContent = __("Cancel");
4364 cancel.addEventListener("click", () => {
4365 editor.destroy();
4366 host.remove();
4367 });
4368 const save = document.createElement("wpd-button");
4369 save.setAttribute("variant", "primary");
4370 save.textContent = __("Save");
4371 save.addEventListener("click", async () => {
4372 try {
4373 await updateCommentContent(cfg, id, editor.getValue());
4374 showToast(__("Comment updated."));
4375 publish("desktop-mode-comments/edited", { id });
4376 editor.destroy();
4377 host.remove();
4378 await refresh(state.tab);
4379 } catch (err) {
4380 showToast(
4381 err instanceof Error ? err.message : __("Edit failed.")
4382 );
4383 }
4384 });
4385 actions.append(cancel, save);
4386 host.appendChild(actions);
4387 editor.focus();
4388 }
4389 async function runBulk(ids, action, state, refresh, cfg) {
4390 try {
4391 const result = await bulkModerate(cfg, ids, action);
4392 const inverse = inverseAction(action);
4393 if (inverse && result.processed.length > 0) {
4394 undoStack.push({
4395 action,
4396 ids: result.processed,
4397 inverse,
4398 expiresAt: Date.now() + 8e3
4399 });
4400 showToast(
4401 actionPastTense(action, result.processed.length),
4402 8e3,
4403 [
4404 {
4405 label: __("Undo"),
4406 onClick: () => {
4407 void undoLast(cfg, refresh, state.tab);
4408 }
4409 }
4410 ]
4411 );
4412 } else {
4413 showToast(actionPastTense(action, result.processed.length));
4414 }
4415 publish(`desktop-mode-comments/${action}d`, {
4416 ids: result.processed,
4417 counts: result.counts
4418 });
4419 updateDockBadge(result.counts.pending);
4420 await refresh(state.tab, { force: true });
4421 } catch (err) {
4422 const fallback = sprintf(__("Bulk %s failed."), action);
4423 showToast(err instanceof Error ? err.message : fallback);
4424 }
4425 }
4426 async function undoLast(cfg, refresh, currentTab) {
4427 const last = undoStack.pop();
4428 if (!last || !last.inverse || Date.now() > last.expiresAt) {
4429 return;
4430 }
4431 try {
4432 await bulkModerate(cfg, last.ids, last.inverse);
4433 showToast(__("Undone."));
4434 await refresh(currentTab, { force: true });
4435 } catch (err) {
4436 showToast(
4437 err instanceof Error ? err.message : __("Undo failed.")
4438 );
4439 }
4440 }
4441 function moveFocus(state, direction) {
4442 if (!state.table || state.rows.length === 0) {
4443 return;
4444 }
4445 const selected = Array.from(state.table.selection).map((v) => Number(v)).filter(Boolean);
4446 const currentIndex = selected.length > 0 ? state.rows.findIndex((r) => r.id === selected[0]) : -1;
4447 let nextIndex = currentIndex + direction;
4448 if (nextIndex < 0) {
4449 nextIndex = 0;
4450 }
4451 if (nextIndex >= state.rows.length) {
4452 nextIndex = state.rows.length - 1;
4453 }
4454 const nextId = state.rows[nextIndex]?.id;
4455 if (!nextId) {
4456 return;
4457 }
4458 state.table.selection = [nextId];
4459 const tr = state.tableHost?.querySelector(
4460 `tr[data-row-id="${nextId}"]`
4461 );
4462 tr?.scrollIntoView({ block: "nearest", behavior: "smooth" });
4463 }
4464 const registry = window.desktopModeNativeWindows ?? (window.desktopModeNativeWindows = {});
4465 registry["desktop-mode-comments"] = (body) => {
4466 setActiveWindowId("desktop-mode-comments");
4467 return renderCommentsWindow(body).catch((err) => {
4468 console.error("[comments-window] render failed:", err);
4469 });
4470 };
4471 })();
4472