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

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

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