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 / posts-window.js

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

17,942 lines 611.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 var desktopModePostsWindow = function(exports) {
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 cacheKey2 = parsed.toString();
104 const cached = gravatarCache.get(cacheKey2);
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(cacheKey2, next);
134 return next;
135 });
136 gravatarCache.set(cacheKey2, 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 const ROOT_ID = "__root__";
155 const PALETTE = [
156 2257329,
157 // wp blue
158 8141549,
159 // violet
160 366185,
161 // emerald
162 14362487,
163 // pink
164 15357964,
165 // orange
166 561586
167 // cyan
168 ];
169 function buildSeedTree() {
170 const seeds = [
171 { id: "science", name: __("Science"), parent: ROOT_ID },
172 { id: "biology", name: __("Biology"), parent: "science" },
173 { id: "astronomy", name: __("Astronomy"), parent: "science" },
174 { id: "physics", name: __("Physics"), parent: "science" },
175 { id: "society", name: __("Society"), parent: ROOT_ID },
176 { id: "economics", name: __("Economics"), parent: "society" },
177 { id: "politics", name: __("Politics"), parent: "society" },
178 { id: "culture", name: __("Culture"), parent: ROOT_ID },
179 { id: "music", name: __("Music"), parent: "culture" },
180 { id: "cinema", name: __("Cinema"), parent: "culture" }
181 ];
182 const map = /* @__PURE__ */ new Map();
183 seeds.forEach((s, i) => {
184 map.set(s.id, {
185 id: s.id,
186 name: s.name,
187 parent: s.parent,
188 color: PALETTE[i % PALETTE.length],
189 radius: s.parent === ROOT_ID ? 34 : 24,
190 x: 0,
191 y: 0,
192 vx: 0,
193 vy: 0,
194 tx: 0,
195 ty: 0,
196 gfx: null,
197 label: null,
198 dragging: false,
199 ...makeFloatPhase(i, 4, 3.5)
200 });
201 });
202 return map;
203 }
204 function makeFloatPhase(seed, ampX, ampY) {
205 const r = (n) => {
206 const x = Math.sin(seed * 9301 + n * 49297) * 233280;
207 return x - Math.floor(x);
208 };
209 return {
210 phaseX: r(1) * Math.PI * 2,
211 phaseY: r(2) * Math.PI * 2,
212 // 0.0006–0.0012 rad/ms ≈ 5–10 second periods.
213 freqX: 6e-4 + r(3) * 6e-4,
214 freqY: 6e-4 + r(4) * 6e-4,
215 ampX,
216 ampY
217 };
218 }
219 const TAG_SEEDS = [
220 { id: "t-wp", name: "wordpress", count: 42, hue: 210 },
221 { id: "t-design", name: "design", count: 28, hue: 280 },
222 { id: "t-code", name: "code", count: 33, hue: 145 },
223 { id: "t-photo", name: "photo", count: 22, hue: 320 },
224 { id: "t-news", name: "news", count: 19, hue: 10 }
225 ];
226 const TAG_FONT_MIN = 11;
227 const TAG_FONT_MAX = 16;
228 const TAG_PAD_X = 9;
229 const TAG_PAD_Y = 4;
230 const TAG_GAP_HASH = 3;
231 const TAG_GAP_COUNT = 6;
232 function fontSizeFor$1(count, max) {
233 if (max <= 0) {
234 return TAG_FONT_MIN;
235 }
236 const t = Math.min(1, count / max);
237 return TAG_FONT_MIN + (TAG_FONT_MAX - TAG_FONT_MIN) * t;
238 }
239 function darkenColor(color, factor) {
240 const r = Math.round(Math.floor(color / 65536) * factor);
241 const g = Math.round(Math.floor(color % 65536 / 256) * factor);
242 const b = Math.round(color % 256 * factor);
243 return r * 65536 + g * 256 + b;
244 }
245 function hslToInt$2(h, s, l) {
246 const sat = s / 100;
247 const lig = l / 100;
248 const c = (1 - Math.abs(2 * lig - 1)) * sat;
249 const hp = (h % 360 + 360) % 360 / 60;
250 const xCol = c * (1 - Math.abs(hp % 2 - 1));
251 let r = 0;
252 let g = 0;
253 let b = 0;
254 if (hp < 1) {
255 r = c;
256 g = xCol;
257 } else if (hp < 2) {
258 r = xCol;
259 g = c;
260 } else if (hp < 3) {
261 g = c;
262 b = xCol;
263 } else if (hp < 4) {
264 g = xCol;
265 b = c;
266 } else if (hp < 5) {
267 r = xCol;
268 b = c;
269 } else {
270 r = c;
271 b = xCol;
272 }
273 const m = lig - c / 2;
274 const R = Math.round((r + m) * 255);
275 const G = Math.round((g + m) * 255);
276 const B = Math.round((b + m) * 255);
277 return R * 65536 + G * 256 + B;
278 }
279 function isDescendant(nodes, candidateId, targetId) {
280 if (candidateId === targetId) {
281 return true;
282 }
283 let cur = candidateId;
284 const visited = /* @__PURE__ */ new Set();
285 while (cur && !visited.has(cur)) {
286 visited.add(cur);
287 const n = nodes.get(cur);
288 if (!n) {
289 return false;
290 }
291 if (n.parent === targetId) {
292 return true;
293 }
294 cur = n.parent;
295 }
296 return false;
297 }
298 function layoutTree(nodes, width, height) {
299 const cx = width / 2;
300 const cy = height * 0.4;
301 const roots = Array.from(nodes.values()).filter((n) => n.parent === ROOT_ID);
302 const mindmapH = height * 0.62;
303 const rootR = Math.min(width, mindmapH) * 0.22;
304 roots.forEach((root, i) => {
305 const angle = i / Math.max(1, roots.length) * Math.PI * 2 - Math.PI / 2;
306 root.tx = cx + Math.cos(angle) * rootR;
307 root.ty = cy + Math.sin(angle) * rootR;
308 layoutChildren(nodes, root, angle);
309 });
310 }
311 function layoutTags(tags, width, height) {
312 const bandTop = height * 0.72;
313 const bandH = height * 0.26;
314 const bandCy = bandTop + bandH / 2;
315 const gap = 8;
316 const rows = [[]];
317 let rowW = 0;
318 tags.forEach((t) => {
319 const w = t.width || 60;
320 if (rowW + w + gap > width - 24 && rows[rows.length - 1].length > 0) {
321 rows.push([]);
322 rowW = 0;
323 }
324 rows[rows.length - 1].push(t);
325 rowW += w + gap;
326 });
327 const rowSpacing = 38;
328 const totalRowsH = rows.length * rowSpacing - rowSpacing;
329 const startY = bandCy - totalRowsH / 2;
330 rows.forEach((row, rIdx) => {
331 const total = row.reduce((acc, t) => acc + (t.width || 60), 0) + gap * Math.max(0, row.length - 1);
332 let cursor = (width - total) / 2;
333 row.forEach((t) => {
334 const w = t.width || 60;
335 t.tx = cursor + w / 2;
336 t.ty = startY + rIdx * rowSpacing;
337 cursor += w + gap;
338 });
339 });
340 }
341 function layoutChildren(nodes, parent, parentAngle) {
342 const children = Array.from(nodes.values()).filter(
343 (n) => n.parent === parent.id
344 );
345 if (children.length === 0) {
346 return;
347 }
348 const spread = Math.PI * 0.9;
349 const baseAngle = parentAngle;
350 const step = children.length === 1 ? 0 : spread / (children.length - 1);
351 const start = baseAngle - spread / 2;
352 const r = 95;
353 children.forEach((child, i) => {
354 const a = children.length === 1 ? baseAngle : start + step * i;
355 child.tx = parent.tx + Math.cos(a) * r;
356 child.ty = parent.ty + Math.sin(a) * r;
357 layoutChildren(nodes, child, a);
358 });
359 }
360 function layoutTagChip(chip) {
361 chip.hashText.style.fontSize = chip.fontSize;
362 chip.nameText.style.fontSize = chip.fontSize;
363 chip.countText.style.fontSize = Math.max(9, Math.round(chip.fontSize * 0.6));
364 const hashW = chip.hashText.width;
365 const nameW = chip.nameText.width;
366 const nameH = chip.nameText.height;
367 const countW = chip.countText.width;
368 const countH = chip.countText.height;
369 const countBadgeW = Math.max(16, countW + 8);
370 const countBadgeH = Math.max(13, countH + 3);
371 chip.width = TAG_PAD_X + hashW + TAG_GAP_HASH + nameW + TAG_GAP_COUNT + countBadgeW + TAG_PAD_X;
372 chip.height = Math.max(nameH, countBadgeH) + TAG_PAD_Y * 2;
373 }
374 function paintTagChip(chip) {
375 const totalW = chip.width;
376 const totalH = chip.height;
377 const left = -totalW / 2;
378 const top = -totalH / 2;
379 const radius = totalH / 2;
380 const fillBg = chip.hover ? hslToInt$2(chip.hue, 70, 88) : hslToInt$2(chip.hue, 60, 95);
381 const borderColor = hslToInt$2(chip.hue, 50, 70);
382 const textColor = 1909543;
383 const hashColor = hslToInt$2(chip.hue, 65, 42);
384 const countBg = hslToInt$2(chip.hue, 70, 50);
385 chip.bg.clear();
386 chip.bg.roundRect(left, top, totalW, totalH, radius);
387 chip.bg.fill(fillBg);
388 chip.bg.stroke({
389 color: borderColor,
390 width: chip.hover ? 1.6 : 1.2,
391 alpha: 0.85
392 });
393 const hashW = chip.hashText.width;
394 const nameW = chip.nameText.width;
395 const nameH = chip.nameText.height;
396 const countW = chip.countText.width;
397 const countH = chip.countText.height;
398 const countBadgeW = Math.max(16, countW + 8);
399 const countBadgeH = Math.max(13, countH + 3);
400 chip.hashText.x = left + TAG_PAD_X;
401 chip.hashText.y = (totalH - nameH) / 2 + top;
402 chip.hashText.style.fill = hashColor;
403 chip.nameText.x = left + TAG_PAD_X + hashW + TAG_GAP_HASH;
404 chip.nameText.y = (totalH - nameH) / 2 + top;
405 chip.nameText.style.fill = textColor;
406 const badgeX = left + TAG_PAD_X + hashW + TAG_GAP_HASH + nameW + TAG_GAP_COUNT;
407 const badgeY = (totalH - countBadgeH) / 2 + top;
408 chip.bg.roundRect(badgeX, badgeY, countBadgeW, countBadgeH, countBadgeH / 2);
409 chip.bg.fill(countBg);
410 chip.countText.x = badgeX + (countBadgeW - countW) / 2;
411 chip.countText.y = badgeY + (countBadgeH - countH) / 2;
412 }
413 function renderFallback(stage) {
414 stage.replaceChildren();
415 const note = document.createElement("p");
416 note.className = "wpd-intro__fallback";
417 note.textContent = __(
418 "A new visual editor for Categories and Tags awaits inside — drag, drop, and reorganize your taxonomy in seconds."
419 );
420 stage.appendChild(note);
421 }
422 async function showPostsIntroDialog() {
423 return new Promise((resolve) => {
424 const backdrop = document.createElement("div");
425 backdrop.className = "wpd-intro-backdrop";
426 const dialog = document.createElement("div");
427 dialog.className = "wpd-intro";
428 dialog.setAttribute("role", "dialog");
429 dialog.setAttribute("aria-modal", "true");
430 dialog.setAttribute("aria-labelledby", "wpd-intro-title");
431 dialog.tabIndex = -1;
432 backdrop.appendChild(dialog);
433 const titleEl = document.createElement("h2");
434 titleEl.id = "wpd-intro-title";
435 titleEl.className = "wpd-intro__title";
436 titleEl.textContent = __("Welcome to the new Posts");
437 dialog.appendChild(titleEl);
438 const lede = document.createElement("p");
439 lede.className = "wpd-intro__lede";
440 lede.textContent = __(
441 "A redesigned Posts experience built around how you actually work. Try the new Categories canvas — grab a node and drop it on another to reparent it."
442 );
443 dialog.appendChild(lede);
444 const stage = document.createElement("div");
445 stage.className = "wpd-intro__stage";
446 dialog.appendChild(stage);
447 const escape = document.createElement("p");
448 escape.className = "wpd-intro__escape";
449 escape.textContent = __(
450 "Prefer the classic Posts list? You can switch back any time from OS Settings → Features."
451 );
452 dialog.appendChild(escape);
453 const actions = document.createElement("div");
454 actions.className = "wpd-intro__actions";
455 const settingsBtn = document.createElement("button");
456 settingsBtn.type = "button";
457 settingsBtn.className = "wpd-intro__btn wpd-intro__btn--secondary";
458 settingsBtn.textContent = __("Take me to settings");
459 const confirmBtn = document.createElement("button");
460 confirmBtn.type = "button";
461 confirmBtn.className = "wpd-intro__btn wpd-intro__btn--primary";
462 confirmBtn.textContent = __("Got it");
463 actions.appendChild(settingsBtn);
464 actions.appendChild(confirmBtn);
465 dialog.appendChild(actions);
466 document.body.appendChild(backdrop);
467 let teardownPixi = null;
468 const cleanup = (result) => {
469 document.removeEventListener("keydown", onKey);
470 teardownPixi?.();
471 backdrop.remove();
472 resolve(result);
473 };
474 const onKey = (e) => {
475 if (e.key === "Escape") {
476 e.preventDefault();
477 cleanup("cancel");
478 }
479 };
480 document.addEventListener("keydown", onKey);
481 confirmBtn.addEventListener("click", () => cleanup("confirm"));
482 settingsBtn.addEventListener("click", () => cleanup("settings"));
483 backdrop.addEventListener("click", (e) => {
484 if (e.target === backdrop) {
485 cleanup("cancel");
486 }
487 });
488 requestAnimationFrame(() => dialog.focus());
489 void mountPixi(stage).then((teardown) => {
490 teardownPixi = teardown;
491 }).catch(() => {
492 renderFallback(stage);
493 });
494 });
495 }
496 async function mountPixi(stage) {
497 const api = window.wp?.desktop;
498 if (!api || typeof api.loadModules !== "function") {
499 renderFallback(stage);
500 return () => {
501 };
502 }
503 try {
504 await api.loadModules(["pixijs"]);
505 } catch {
506 renderFallback(stage);
507 return () => {
508 };
509 }
510 const pixiMaybe = window.PIXI;
511 if (!pixiMaybe) {
512 renderFallback(stage);
513 return () => {
514 };
515 }
516 const pixi = pixiMaybe;
517 const app = new pixi.Application();
518 await app.init({
519 resizeTo: stage,
520 backgroundAlpha: 0,
521 antialias: true,
522 autoDensity: true,
523 resolution: Math.min(window.devicePixelRatio || 1, 2)
524 });
525 stage.appendChild(app.canvas);
526 app.canvas.classList.add("wpd-intro__canvas");
527 const world = new pixi.Container();
528 world.sortableChildren = true;
529 world.scale.set(1);
530 app.stage.addChild(world);
531 const edgeLayer = new pixi.Container();
532 const nodeLayer = new pixi.Container();
533 const tagLayer = new pixi.Container();
534 const postLayer = new pixi.Container();
535 edgeLayer.zIndex = 1;
536 nodeLayer.zIndex = 2;
537 tagLayer.zIndex = 3;
538 postLayer.zIndex = 5;
539 world.addChild(edgeLayer);
540 world.addChild(postLayer);
541 world.addChild(nodeLayer);
542 world.addChild(tagLayer);
543 const nodes = buildSeedTree();
544 nodes.forEach((n) => {
545 const gfx = new pixi.Graphics();
546 gfx.eventMode = "static";
547 gfx.cursor = "grab";
548 const label = new pixi.Text({
549 text: n.name,
550 style: { fill: 16777215, fontSize: 12, fontWeight: "600", fontFamily: "system-ui, -apple-system, sans-serif" },
551 resolution: 3,
552 anchor: { x: 0.5, y: 0.5 }
553 });
554 gfx.addChild(label);
555 n.gfx = gfx;
556 n.label = label;
557 nodeLayer.addChild(gfx);
558 });
559 const tags = [];
560 const maxTagCount = TAG_SEEDS.reduce((m, t) => Math.max(m, t.count), 0);
561 TAG_SEEDS.forEach((seed, i) => {
562 const container = new pixi.Container();
563 container.eventMode = "static";
564 container.cursor = "grab";
565 const bg = new pixi.Graphics();
566 const fontSize = fontSizeFor$1(seed.count, maxTagCount);
567 const hashText = new pixi.Text({
568 text: "#",
569 style: { fill: 1909543, fontSize, fontWeight: "600", fontFamily: "system-ui, -apple-system, sans-serif" },
570 resolution: 3,
571 anchor: { x: 0, y: 0 }
572 });
573 const nameText = new pixi.Text({
574 text: seed.name,
575 style: { fill: 1909543, fontSize, fontWeight: "600", fontFamily: "system-ui, -apple-system, sans-serif" },
576 resolution: 3,
577 anchor: { x: 0, y: 0 }
578 });
579 const countText = new pixi.Text({
580 text: String(seed.count),
581 style: { fill: 16777215, fontSize: Math.max(9, Math.round(fontSize * 0.6)), fontWeight: "700", fontFamily: "system-ui, -apple-system, sans-serif" },
582 resolution: 3,
583 anchor: { x: 0, y: 0 }
584 });
585 container.addChild(bg, hashText, nameText, countText);
586 tagLayer.addChild(container);
587 const chip = {
588 id: seed.id,
589 name: seed.name,
590 count: seed.count,
591 hue: seed.hue,
592 fontSize,
593 width: 0,
594 height: 0,
595 x: 0,
596 y: 0,
597 tx: 0,
598 ty: 0,
599 bg,
600 hashText,
601 nameText,
602 countText,
603 container,
604 dragging: false,
605 hover: false,
606 ...makeFloatPhase(100 + i, 5, 4)
607 };
608 layoutTagChip(chip);
609 paintTagChip(chip);
610 tags.push(chip);
611 });
612 let stageW = stage.clientWidth || 600;
613 let stageH = stage.clientHeight || 360;
614 layoutTree(nodes, stageW, stageH);
615 layoutTags(tags, stageW, stageH);
616 const cx0 = stageW / 2;
617 const cy0 = stageH * 0.4;
618 nodes.forEach((n) => {
619 n.x = cx0;
620 n.y = cy0;
621 });
622 tags.forEach((t) => {
623 t.x = t.tx;
624 t.y = stageH + 40;
625 });
626 const drawNode = (n, hovered, dropTarget) => {
627 n.gfx.clear();
628 const r = n.radius * (hovered ? 1.08 : 1);
629 if (dropTarget) {
630 n.gfx.circle(0, 0, r + 10).fill({ color: n.color, alpha: 0.18 });
631 }
632 n.gfx.circle(0, 0, r).fill({ color: n.color, alpha: 0.95 }).stroke({ color: 16777215, width: dropTarget ? 3 : 1.5, alpha: 0.9 });
633 const labelW = n.label.width;
634 const labelH = n.label.height;
635 if (labelW + 6 > r * 2) {
636 const padX = 8;
637 const padY = 3;
638 const capW = labelW + padX * 2;
639 const capH = labelH + padY * 2;
640 n.gfx.roundRect(-capW / 2, -capH / 2, capW, capH, capH / 2).fill({ color: darkenColor(n.color, 0.55), alpha: 0.92 });
641 }
642 n.gfx.x = n.x;
643 n.gfx.y = n.y;
644 };
645 const drawEdges = () => {
646 const edgeLayerWithChildren = edgeLayer;
647 const previousChildren = edgeLayerWithChildren.children.slice();
648 previousChildren.forEach((c) => edgeLayer.removeChild(c));
649 const edge = new pixi.Graphics();
650 nodes.forEach((n) => {
651 if (!n.parent || n.parent === ROOT_ID) {
652 return;
653 }
654 const parent = nodes.get(n.parent);
655 if (!parent) {
656 return;
657 }
658 const dx = n.x - parent.x;
659 const cp1x = parent.x + dx * 0.5;
660 const cp1y = parent.y;
661 const cp2x = parent.x + dx * 0.5;
662 const cp2y = n.y;
663 edge.moveTo(parent.x, parent.y);
664 edge.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, n.x, n.y);
665 });
666 edge.stroke({ color: 9741240, width: 1.6, alpha: 0.55 });
667 edgeLayer.addChild(edge);
668 };
669 const POSTS_BY_TAG = {
670 "t-wp": [{ node: "politics", title: __("WordPress at scale") }, { node: "economics", title: __("Plugins economy") }, { node: "astronomy", title: __("Open-source orbits") }],
671 "t-design": [{ node: "cinema", title: __("Title cards reborn") }, { node: "music", title: __("Album art trends") }, { node: "culture", title: __("Type as identity") }],
672 "t-code": [{ node: "physics", title: __("Sim notebooks") }, { node: "astronomy", title: __("Pixel pipelines") }, { node: "science", title: __("Code as method") }],
673 "t-photo": [{ node: "cinema", title: __("Anamorphic notes") }, { node: "biology", title: __("Field portraits") }, { node: "culture", title: __("Sunday playlist") }],
674 "t-news": [{ node: "politics", title: __("Weekly briefing") }, { node: "economics", title: __("Markets recap") }]
675 };
676 let fakePosts = [];
677 const POSTS_BY_CATEGORY = {
678 science: [__("What we learned"), __("Open questions"), __("Methodology notes"), __("Replication study")],
679 biology: [__("Fieldwork log"), __("Cell shapes"), __("Microscope diary")],
680 botany: [__("Pressed leaves"), __("Greenhouse notes"), __("Native species")],
681 zoology: [__("Migration map"), __("Birding weekend"), __("Tracks at dawn")],
682 astronomy: [__("Comet schedule"), __("Backyard telescope"), __("Lunar tides")],
683 physics: [__("Lab notebook"), __("Toy models"), __("Phase transitions")],
684 society: [__("Sunday digest"), __("Local elections"), __("Reader letters")],
685 economics: [__("Macro recap"), __("Numbers I noticed"), __("Market mood")],
686 macro: [__("Inflation trail"), __("Central banks")],
687 micro: [__("Pricing tactics"), __("Coffee shop economics")],
688 politics: [__("Campaign trail"), __("Town hall notes"), __("Policy explainer")],
689 culture: [__("Type as identity"), __("Sunday playlist"), __("City walks")],
690 music: [__("Liner notes"), __("Live this week"), __("Album re-listen")],
691 cinema: [__("Title cards reborn"), __("Director cut"), __("Set on the road")],
692 drama: [__("Three-act notes"), __("Stage to screen")],
693 "sci-fi": [__("Anamorphic notes"), __("Future-proof tropes"), __("Worldbuilding 101")]
694 };
695 const clearFakePosts = () => {
696 fakePosts.forEach((p) => {
697 try {
698 postLayer.removeChild(p.container);
699 p.container.destroy({ children: true });
700 } catch {
701 }
702 });
703 fakePosts = [];
704 };
705 const buildPostChip = (title, anchorKind, anchorId, accentColor, angle, orbit, originX, originY, spawnedAt) => {
706 const container = new pixi.Container();
707 container.alpha = 0;
708 container.x = originX;
709 container.y = originY;
710 const bg = new pixi.Graphics();
711 const text = new pixi.Text({
712 text: title,
713 style: {
714 fill: 1909543,
715 fontSize: 10,
716 fontFamily: "system-ui, -apple-system, sans-serif"
717 },
718 resolution: 3,
719 anchor: { x: 0, y: 0 }
720 });
721 container.addChild(bg, text);
722 postLayer.addChild(container);
723 return {
724 title,
725 anchorKind,
726 anchorId,
727 accentColor,
728 angle,
729 orbit,
730 originX,
731 originY,
732 container,
733 bg,
734 text,
735 spawnedAt
736 };
737 };
738 const spawnFakePostsFromTag = (tag) => {
739 clearFakePosts();
740 const list = POSTS_BY_TAG[tag.id];
741 if (!list) {
742 return;
743 }
744 const now = performance.now();
745 const ox = tag.container.x;
746 const oy = tag.container.y;
747 const accent = hslToInt$2(tag.hue, 70, 50);
748 const titles = list.map((p) => p.title);
749 const spread = Math.PI * 1.2;
750 const baseAngle = -Math.PI / 2;
751 const step = titles.length === 1 ? 0 : spread / (titles.length - 1);
752 const start = baseAngle - spread / 2;
753 const orbitR = 56 + Math.min(16, titles.length * 2);
754 titles.forEach((title, i) => {
755 const angle = titles.length === 1 ? baseAngle : start + step * i;
756 fakePosts.push(
757 buildPostChip(
758 title,
759 "tag",
760 tag.id,
761 accent,
762 angle,
763 orbitR + i % 2 * 6,
764 ox,
765 oy,
766 now
767 )
768 );
769 });
770 };
771 const spawnFakePostsFromCategory = (node) => {
772 clearFakePosts();
773 const titles = POSTS_BY_CATEGORY[node.id];
774 if (!titles || titles.length === 0) {
775 return;
776 }
777 const now = performance.now();
778 const ox = node.gfx.x;
779 const oy = node.gfx.y;
780 const spread = Math.PI * 1.6;
781 const start = -Math.PI / 2 - spread / 2;
782 const step = titles.length === 1 ? 0 : spread / (titles.length - 1);
783 titles.forEach((title, i) => {
784 const angle = titles.length === 1 ? -Math.PI / 2 : start + step * i;
785 fakePosts.push(
786 buildPostChip(
787 title,
788 "node",
789 node.id,
790 node.color,
791 angle,
792 78 + i % 3 * 8,
793 ox,
794 oy,
795 now
796 )
797 );
798 });
799 };
800 let dragging = null;
801 let pointerStart = { x: 0, y: 0 };
802 let nodeStart = { x: 0, y: 0 };
803 let hoverDrop = null;
804 let dragTag = null;
805 let tagDragStart = { x: 0, y: 0 };
806 let tagStart = { x: 0, y: 0 };
807 nodes.forEach((n) => {
808 n.gfx.on("pointerdown", (raw) => {
809 const e = raw;
810 dragging = n;
811 n.dragging = true;
812 pointerStart = { x: e.global.x, y: e.global.y };
813 nodeStart = { x: n.x, y: n.y };
814 n.gfx.cursor = "grabbing";
815 n.gfx.zIndex = 1e3;
816 drawNode(n, true, false);
817 });
818 n.gfx.on("pointerover", () => {
819 if (dragging || dragTag) {
820 return;
821 }
822 drawNode(n, true, false);
823 spawnFakePostsFromCategory(n);
824 });
825 n.gfx.on("pointerout", () => {
826 if (dragging !== n) {
827 drawNode(n, false, hoverDrop === n);
828 }
829 clearFakePosts();
830 });
831 });
832 tags.forEach((t) => {
833 t.container.on("pointerdown", (raw) => {
834 const e = raw;
835 dragTag = t;
836 t.dragging = true;
837 tagDragStart = { x: e.global.x, y: e.global.y };
838 tagStart = { x: t.x, y: t.y };
839 t.container.cursor = "grabbing";
840 t.container.zIndex = 5e3;
841 });
842 t.container.on("pointerover", () => {
843 if (dragTag || dragging) {
844 return;
845 }
846 t.hover = true;
847 paintTagChip(t);
848 spawnFakePostsFromTag(t);
849 });
850 t.container.on("pointerout", () => {
851 t.hover = false;
852 paintTagChip(t);
853 clearFakePosts();
854 });
855 });
856 const onMove = (e) => {
857 const rect = app.canvas.getBoundingClientRect();
858 const px = e.clientX - rect.left;
859 const py = e.clientY - rect.top;
860 if (dragTag) {
861 dragTag.x = tagStart.x + (px - tagDragStart.x);
862 dragTag.y = tagStart.y + (py - tagDragStart.y);
863 dragTag.container.x = dragTag.x;
864 dragTag.container.y = dragTag.y;
865 return;
866 }
867 if (!dragging) {
868 return;
869 }
870 const dx = px - pointerStart.x;
871 const dy = py - pointerStart.y;
872 dragging.x = nodeStart.x + dx;
873 dragging.y = nodeStart.y + dy;
874 let hit = null;
875 nodes.forEach((other) => {
876 if (other === dragging) {
877 return;
878 }
879 if (isDescendant(nodes, other.id, dragging.id)) {
880 return;
881 }
882 const ddx = other.x - dragging.x;
883 const ddy = other.y - dragging.y;
884 if (Math.hypot(ddx, ddy) < other.radius + dragging.radius * 0.6) {
885 hit = other;
886 }
887 });
888 if (hit !== hoverDrop) {
889 if (hoverDrop) {
890 drawNode(hoverDrop, false, false);
891 }
892 hoverDrop = hit;
893 if (hoverDrop) {
894 drawNode(hoverDrop, false, true);
895 }
896 }
897 drawNode(dragging, true, false);
898 };
899 const onUp = () => {
900 if (dragTag) {
901 dragTag.container.cursor = "grab";
902 dragTag.container.zIndex = 0;
903 dragTag.dragging = false;
904 dragTag = null;
905 return;
906 }
907 if (!dragging) {
908 return;
909 }
910 const drop = hoverDrop;
911 if (drop && drop.id !== dragging.parent) {
912 dragging.parent = drop.id;
913 layoutTree(nodes, stageW, stageH);
914 }
915 dragging.gfx.cursor = "grab";
916 dragging.gfx.zIndex = 0;
917 dragging.dragging = false;
918 const dragged = dragging;
919 dragging = null;
920 if (hoverDrop) {
921 drawNode(hoverDrop, false, false);
922 hoverDrop = null;
923 }
924 drawNode(dragged, false, false);
925 };
926 app.canvas.addEventListener("pointermove", onMove);
927 window.addEventListener("pointerup", onUp);
928 window.addEventListener("pointercancel", onUp);
929 const tick = () => {
930 const now = performance.now();
931 const REPULSION_K2 = 6500;
932 const SPRING_K2 = 0.05;
933 const SPRING_LEN2 = 110;
934 const ANCHOR_K = 0.012;
935 const DAMPING = 0.82;
936 const MAX_V = 8;
937 const list = Array.from(nodes.values());
938 const fxArr = new Array(list.length).fill(0);
939 const fyArr = new Array(list.length).fill(0);
940 for (let i = 0; i < list.length; i++) {
941 const a = list[i];
942 if (a === dragging) {
943 continue;
944 }
945 for (let j = i + 1; j < list.length; j++) {
946 const b = list[j];
947 if (b === dragging) {
948 continue;
949 }
950 const dx = b.x - a.x;
951 const dy = b.y - a.y;
952 const d2 = dx * dx + dy * dy + 0.01;
953 const d = Math.sqrt(d2);
954 const minD = a.radius + b.radius;
955 if (d > minD * 4) {
956 continue;
957 }
958 const f = REPULSION_K2 / d2;
959 const fx = dx / d * f;
960 const fy = dy / d * f;
961 fxArr[i] -= fx;
962 fyArr[i] -= fy;
963 fxArr[j] += fx;
964 fyArr[j] += fy;
965 }
966 }
967 list.forEach((c, idx) => {
968 if (!c.parent || c.parent === ROOT_ID) {
969 return;
970 }
971 if (c === dragging) {
972 return;
973 }
974 const parent = nodes.get(c.parent);
975 if (!parent || parent === dragging) {
976 return;
977 }
978 const pIdx = list.indexOf(parent);
979 const dx = parent.x - c.x;
980 const dy = parent.y - c.y;
981 const d = Math.max(0.01, Math.sqrt(dx * dx + dy * dy));
982 const diff = d - SPRING_LEN2;
983 const sx = dx / d * diff * SPRING_K2;
984 const sy = dy / d * diff * SPRING_K2;
985 fxArr[idx] += sx;
986 fyArr[idx] += sy;
987 if (pIdx >= 0) {
988 fxArr[pIdx] -= sx;
989 fyArr[pIdx] -= sy;
990 }
991 });
992 list.forEach((n, idx) => {
993 fxArr[idx] += (n.tx - n.x) * ANCHOR_K;
994 fyArr[idx] += (n.ty - n.y) * ANCHOR_K;
995 });
996 list.forEach((n, idx) => {
997 if (n === dragging) {
998 n.vx = 0;
999 n.vy = 0;
1000 return;
1001 }
1002 n.vx = (n.vx + fxArr[idx]) * DAMPING;
1003 n.vy = (n.vy + fyArr[idx]) * DAMPING;
1004 if (n.vx > MAX_V) {
1005 n.vx = MAX_V;
1006 } else if (n.vx < -MAX_V) {
1007 n.vx = -MAX_V;
1008 }
1009 if (n.vy > MAX_V) {
1010 n.vy = MAX_V;
1011 } else if (n.vy < -MAX_V) {
1012 n.vy = -MAX_V;
1013 }
1014 n.x += n.vx;
1015 n.y += n.vy;
1016 });
1017 drawEdges();
1018 nodes.forEach((n) => {
1019 const fx = n === dragging ? n.x : n.x + Math.sin(now * n.freqX + n.phaseX) * n.ampX;
1020 const fy = n === dragging ? n.y : n.y + Math.sin(now * n.freqY + n.phaseY) * n.ampY;
1021 drawNode(n, false, hoverDrop === n);
1022 n.gfx.x = fx;
1023 n.gfx.y = fy;
1024 });
1025 tags.forEach((t) => {
1026 if (t === dragTag) {
1027 return;
1028 }
1029 t.x += (t.tx - t.x) * 0.16;
1030 t.y += (t.ty - t.y) * 0.16;
1031 const fx = t.x + Math.sin(now * t.freqX + t.phaseX) * t.ampX;
1032 const fy = t.y + Math.sin(now * t.freqY + t.phaseY) * t.ampY * 0.6;
1033 t.container.x = fx;
1034 t.container.y = fy;
1035 });
1036 fakePosts.forEach((p, idx) => {
1037 let anchorX = 0;
1038 let anchorY = 0;
1039 if (p.anchorKind === "tag") {
1040 const t2 = tags.find((tg) => tg.id === p.anchorId);
1041 if (!t2) {
1042 return;
1043 }
1044 anchorX = t2.container.x;
1045 anchorY = t2.container.y;
1046 } else {
1047 const node = nodes.get(p.anchorId);
1048 if (!node) {
1049 return;
1050 }
1051 anchorX = node.gfx.x;
1052 anchorY = node.gfx.y;
1053 }
1054 const elapsed = now - p.spawnedAt;
1055 const t = Math.min(1, elapsed / 320);
1056 p.container.alpha = t;
1057 const wobble = Math.sin(now * 15e-4 + idx) * 4;
1058 const tx = anchorX + Math.cos(p.angle) * (p.orbit + wobble);
1059 const ty = anchorY + Math.sin(p.angle) * (p.orbit + wobble);
1060 p.container.x += (tx - p.container.x) * 0.16;
1061 p.container.y += (ty - p.container.y) * 0.16;
1062 const padX = 7;
1063 const padY = 3;
1064 const textW = p.text.width;
1065 const textH = p.text.height;
1066 const w = textW + padX * 2;
1067 const h = textH + padY * 2;
1068 p.text.x = -w / 2 + padX;
1069 p.text.y = -h / 2 + padY;
1070 p.bg.clear();
1071 p.bg.roundRect(-w / 2, -h / 2, w, h, h / 2);
1072 p.bg.fill({ color: 16777215, alpha: 0.95 });
1073 p.bg.stroke({
1074 color: p.accentColor,
1075 width: 1.2,
1076 alpha: 0.85
1077 });
1078 });
1079 const FIT_MARGIN = 24;
1080 const FIT_EASE = 0.08;
1081 let minX = Infinity;
1082 let minY = Infinity;
1083 let maxX = -Infinity;
1084 let maxY = -Infinity;
1085 nodes.forEach((n) => {
1086 const dx = n.gfx.x;
1087 const dy = n.gfx.y;
1088 const r = n.radius + 8;
1089 if (dx - r < minX) {
1090 minX = dx - r;
1091 }
1092 if (dy - r < minY) {
1093 minY = dy - r;
1094 }
1095 if (dx + r > maxX) {
1096 maxX = dx + r;
1097 }
1098 if (dy + r > maxY) {
1099 maxY = dy + r;
1100 }
1101 });
1102 tags.forEach((tg) => {
1103 const dx = tg.container.x;
1104 const dy = tg.container.y;
1105 const w = tg.width / 2 + 4;
1106 const h = tg.height / 2 + 4;
1107 if (dx - w < minX) {
1108 minX = dx - w;
1109 }
1110 if (dy - h < minY) {
1111 minY = dy - h;
1112 }
1113 if (dx + w > maxX) {
1114 maxX = dx + w;
1115 }
1116 if (dy + h > maxY) {
1117 maxY = dy + h;
1118 }
1119 });
1120 const bw = maxX - minX;
1121 const bh = maxY - minY;
1122 if (bw > 0 && bh > 0 && Number.isFinite(bw) && Number.isFinite(bh)) {
1123 const sx = (stageW - FIT_MARGIN * 2) / bw;
1124 const sy = (stageH - FIT_MARGIN * 2) / bh;
1125 const targetScale = Math.max(0.55, Math.min(1, sx, sy));
1126 const cx = (minX + maxX) / 2;
1127 const cy = (minY + maxY) / 2;
1128 const targetX = stageW / 2 - cx * targetScale;
1129 const targetY = stageH / 2 - cy * targetScale;
1130 world.x += (targetX - world.x) * FIT_EASE;
1131 world.y += (targetY - world.y) * FIT_EASE;
1132 const curScale = world.scale.x;
1133 world.scale.set(curScale + (targetScale - curScale) * FIT_EASE);
1134 }
1135 };
1136 app.ticker.add(tick);
1137 const ro = new ResizeObserver(() => {
1138 stageW = stage.clientWidth || stageW;
1139 stageH = stage.clientHeight || stageH;
1140 layoutTree(nodes, stageW, stageH);
1141 layoutTags(tags, stageW, stageH);
1142 });
1143 ro.observe(stage);
1144 return () => {
1145 ro.disconnect();
1146 app.ticker.remove(tick);
1147 app.canvas.removeEventListener("pointermove", onMove);
1148 window.removeEventListener("pointerup", onUp);
1149 window.removeEventListener("pointercancel", onUp);
1150 clearFakePosts();
1151 try {
1152 app.destroy({ removeView: true }, { children: true });
1153 } catch {
1154 }
1155 };
1156 }
1157 function html(strings, ...values) {
1158 return { __wpdHtml: true, strings, values };
1159 }
1160 function isTemplateResult$1(v) {
1161 return !!v && v.__wpdHtml === true;
1162 }
1163 const MARKER_PREFIX = "$$wpd$$";
1164 const MARKER_RE = /\$\$wpd\$\$(\d+)\$\$/g;
1165 function joinWithMarkers(strings) {
1166 let out = strings[0];
1167 for (let i = 1; i < strings.length; i++) {
1168 out += `${MARKER_PREFIX}${i - 1}$$` + strings[i];
1169 }
1170 return out;
1171 }
1172 const compiledCache = /* @__PURE__ */ new WeakMap();
1173 function compile(strings) {
1174 const cached = compiledCache.get(strings);
1175 if (cached) {
1176 return cached;
1177 }
1178 const template = document.createElement("template");
1179 template.innerHTML = joinWithMarkers(strings);
1180 const recipes = [];
1181 const walk = (node, path) => {
1182 if (node.nodeType === Node.ELEMENT_NODE) {
1183 const el = node;
1184 for (const attr of Array.from(el.attributes)) {
1185 const rawName = attr.name;
1186 const rawValue = attr.value;
1187 const prefix = rawName[0];
1188 if (MARKER_RE.test(rawValue)) {
1189 MARKER_RE.lastIndex = 0;
1190 if (prefix === "@") {
1191 const match = MARKER_RE.exec(rawValue);
1192 MARKER_RE.lastIndex = 0;
1193 recipes.push({
1194 path,
1195 kind: "event",
1196 name: rawName.slice(1),
1197 valueIndex: match ? Number(match[1]) : 0
1198 });
1199 el.removeAttribute(rawName);
1200 } else if (prefix === ".") {
1201 const match = MARKER_RE.exec(rawValue);
1202 MARKER_RE.lastIndex = 0;
1203 recipes.push({
1204 path,
1205 kind: "prop",
1206 name: rawName.slice(1),
1207 valueIndex: match ? Number(match[1]) : 0
1208 });
1209 el.removeAttribute(rawName);
1210 } else if (prefix === "?") {
1211 const match = MARKER_RE.exec(rawValue);
1212 MARKER_RE.lastIndex = 0;
1213 recipes.push({
1214 path,
1215 kind: "bool",
1216 name: rawName.slice(1),
1217 valueIndex: match ? Number(match[1]) : 0
1218 });
1219 el.removeAttribute(rawName);
1220 } else {
1221 const fragments = [];
1222 const indices = [];
1223 let lastEnd = 0;
1224 let m;
1225 MARKER_RE.lastIndex = 0;
1226 while ((m = MARKER_RE.exec(rawValue)) !== null) {
1227 fragments.push(rawValue.slice(lastEnd, m.index));
1228 indices.push(Number(m[1]));
1229 lastEnd = m.index + m[0].length;
1230 }
1231 fragments.push(rawValue.slice(lastEnd));
1232 recipes.push({
1233 path,
1234 kind: "attr",
1235 name: rawName,
1236 template: fragments,
1237 valueIndices: indices
1238 });
1239 el.setAttribute(rawName, "");
1240 }
1241 }
1242 }
1243 }
1244 const children = Array.from(node.childNodes);
1245 let shift = 0;
1246 for (let i = 0; i < children.length; i++) {
1247 const child = children[i];
1248 const liveIndex = i + shift;
1249 if (child.nodeType === Node.TEXT_NODE) {
1250 const text = child.textContent || "";
1251 if (!MARKER_RE.test(text)) {
1252 MARKER_RE.lastIndex = 0;
1253 continue;
1254 }
1255 MARKER_RE.lastIndex = 0;
1256 const parent = child.parentNode;
1257 let lastEnd = 0;
1258 let m;
1259 const newNodes = [];
1260 const newRecipes = [];
1261 MARKER_RE.lastIndex = 0;
1262 while ((m = MARKER_RE.exec(text)) !== null) {
1263 if (m.index > lastEnd) {
1264 newNodes.push(document.createTextNode(text.slice(lastEnd, m.index)));
1265 }
1266 const placeholder = document.createTextNode("");
1267 newNodes.push(placeholder);
1268 newRecipes.push({
1269 path: [...path, liveIndex + newNodes.length - 1],
1270 kind: "node",
1271 valueIndex: Number(m[1])
1272 });
1273 lastEnd = m.index + m[0].length;
1274 }
1275 if (lastEnd < text.length) {
1276 newNodes.push(document.createTextNode(text.slice(lastEnd)));
1277 }
1278 for (const nn of newNodes) {
1279 parent.insertBefore(nn, child);
1280 }
1281 parent.removeChild(child);
1282 shift += newNodes.length - 1;
1283 recipes.push(...newRecipes);
1284 } else {
1285 walk(child, [...path, liveIndex]);
1286 }
1287 }
1288 };
1289 walk(template.content, []);
1290 const buildParts = (fragment) => {
1291 const out = [];
1292 for (const r of recipes) {
1293 let node = fragment;
1294 for (const idx of r.path) {
1295 node = node.childNodes[idx];
1296 }
1297 if (r.kind === "node") {
1298 out.push({
1299 kind: "node",
1300 valueIndex: r.valueIndex,
1301 child: {
1302 anchor: node,
1303 state: null
1304 }
1305 });
1306 } else if (r.kind === "attr") {
1307 out.push({
1308 kind: "attr",
1309 element: node,
1310 name: r.name,
1311 template: r.template,
1312 valueIndices: r.valueIndices
1313 });
1314 } else if (r.kind === "event") {
1315 out.push({
1316 kind: "event",
1317 valueIndex: r.valueIndex,
1318 element: node,
1319 name: r.name
1320 });
1321 } else if (r.kind === "prop") {
1322 out.push({
1323 kind: "prop",
1324 valueIndex: r.valueIndex,
1325 element: node,
1326 name: r.name
1327 });
1328 } else if (r.kind === "bool") {
1329 out.push({
1330 kind: "bool",
1331 valueIndex: r.valueIndex,
1332 element: node,
1333 name: r.name
1334 });
1335 }
1336 }
1337 return out;
1338 };
1339 const entry = { template, buildParts };
1340 compiledCache.set(strings, entry);
1341 return entry;
1342 }
1343 const mountState = /* @__PURE__ */ new WeakMap();
1344 function mountIntact(state, container) {
1345 for (const node of state.nodes) {
1346 if (node.parentNode !== container) {
1347 return false;
1348 }
1349 }
1350 return true;
1351 }
1352 function render(result, container) {
1353 const existing = mountState.get(container);
1354 if (existing && existing.strings === result.strings && mountIntact(existing, container)) {
1355 applyValues(existing.parts, result.values);
1356 return;
1357 }
1358 const compiled = compile(result.strings);
1359 const fragment = compiled.template.content.cloneNode(true);
1360 const parts = compiled.buildParts(fragment);
1361 const nodes = Array.from(fragment.childNodes);
1362 while (container.firstChild) {
1363 container.removeChild(container.firstChild);
1364 }
1365 container.appendChild(fragment);
1366 applyValues(parts, result.values);
1367 mountState.set(container, { strings: result.strings, parts, nodes });
1368 }
1369 function applyValues(parts, values) {
1370 for (const part of parts) {
1371 if (part.kind === "node") {
1372 updateChildPart(part.child, values[part.valueIndex]);
1373 } else if (part.kind === "attr") {
1374 let composed = part.template[0];
1375 for (let i = 0; i < part.valueIndices.length; i++) {
1376 composed += formatText(values[part.valueIndices[i]]);
1377 composed += part.template[i + 1];
1378 }
1379 if (composed !== part.last) {
1380 part.last = composed;
1381 if (composed === "") {
1382 part.element.removeAttribute(part.name);
1383 } else {
1384 part.element.setAttribute(part.name, composed);
1385 }
1386 }
1387 } else if (part.kind === "event") {
1388 const next = values[part.valueIndex];
1389 if (next !== part.current) {
1390 if (part.current) {
1391 part.element.removeEventListener(part.name, part.current);
1392 }
1393 if (next) {
1394 part.element.addEventListener(part.name, next);
1395 }
1396 part.current = next;
1397 }
1398 } else if (part.kind === "prop") {
1399 const next = values[part.valueIndex];
1400 if (next !== part.last) {
1401 part.last = next;
1402 part.element[part.name] = next;
1403 }
1404 } else if (part.kind === "bool") {
1405 const next = !!values[part.valueIndex];
1406 if (next !== part.last) {
1407 part.last = next;
1408 if (next) {
1409 part.element.setAttribute(part.name, "");
1410 } else {
1411 part.element.removeAttribute(part.name);
1412 }
1413 }
1414 }
1415 }
1416 }
1417 function updateChildPart(child, value) {
1418 if (value === null || value === void 0 || value === false) {
1419 if (child.state) {
1420 disposeChildState(child.state);
1421 child.state = null;
1422 }
1423 return;
1424 }
1425 if (Array.isArray(value)) {
1426 updateArrayChild(child, value);
1427 return;
1428 }
1429 if (isTemplateResult$1(value)) {
1430 updateTemplateChild(child, value);
1431 return;
1432 }
1433 if (value instanceof Node) {
1434 updateNodeChild(child, value);
1435 return;
1436 }
1437 updateTextChild(child, formatText(value));
1438 }
1439 function updateNodeChild(child, node) {
1440 const old = child.state;
1441 if (old?.shape === "node" && old.node === node) {
1442 return;
1443 }
1444 if (old) {
1445 disposeChildState(old);
1446 }
1447 insertBeforeAnchor(child, [node]);
1448 child.state = { shape: "node", node };
1449 }
1450 function updateTextChild(child, text) {
1451 const old = child.state;
1452 if (old?.shape === "text") {
1453 if (old.text !== text) {
1454 old.node.textContent = text;
1455 old.text = text;
1456 }
1457 return;
1458 }
1459 if (old) {
1460 disposeChildState(old);
1461 }
1462 const node = document.createTextNode(text);
1463 insertBeforeAnchor(child, [node]);
1464 child.state = { shape: "text", node, text };
1465 }
1466 function updateTemplateChild(child, result) {
1467 const old = child.state;
1468 if (old?.shape === "template" && old.strings === result.strings) {
1469 applyValues(old.parts, result.values);
1470 return;
1471 }
1472 if (old) {
1473 disposeChildState(old);
1474 }
1475 const compiled = compile(result.strings);
1476 const fragment = compiled.template.content.cloneNode(true);
1477 const parts = compiled.buildParts(fragment);
1478 const topNodes = Array.from(fragment.childNodes);
1479 insertBeforeAnchor(child, [fragment]);
1480 applyValues(parts, result.values);
1481 child.state = {
1482 shape: "template",
1483 strings: result.strings,
1484 parts,
1485 nodes: topNodes
1486 };
1487 }
1488 function updateArrayChild(child, arr) {
1489 const old = child.state;
1490 if (old?.shape === "array" && old.entries.length === arr.length) {
1491 for (let i = 0; i < arr.length; i++) {
1492 updateChildPart(old.entries[i], arr[i]);
1493 }
1494 return;
1495 }
1496 if (old) {
1497 disposeChildState(old);
1498 }
1499 const entries = [];
1500 for (const v of arr) {
1501 const entryAnchor = document.createTextNode("");
1502 insertBeforeAnchor(child, [entryAnchor]);
1503 const entry = { anchor: entryAnchor, state: null };
1504 updateChildPart(entry, v);
1505 entries.push(entry);
1506 }
1507 child.state = { shape: "array", entries };
1508 }
1509 function insertBeforeAnchor(child, nodes) {
1510 const parent = child.anchor.parentNode;
1511 if (!parent) {
1512 return;
1513 }
1514 for (const node of nodes) {
1515 parent.insertBefore(node, child.anchor);
1516 }
1517 }
1518 function disposeChildState(state) {
1519 if (state.shape === "text") {
1520 state.node.remove();
1521 return;
1522 }
1523 if (state.shape === "template") {
1524 for (const node of state.nodes) {
1525 if (node.parentNode) {
1526 node.parentNode.removeChild(node);
1527 }
1528 }
1529 return;
1530 }
1531 if (state.shape === "node") {
1532 if (state.node.parentNode) {
1533 state.node.parentNode.removeChild(state.node);
1534 }
1535 return;
1536 }
1537 for (const entry of state.entries) {
1538 if (entry.state) {
1539 disposeChildState(entry.state);
1540 }
1541 entry.anchor.remove();
1542 }
1543 }
1544 function formatText(v) {
1545 if (v === null || v === void 0 || v === false) {
1546 return "";
1547 }
1548 return String(v);
1549 }
1550 const _Component = class _Component extends HTMLElement {
1551 constructor() {
1552 super();
1553 this._renderScheduled = false;
1554 this._propValues = {};
1555 const ctor = this.constructor;
1556 if (ctor.shadow) {
1557 this.attachShadow({ mode: "open" });
1558 this._renderRoot = this.shadowRoot;
1559 } else {
1560 this._renderRoot = this;
1561 }
1562 this._installPropAccessors();
1563 }
1564 static get observedAttributes() {
1565 return this.props.map(kebab);
1566 }
1567 connectedCallback() {
1568 this._adoptStyles();
1569 this.requestUpdate();
1570 }
1571 attributeChangedCallback(name, oldValue, newValue) {
1572 if (oldValue === newValue) {
1573 return;
1574 }
1575 const prop = camel(name);
1576 this._propValues[prop] = newValue;
1577 this.requestUpdate();
1578 }
1579 /**
1580 * Declarative class-name setter. Assign an array (or a
1581 * space-separated string) and the host's `class` attribute is
1582 * rewritten to match. Intended for programmatic styling — when
1583 * a plugin has enqueued its own stylesheet and wants to apply
1584 * one of those classes to a shell component:
1585 *
1586 * ```js
1587 * element.classNames = [ 'my-plugin-brand', 'is-active' ];
1588 * // → <wpd-select class="my-plugin-brand is-active">
1589 * ```
1590 *
1591 * The plain HTML `class="…"` attribute works just the same and
1592 * is always preferred when writing markup by hand — this setter
1593 * exists for the JS-API case where the caller has an array of
1594 * conditional classes in hand.
1595 *
1596 * Getter returns the current `classList` as a plain array for
1597 * symmetric read/write.
1598 *
1599 * @since 0.5.0
1600 */
1601 get classNames() {
1602 return Array.from(this.classList);
1603 }
1604 set classNames(next) {
1605 if (next === null || next === void 0) {
1606 this.removeAttribute("class");
1607 return;
1608 }
1609 const list = Array.isArray(next) ? next : String(next).split(/\s+/);
1610 const cleaned = list.map((s) => String(s).trim()).filter((s) => s !== "");
1611 this.className = cleaned.join(" ");
1612 }
1613 /**
1614 * Request a re-render explicitly. Components rarely need this —
1615 * declare state via props + attribute observers and the render
1616 * loop picks up changes automatically.
1617 */
1618 requestUpdate() {
1619 this._scheduleRender();
1620 }
1621 /**
1622 * Dispatch a `CustomEvent` with a `detail`. Bubbles + composed
1623 * by default (matches typical WC UX — events cross shadow
1624 * boundaries, parents can listen without knowing about internal
1625 * structure).
1626 */
1627 emit(name, detail) {
1628 return this.dispatchEvent(
1629 new CustomEvent(name, {
1630 detail,
1631 bubbles: true,
1632 composed: true
1633 })
1634 );
1635 }
1636 // ------------------------------------------------------------------
1637 // Internals
1638 // ------------------------------------------------------------------
1639 /**
1640 * Wire every `static props` entry to a matched property getter +
1641 * setter on the element. Setting the property reflects into the
1642 * attribute (so downstream observers + CSS selectors see it);
1643 * reading the property falls back to the attribute.
1644 */
1645 _installPropAccessors() {
1646 const ctor = this.constructor;
1647 for (const prop of ctor.props) {
1648 if (Object.getOwnPropertyDescriptor(this, prop)) {
1649 continue;
1650 }
1651 const attr = kebab(prop);
1652 Object.defineProperty(this, prop, {
1653 get: () => {
1654 if (prop in this._propValues) {
1655 return this._propValues[prop];
1656 }
1657 return this.getAttribute(attr);
1658 },
1659 set: (value) => {
1660 let str;
1661 if (value === null || value === void 0 || value === false) {
1662 str = null;
1663 } else if (value === true) {
1664 str = "";
1665 } else {
1666 str = String(value);
1667 }
1668 this._propValues[prop] = str;
1669 if (str === null) {
1670 this.removeAttribute(attr);
1671 } else {
1672 this.setAttribute(attr, str);
1673 }
1674 this.requestUpdate();
1675 },
1676 enumerable: true,
1677 configurable: true
1678 });
1679 }
1680 }
1681 /**
1682 * Schedule a render on the next microtask. Multiple property
1683 * assignments in the same tick collapse into a single render.
1684 */
1685 _scheduleRender() {
1686 if (this._renderScheduled || !this.isConnected) {
1687 return;
1688 }
1689 this._renderScheduled = true;
1690 queueMicrotask(() => {
1691 this._renderScheduled = false;
1692 if (!this.isConnected) {
1693 return;
1694 }
1695 render(this.render(), this._renderRoot);
1696 });
1697 }
1698 /**
1699 * Mount adoptable stylesheets onto the shadow root (via
1700 * `adoptedStyleSheets`) or the light DOM (via one `<style>`
1701 * tag per def). No-op if `static styles` is empty.
1702 */
1703 _adoptStyles() {
1704 const ctor = this.constructor;
1705 if (ctor.styles.length === 0) {
1706 return;
1707 }
1708 if (ctor.shadow && this.shadowRoot) {
1709 const sheets = ctor.styles.map((s) => s.sheet).filter((s) => s !== null);
1710 this.shadowRoot.adoptedStyleSheets = sheets;
1711 if (sheets.length !== ctor.styles.length) {
1712 for (const s of ctor.styles) {
1713 if (!s.sheet) {
1714 const tag = document.createElement("style");
1715 tag.textContent = s.cssText;
1716 this.shadowRoot.appendChild(tag);
1717 }
1718 }
1719 }
1720 } else {
1721 this._adoptLightStyles(ctor);
1722 }
1723 }
1724 _adoptLightStyles(ctor) {
1725 if (_Component._lightStylesAdopted.has(ctor)) {
1726 return;
1727 }
1728 _Component._lightStylesAdopted.add(ctor);
1729 for (const s of ctor.styles) {
1730 const tag = document.createElement("style");
1731 tag.dataset.wpdUi = this.tagName.toLowerCase();
1732 tag.textContent = s.cssText;
1733 document.head.appendChild(tag);
1734 }
1735 }
1736 };
1737 _Component.props = [];
1738 _Component.styles = [];
1739 _Component.shadow = true;
1740 _Component._lightStylesAdopted = /* @__PURE__ */ new WeakSet();
1741 let Component = _Component;
1742 function defineComponent(tag, ctor) {
1743 if (customElements.get(tag)) {
1744 return;
1745 }
1746 customElements.define(tag, ctor);
1747 }
1748 function kebab(s) {
1749 return s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
1750 }
1751 function camel(s) {
1752 return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1753 }
1754 const SUPPORTS_CONSTRUCTABLE_SHEETS = (() => {
1755 try {
1756 const s = new CSSStyleSheet();
1757 return typeof s.replaceSync === "function";
1758 } catch {
1759 return false;
1760 }
1761 })();
1762 function css(strings, ...values) {
1763 let text = strings[0];
1764 for (let i = 1; i < strings.length; i++) {
1765 const v = values[i - 1];
1766 if (typeof v === "string" || typeof v === "number") {
1767 text += String(v);
1768 } else if (v && v.__wpdCss) {
1769 text += v.cssText;
1770 } else {
1771 throw new TypeError(
1772 "[wpd-ui] css`` interpolations must be strings, numbers, or other css`` results. Got: " + typeof v
1773 );
1774 }
1775 text += strings[i];
1776 }
1777 if (SUPPORTS_CONSTRUCTABLE_SHEETS) {
1778 const sheet = new CSSStyleSheet();
1779 sheet.replaceSync(text);
1780 return { __wpdCss: true, sheet, cssText: text };
1781 }
1782 return { __wpdCss: true, sheet: null, cssText: text };
1783 }
1784 function computeAutoId(element) {
1785 const parts = [];
1786 const tabs = [];
1787 let windowId = null;
1788 let node = element.parentElement;
1789 while (node) {
1790 if (node === document.body || node === document.documentElement) {
1791 break;
1792 }
1793 const id = node.id || "";
1794 if (id.startsWith("wp-window-")) {
1795 windowId = id.slice("wp-window-".length);
1796 break;
1797 }
1798 if (node.tagName.toLowerCase() === "wpd-tabpanel") {
1799 const forValue = node.getAttribute("for");
1800 if (forValue) {
1801 tabs.unshift(forValue);
1802 }
1803 }
1804 node = node.parentElement;
1805 }
1806 if (windowId) {
1807 parts.push(slugify(windowId));
1808 }
1809 for (const tab of tabs) {
1810 parts.push("tab-" + slugify(tab));
1811 }
1812 const label = element.getAttribute("label");
1813 if (label) {
1814 parts.push(slugify(label));
1815 }
1816 if (parts.length === 0) {
1817 return "wpd-unnamed";
1818 }
1819 return "wpd-" + parts.filter((p) => p !== "").join("-");
1820 }
1821 function slugify(s) {
1822 return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1823 }
1824 function ensureAutoId(element) {
1825 if (element.id) {
1826 return element.id;
1827 }
1828 const id = computeAutoId(element);
1829 element.id = id;
1830 return id;
1831 }
1832 const styles$8 = 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}}`;
1833 const EXPANDER_KEY = "__wpd_expander__";
1834 const SELECT_KEY = "__wpd_select__";
1835 const _WpdTable = class _WpdTable extends Component {
1836 constructor() {
1837 super(...arguments);
1838 this._data = [];
1839 this._columns = [];
1840 this._filters = {};
1841 this._expanded = /* @__PURE__ */ new Set();
1842 this._subTable = null;
1843 this._sort = null;
1844 this._selection = /* @__PURE__ */ new Set();
1845 this._getRowId = (_row, index) => index;
1846 this._filterCache = /* @__PURE__ */ new Map();
1847 this._paintScheduled = false;
1848 this._stickyHeaderWarned = false;
1849 this._stickyRaceWarned = false;
1850 this._resizeObserver = null;
1851 this._stickyMicroScheduled = false;
1852 this._stickyRafHandle = null;
1853 this._loadingDesyncWarned = false;
1854 this._lastStickyIndex = -1;
1855 }
1856 // ------------------------------------------------------------------
1857 // Public properties — set from JS (use `.data=${...}` in templates).
1858 // ------------------------------------------------------------------
1859 /** The row buffer. Reassigning replaces (and clears expansion state). */
1860 get data() {
1861 return this._data;
1862 }
1863 set data(next) {
1864 this._data = Array.isArray(next) ? next.slice() : [];
1865 this._expanded.clear();
1866 this._schedulePaint();
1867 }
1868 /** Column descriptors. See {@link WpdTableColumn}. */
1869 get columns() {
1870 return this._columns;
1871 }
1872 set columns(next) {
1873 this._columns = Array.isArray(next) ? next.slice() : [];
1874 const keys = new Set(this._columns.map((c) => c.key));
1875 for (const k of Object.keys(this._filters)) {
1876 if (!keys.has(k)) {
1877 delete this._filters[k];
1878 }
1879 }
1880 for (const k of Array.from(this._filterCache.keys())) {
1881 if (!keys.has(k)) {
1882 this._filterCache.delete(k);
1883 }
1884 }
1885 if (this._sort && !keys.has(this._sort.key)) {
1886 this._sort = null;
1887 }
1888 this._schedulePaint();
1889 }
1890 /** Read or replace the current filter map. */
1891 get filters() {
1892 return { ...this._filters };
1893 }
1894 set filters(next) {
1895 this._filters = next ? { ...next } : {};
1896 this._schedulePaint();
1897 }
1898 /** Read or set the active sort. `null` clears it. */
1899 get sort() {
1900 return this._sort ? { ...this._sort } : null;
1901 }
1902 set sort(next) {
1903 this._sort = next ? { ...next } : null;
1904 this._schedulePaint();
1905 }
1906 /** Read or replace the selection (set of row ids). */
1907 get selection() {
1908 return new Set(this._selection);
1909 }
1910 set selection(next) {
1911 this._selection = new Set(next ?? []);
1912 this._schedulePaint();
1913 }
1914 /** The currently-selected rows (resolved from `selection` + `data`). */
1915 get selectedRows() {
1916 const out = [];
1917 this._data.forEach((row, i) => {
1918 if (this._selection.has(this._getRowId(row, i))) {
1919 out.push(row);
1920 }
1921 });
1922 return out;
1923 }
1924 /**
1925 * The rows currently visible — i.e. passing the active client-side
1926 * filters, in data order. This is the row set `selectAll()` and
1927 * the header select-all tri-state operate on.
1928 *
1929 * Destructive bulk consumers should resolve `selection` against
1930 * THIS list rather than `data`: selection deliberately survives
1931 * `data` reassignment, and a data-driven change (a realtime
1932 * refresh editing a row so it no longer matches an active filter)
1933 * can hide a selected row without any filter event firing. Rows
1934 * the user cannot see must never be swept into a destructive
1935 * action. See `collectSelectedItems()` in src/recycle-bin/index.ts
1936 * for the canonical consumer.
1937 *
1938 * @since 0.9.4
1939 */
1940 get visibleRows() {
1941 return this._filteredRows().map((entry) => entry.row);
1942 }
1943 /** Stable row-id extractor. Default is row index. */
1944 get getRowId() {
1945 return this._getRowId;
1946 }
1947 set getRowId(fn) {
1948 this._getRowId = typeof fn === "function" ? fn : (_r, i) => i;
1949 this._schedulePaint();
1950 }
1951 /**
1952 * Sub-table accessor. Return `null` (or omit) for rows with no
1953 * children. Return `{ columns, data }` to render a nested
1954 * `<wpd-table>` inline; or return any `Node` / `html\`\`` template
1955 * for fully custom expanded content.
1956 */
1957 get subTable() {
1958 return this._subTable;
1959 }
1960 set subTable(fn) {
1961 this._subTable = typeof fn === "function" ? fn : null;
1962 this._expanded.clear();
1963 this._schedulePaint();
1964 }
1965 /** Read or replace the expansion set (row indices that are open). */
1966 get expanded() {
1967 return new Set(this._expanded);
1968 }
1969 set expanded(next) {
1970 this._expanded = new Set(next ?? []);
1971 this._schedulePaint();
1972 }
1973 // ------------------------------------------------------------------
1974 // Programmatic methods
1975 // ------------------------------------------------------------------
1976 /** Open a row's sub-table by index. No-op if the index is out of range. */
1977 expand(index) {
1978 if (index < 0 || index >= this._data.length) {
1979 return;
1980 }
1981 if (this._expanded.has(index)) {
1982 return;
1983 }
1984 this._expanded.add(index);
1985 this.emit("wpd-table-expand-change", {
1986 row: this._data[index],
1987 index,
1988 expanded: true
1989 });
1990 this._schedulePaint();
1991 }
1992 /** Close a row's sub-table by index. No-op if it wasn't open. */
1993 collapse(index) {
1994 if (!this._expanded.has(index)) {
1995 return;
1996 }
1997 this._expanded.delete(index);
1998 this.emit("wpd-table-expand-change", {
1999 row: this._data[index],
2000 index,
2001 expanded: false
2002 });
2003 this._schedulePaint();
2004 }
2005 /** Open every row that has children. */
2006 expandAll() {
2007 if (!this._subTable) {
2008 return;
2009 }
2010 let changed = false;
2011 for (let i = 0; i < this._data.length; i++) {
2012 if (!this._subTable(this._data[i], i)) {
2013 continue;
2014 }
2015 if (!this._expanded.has(i)) {
2016 this._expanded.add(i);
2017 changed = true;
2018 }
2019 }
2020 if (changed) {
2021 this._schedulePaint();
2022 }
2023 }
2024 /** Close every open row. */
2025 collapseAll() {
2026 if (this._expanded.size === 0) {
2027 return;
2028 }
2029 this._expanded.clear();
2030 this._schedulePaint();
2031 }
2032 isExpanded(index) {
2033 return this._expanded.has(index);
2034 }
2035 /** Drop every active filter and emit `wpd-table-filter-change`. */
2036 clearFilters() {
2037 if (Object.keys(this._filters).length === 0) {
2038 return;
2039 }
2040 this._filters = {};
2041 this.emit("wpd-table-filter-change", { filters: {} });
2042 this._schedulePaint();
2043 }
2044 /** Drop the active sort and emit `wpd-table-sort-change`. */
2045 clearSort() {
2046 if (this._sort === null) {
2047 return;
2048 }
2049 this._sort = null;
2050 this.emit("wpd-table-sort-change", { sort: null });
2051 this._schedulePaint();
2052 }
2053 /**
2054 * Add a row id to the selection. Emits `wpd-table-selection-change`.
2055 *
2056 * Selection mutators (`select` / `deselect` / `selectAll` /
2057 * `clearSelection`) update the affected row in place via
2058 * {@link _syncSelectionDom} rather than re-rendering the whole
2059 * tbody — a rebuild would tear down the focused checkbox and
2060 * (because scroll-anchoring abandons a momentarily empty container)
2061 * could snap scroll back to the top.
2062 */
2063 select(id) {
2064 if (this._selection.has(id)) {
2065 return;
2066 }
2067 const mode = this._readSelectable();
2068 const previouslySelected = mode === "single" ? Array.from(this._selection) : [];
2069 if (mode === "single") {
2070 this._selection.clear();
2071 }
2072 this._selection.add(id);
2073 this._emitSelectionChange();
2074 this._syncSelectionDom([id, ...previouslySelected]);
2075 }
2076 /** Remove a row id from the selection. */
2077 deselect(id) {
2078 if (!this._selection.delete(id)) {
2079 return;
2080 }
2081 this._emitSelectionChange();
2082 this._syncSelectionDom([id]);
2083 }
2084 /** Select every visible row — the rows passing the active client-side filters (multi-mode only). */
2085 selectAll() {
2086 if (this._readSelectable() !== "multi") {
2087 return;
2088 }
2089 for (const { row, index } of this._filteredRows()) {
2090 this._selection.add(this._getRowId(row, index));
2091 }
2092 this._emitSelectionChange();
2093 this._syncSelectionDom("all");
2094 }
2095 /** Empty the selection. */
2096 clearSelection() {
2097 if (this._selection.size === 0) {
2098 return;
2099 }
2100 this._selection.clear();
2101 this._emitSelectionChange();
2102 this._syncSelectionDom("all");
2103 }
2104 /**
2105 * Apply a selection change to the existing tbody DOM without
2106 * rebuilding it. Updates each affected row's `is-selected` class
2107 * and `select-row-checkbox` `checked` state, then re-syncs the
2108 * header select-all checkbox (checked / indeterminate / empty).
2109 *
2110 * @param ids `'all'` to walk every row, or an iterable of row ids
2111 * whose rows need updating. Unknown ids are silently
2112 * skipped (row may not be in the current filter/page).
2113 */
2114 _syncSelectionDom(ids) {
2115 const root = this.shadowRoot;
2116 if (!root) {
2117 return;
2118 }
2119 const tbody = root.querySelector("tbody");
2120 if (!tbody) {
2121 return;
2122 }
2123 let needle = null;
2124 if (ids !== "all") {
2125 needle = /* @__PURE__ */ new Set();
2126 for (const id of ids) {
2127 needle.add(String(id));
2128 }
2129 }
2130 const rows = tbody.querySelectorAll(
2131 "tr[data-row-id]"
2132 );
2133 for (const tr of rows) {
2134 const rowIdStr = tr.dataset.rowId;
2135 if (rowIdStr === void 0) {
2136 continue;
2137 }
2138 if (needle && !needle.has(rowIdStr)) {
2139 continue;
2140 }
2141 const idx = Number(tr.dataset.rowIndex);
2142 if (!Number.isFinite(idx)) {
2143 continue;
2144 }
2145 const row = this._data[idx];
2146 if (row === void 0) {
2147 continue;
2148 }
2149 const id = this._getRowId(row, idx);
2150 const isSelected = this._selection.has(id);
2151 tr.classList.toggle("is-selected", isSelected);
2152 const cb = tr.querySelector(
2153 "input.select-row-checkbox"
2154 );
2155 if (cb && cb.checked !== isSelected) {
2156 cb.checked = isSelected;
2157 }
2158 }
2159 const headerCb = root.querySelector(
2160 "thead .select-all-checkbox"
2161 );
2162 if (headerCb) {
2163 const { total, selected } = this._visibleSelectionStats();
2164 headerCb.checked = total > 0 && selected === total;
2165 headerCb.indeterminate = selected > 0 && selected < total;
2166 }
2167 }
2168 /** Scroll the (filtered) row at `index` into view inside the table's scroll container. */
2169 scrollToRow(index) {
2170 const root = this.shadowRoot;
2171 if (!root) {
2172 return;
2173 }
2174 const rows = root.querySelectorAll(
2175 "tbody tr:not(.subtable):not(.empty):not(.skeleton)"
2176 );
2177 const row = rows[index];
2178 if (row) {
2179 row.scrollIntoView({ block: "nearest", inline: "nearest" });
2180 }
2181 }
2182 connectedCallback() {
2183 super.connectedCallback();
2184 this._schedulePaint();
2185 }
2186 disconnectedCallback() {
2187 this._resizeObserver?.disconnect();
2188 this._resizeObserver = null;
2189 if (this._stickyRafHandle !== null && typeof cancelAnimationFrame !== "undefined") {
2190 cancelAnimationFrame(this._stickyRafHandle);
2191 this._stickyRafHandle = null;
2192 }
2193 }
2194 /**
2195 * Force a sticky-offsets recompute. Public escape hatch for the
2196 * rare case where layout settles after every internal hook has
2197 * fired — e.g. an out-of-band font swap or a JS-driven width
2198 * change on an ancestor that doesn't bubble through ResizeObserver.
2199 *
2200 * Usually you don't need this: the component schedules recomputes
2201 * on a microtask + animation frame after every paint, and a
2202 * ResizeObserver on the inner scroll element catches geometry
2203 * changes thereafter. Reach for `recomputeLayout()` only if you've
2204 * confirmed that all of those pathways missed your case.
2205 */
2206 recomputeLayout() {
2207 this._applyStickyOffsets();
2208 this._measureHeaderHeight();
2209 }
2210 // ------------------------------------------------------------------
2211 // Skeleton + paint pipeline
2212 // ------------------------------------------------------------------
2213 render() {
2214 return html`
2215 <div class="scroll" part="scroll">
2216 <table part="table">
2217 <colgroup></colgroup>
2218 <thead></thead>
2219 <tbody></tbody>
2220 </table>
2221 </div>
2222 `;
2223 }
2224 requestUpdate() {
2225 super.requestUpdate();
2226 this._schedulePaint();
2227 }
2228 _schedulePaint() {
2229 if (this._paintScheduled || !this.isConnected) {
2230 return;
2231 }
2232 this._paintScheduled = true;
2233 queueMicrotask(() => {
2234 this._paintScheduled = false;
2235 if (!this.isConnected) {
2236 return;
2237 }
2238 this._paint();
2239 });
2240 }
2241 _paint() {
2242 const root = this.shadowRoot;
2243 if (!root) {
2244 return;
2245 }
2246 if (!root.querySelector("tbody")) {
2247 render(this.render(), root);
2248 }
2249 const colgroup = root.querySelector("colgroup");
2250 const thead = root.querySelector("thead");
2251 const tbody = root.querySelector("tbody");
2252 if (!colgroup || !thead || !tbody) {
2253 return;
2254 }
2255 const cols = this._effectiveColumns();
2256 const stickyN = this._readStickyColumns();
2257 this._lastStickyIndex = this._computeLastStickyIndex(cols, stickyN);
2258 this._paintColgroup(colgroup, cols);
2259 this._paintHead(thead, cols, stickyN);
2260 this._paintBody(tbody, cols, stickyN);
2261 this._applyStickyOffsets();
2262 this._measureHeaderHeight();
2263 this._scheduleStickyOffsets();
2264 this._maybeWarnStickyHeader();
2265 this._maybeWarnLoadingDesync(tbody);
2266 this._ensureResizeObserver();
2267 }
2268 /**
2269 * Diagnostic for the "I set `loading` but the skeleton never
2270 * appeared" footgun. If we get here with the attribute on but no
2271 * `.skeleton` rows in `tbody`, something between attribute set and
2272 * paint went off the rails — historically this happened when the
2273 * base `Component.attributeChangedCallback` called `_scheduleRender`
2274 * directly, bypassing our `requestUpdate` override. Same pattern as
2275 * the sticky-columns 0px tripwire: should never fire, but if it
2276 * does, names the bug instead of leaving the dev guessing.
2277 */
2278 _maybeWarnLoadingDesync(tbody) {
2279 if (this._loadingDesyncWarned) {
2280 return;
2281 }
2282 if (!this.hasAttribute("loading")) {
2283 return;
2284 }
2285 if (tbody.querySelector("tr.skeleton")) {
2286 return;
2287 }
2288 this._loadingDesyncWarned = true;
2289 console.warn(
2290 "[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."
2291 );
2292 }
2293 /**
2294 * Belt-and-braces sticky-offset scheduling.
2295 *
2296 * - Microtask: cheap, fires after the current task drains. Fixes
2297 * mounts where the synchronous read in `_paint` happened before
2298 * a sibling style applied.
2299 * - rAF: fires before the next paint. Catches "layout settles
2300 * after a queued style mutation" races — the most common cause
2301 * of "col 1 ended up at inset-inline-start: 0px".
2302 *
2303 * Both reduce to a no-op when nothing changed. The cost is two
2304 * extra DOM reads per paint; the win is the bug class disappears.
2305 */
2306 _scheduleStickyOffsets() {
2307 if (!this._stickyMicroScheduled) {
2308 this._stickyMicroScheduled = true;
2309 queueMicrotask(() => {
2310 this._stickyMicroScheduled = false;
2311 if (this.isConnected) {
2312 this._applyStickyOffsets();
2313 }
2314 });
2315 }
2316 if (this._stickyRafHandle === null && typeof requestAnimationFrame !== "undefined") {
2317 this._stickyRafHandle = requestAnimationFrame(() => {
2318 this._stickyRafHandle = null;
2319 if (this.isConnected) {
2320 this._applyStickyOffsets();
2321 this._measureHeaderHeight();
2322 }
2323 });
2324 }
2325 }
2326 /**
2327 * Wire a `ResizeObserver` on the inner `.scroll` element (NOT the
2328 * host). Why: the host's outer width is often pinned by its parent
2329 * panel — a vertical scrollbar appearing inside the table changes
2330 * the inner scroll-area width by ~15px without changing the host
2331 * size. Observing the host would miss that reflow and leave sticky
2332 * offsets stale.
2333 *
2334 * Idempotent — runs once after the first paint produces a real
2335 * `.scroll` element. Disconnect happens in `disconnectedCallback`.
2336 */
2337 _ensureResizeObserver() {
2338 if (this._resizeObserver) {
2339 return;
2340 }
2341 if (typeof ResizeObserver === "undefined") {
2342 return;
2343 }
2344 const scroll = this.shadowRoot?.querySelector(
2345 ".scroll"
2346 );
2347 if (!scroll) {
2348 return;
2349 }
2350 this._resizeObserver = new ResizeObserver(() => {
2351 if (!this.isConnected) {
2352 return;
2353 }
2354 this._applyStickyOffsets();
2355 this._measureHeaderHeight();
2356 this._stickyHeaderWarned = false;
2357 this._maybeWarnStickyHeader();
2358 });
2359 this._resizeObserver.observe(scroll);
2360 this._resizeObserver.observe(this);
2361 }
2362 _paintColgroup(colgroup, cols) {
2363 const out = [];
2364 for (const c of cols) {
2365 const col = document.createElement("col");
2366 if (c.width) {
2367 col.style.width = c.width;
2368 }
2369 out.push(col);
2370 }
2371 colgroup.replaceChildren(...out);
2372 }
2373 _paintHead(thead, cols, stickyN) {
2374 const newHeaderRow = document.createElement("tr");
2375 newHeaderRow.setAttribute("part", "header-row");
2376 for (let i = 0; i < cols.length; i++) {
2377 newHeaderRow.appendChild(this._buildHeaderCell(cols[i], i, stickyN));
2378 }
2379 const existingHeader = thead.querySelector(
2380 ':scope > tr[part="header-row"]'
2381 );
2382 if (existingHeader) {
2383 thead.replaceChild(newHeaderRow, existingHeader);
2384 } else {
2385 thead.insertBefore(newHeaderRow, thead.firstChild);
2386 }
2387 const hasFilter = cols.some(
2388 (c) => c.filter || Array.isArray(c.filterOptions) || typeof c.filterRender === "function"
2389 );
2390 let existingFilter = thead.querySelector(
2391 ":scope > tr.filter-row"
2392 );
2393 if (hasFilter) {
2394 const cells = [];
2395 for (let i = 0; i < cols.length; i++) {
2396 cells.push(this._buildFilterCell(cols[i], i, stickyN));
2397 }
2398 if (!existingFilter) {
2399 existingFilter = document.createElement("tr");
2400 existingFilter.classList.add("filter-row");
2401 existingFilter.setAttribute("part", "filter-row");
2402 thead.appendChild(existingFilter);
2403 }
2404 const current = Array.from(existingFilter.children);
2405 let same = current.length === cells.length;
2406 if (same) {
2407 for (let i = 0; i < cells.length; i++) {
2408 if (current[i] !== cells[i]) {
2409 same = false;
2410 break;
2411 }
2412 }
2413 }
2414 if (!same) {
2415 const wanted = new Set(cells);
2416 for (const cell of cells) {
2417 existingFilter.appendChild(cell);
2418 }
2419 for (const child of Array.from(existingFilter.children)) {
2420 if (!wanted.has(child)) {
2421 existingFilter.removeChild(child);
2422 }
2423 }
2424 }
2425 } else if (existingFilter) {
2426 existingFilter.remove();
2427 }
2428 }
2429 _buildHeaderCell(col, index, stickyN) {
2430 const th = document.createElement("th");
2431 th.setAttribute("scope", "col");
2432 th.dataset.key = col.key;
2433 this._applyCellClasses(th, col, index, stickyN);
2434 if (col.minWidth) {
2435 th.style.minWidth = col.minWidth;
2436 }
2437 if (col.key === SELECT_KEY) {
2438 const mode = this._readSelectable();
2439 if (mode === "multi") {
2440 const cb = document.createElement("input");
2441 cb.type = "checkbox";
2442 cb.className = "select-all-checkbox";
2443 cb.setAttribute("data-noclick", "");
2444 cb.setAttribute("aria-label", "Select all rows");
2445 const { total, selected } = this._visibleSelectionStats();
2446 cb.checked = total > 0 && selected === total;
2447 cb.indeterminate = selected > 0 && selected < total;
2448 cb.addEventListener("change", () => {
2449 if (cb.checked) {
2450 this.selectAll();
2451 } else {
2452 this.clearSelection();
2453 }
2454 });
2455 th.appendChild(cb);
2456 }
2457 return th;
2458 }
2459 th.textContent = col.label ?? (col.key === EXPANDER_KEY ? "" : col.key);
2460 if (col.sortable) {
2461 th.classList.add("is-sortable");
2462 const isActive = this._sort?.key === col.key;
2463 const indicator = document.createElement("span");
2464 indicator.className = "sort-indicator";
2465 let arrow = "";
2466 if (isActive) {
2467 arrow = this._sort.direction === "asc" ? " ▲" : " ▼";
2468 }
2469 indicator.textContent = arrow;
2470 th.appendChild(indicator);
2471 if (isActive) {
2472 th.classList.add(
2473 this._sort.direction === "asc" ? "sort-asc" : "sort-desc"
2474 );
2475 }
2476 th.addEventListener("click", () => this._cycleSort(col.key));
2477 }
2478 return th;
2479 }
2480 _buildFilterCell(col, index, stickyN) {
2481 const cached = this._filterCache.get(col.key);
2482 const hasExplicitOptions = Array.isArray(col.filterOptions);
2483 const hasCustomRender = typeof col.filterRender === "function";
2484 let desiredKind;
2485 if (!col.filter && !hasExplicitOptions && !hasCustomRender || col.key === EXPANDER_KEY || col.key === SELECT_KEY) {
2486 desiredKind = "none";
2487 } else if (hasCustomRender) {
2488 desiredKind = "custom";
2489 } else if (col.filter === "select" || hasExplicitOptions) {
2490 desiredKind = "select";
2491 } else {
2492 desiredKind = "text";
2493 }
2494 if (cached && cached.kind === desiredKind) {
2495 cached.th.className = "";
2496 this._applyCellClasses(cached.th, col, index, stickyN);
2497 if (desiredKind === "select") {
2498 const select = cached.control;
2499 const opts = this._resolveFilterOptions(col);
2500 const optsKey = opts.map((o) => o.value).join("|");
2501 if (optsKey !== cached.optionsKey) {
2502 this._populateSelect(select, opts, this._filters[col.key] ?? "");
2503 cached.optionsKey = optsKey;
2504 } else {
2505 select.value = this._filters[col.key] ?? "";
2506 }
2507 } else if (desiredKind === "text") {
2508 const input = cached.control;
2509 const want = this._filters[col.key] ?? "";
2510 if (input.value !== want && input.ownerDocument.activeElement !== input) {
2511 input.value = want;
2512 }
2513 } else if (desiredKind === "custom" && col.filterRender) {
2514 col.filterRender(cached.th, {
2515 value: this._filters[col.key] ?? "",
2516 setValue: (next) => this._onFilterChange(col.key, next),
2517 col
2518 });
2519 }
2520 return cached.th;
2521 }
2522 const th = document.createElement("th");
2523 this._applyCellClasses(th, col, index, stickyN);
2524 if (desiredKind === "none") {
2525 this._filterCache.set(col.key, {
2526 th,
2527 control: null,
2528 optionsKey: "",
2529 kind: "none"
2530 });
2531 return th;
2532 }
2533 if (desiredKind === "custom" && col.filterRender) {
2534 col.filterRender(th, {
2535 value: this._filters[col.key] ?? "",
2536 setValue: (next) => this._onFilterChange(col.key, next),
2537 col
2538 });
2539 this._filterCache.set(col.key, {
2540 th,
2541 control: null,
2542 optionsKey: "",
2543 kind: "custom"
2544 });
2545 return th;
2546 }
2547 let control;
2548 let optionsKey = "";
2549 if (desiredKind === "select") {
2550 const select = document.createElement("select");
2551 select.classList.add("filter-select");
2552 select.setAttribute("data-noclick", "");
2553 select.setAttribute(
2554 "aria-label",
2555 `Filter ${col.label ?? col.key}`
2556 );
2557 const opts = this._resolveFilterOptions(col);
2558 this._populateSelect(select, opts, this._filters[col.key] ?? "");
2559 optionsKey = opts.map((o) => o.value).join("|");
2560 select.addEventListener("change", () => {
2561 this._onFilterChange(col.key, select.value);
2562 });
2563 control = select;
2564 } else {
2565 const input = document.createElement("input");
2566 input.type = "search";
2567 input.classList.add("filter-input");
2568 input.setAttribute("data-noclick", "");
2569 input.setAttribute("placeholder", "Filter…");
2570 input.setAttribute("aria-label", `Filter ${col.label ?? col.key}`);
2571 input.value = this._filters[col.key] ?? "";
2572 input.addEventListener("input", () => {
2573 this._onFilterChange(col.key, input.value);
2574 });
2575 control = input;
2576 }
2577 th.appendChild(control);
2578 this._filterCache.set(col.key, {
2579 th,
2580 control,
2581 optionsKey,
2582 kind: desiredKind
2583 });
2584 return th;
2585 }
2586 _populateSelect(select, options, current) {
2587 select.replaceChildren();
2588 const all = document.createElement("option");
2589 all.value = "";
2590 all.textContent = "All";
2591 select.appendChild(all);
2592 for (const opt of options) {
2593 const el = document.createElement("option");
2594 el.value = opt.value;
2595 el.textContent = opt.label;
2596 if (opt.value === current) {
2597 el.selected = true;
2598 }
2599 select.appendChild(el);
2600 }
2601 select.value = current;
2602 }
2603 /**
2604 * Resolve the option list for a select-filter column. Explicit
2605 * `filterOptions` win — that's the contract for server-driven
2606 * tables that need the dropdown to list values not present on
2607 * the current page. Without `filterOptions`, fall back to the
2608 * unique row values in the column (legacy behaviour for
2609 * client-side tables).
2610 */
2611 _resolveFilterOptions(col) {
2612 if (Array.isArray(col.filterOptions)) {
2613 return col.filterOptions;
2614 }
2615 return this._uniqueValues(col.key).map((v) => ({
2616 value: v,
2617 label: v
2618 }));
2619 }
2620 // ------------------------------------------------------------------
2621 // Body
2622 // ------------------------------------------------------------------
2623 _paintBody(tbody, cols, stickyN) {
2624 tbody.replaceChildren();
2625 if (this.hasAttribute("loading")) {
2626 const count = this._readLoadingRows();
2627 for (let i = 0; i < count; i++) {
2628 tbody.appendChild(this._buildSkeletonRow(cols, i));
2629 }
2630 return;
2631 }
2632 const filtered = this._sortedRows(this._filteredRows());
2633 if (filtered.length === 0) {
2634 tbody.appendChild(this._buildEmptyRow(cols.length));
2635 return;
2636 }
2637 for (const { row, index } of filtered) {
2638 tbody.appendChild(this._buildBodyRow(row, index, cols, stickyN));
2639 if (this._expanded.has(index) && this._subTable) {
2640 const sub = this._subTable(row, index);
2641 if (sub) {
2642 tbody.appendChild(this._buildSubTableRow(sub, cols.length));
2643 }
2644 }
2645 }
2646 }
2647 _buildEmptyRow(colspan) {
2648 const tr = document.createElement("tr");
2649 tr.classList.add("empty");
2650 const td = document.createElement("td");
2651 td.colSpan = colspan;
2652 const slot = document.createElement("slot");
2653 slot.name = "empty";
2654 slot.textContent = this.getAttribute("empty") || "No data";
2655 td.appendChild(slot);
2656 tr.appendChild(td);
2657 return tr;
2658 }
2659 _buildSkeletonRow(cols, seed) {
2660 const tr = document.createElement("tr");
2661 tr.classList.add("skeleton");
2662 tr.setAttribute("aria-hidden", "true");
2663 for (const _c of cols) {
2664 const td = document.createElement("td");
2665 const bar = document.createElement("span");
2666 bar.className = "skeleton-bar";
2667 const widthPct = 50 + (seed * 7 + tr.children.length * 13) % 40;
2668 bar.style.width = `${widthPct}%`;
2669 td.appendChild(bar);
2670 tr.appendChild(td);
2671 }
2672 return tr;
2673 }
2674 _buildBodyRow(row, rowIndex, cols, stickyN) {
2675 const tr = document.createElement("tr");
2676 tr.setAttribute("part", "row");
2677 tr.dataset.rowIndex = String(rowIndex);
2678 const id = this._getRowId(row, rowIndex);
2679 tr.dataset.rowId = String(id);
2680 if (this._selection.has(id)) {
2681 tr.classList.add("is-selected");
2682 }
2683 tr.addEventListener("click", (e) => {
2684 this._onRowClick(row, rowIndex, e);
2685 });
2686 for (let i = 0; i < cols.length; i++) {
2687 tr.appendChild(
2688 this._buildBodyCell(cols[i], i, row, rowIndex, stickyN)
2689 );
2690 }
2691 return tr;
2692 }
2693 _buildBodyCell(col, colIndex, row, rowIndex, stickyN) {
2694 const td = document.createElement("td");
2695 this._applyCellClasses(td, col, colIndex, stickyN);
2696 if (col.minWidth) {
2697 td.style.minWidth = col.minWidth;
2698 }
2699 if (col.key === SELECT_KEY) {
2700 const id = this._getRowId(row, rowIndex);
2701 const cb = document.createElement("input");
2702 cb.type = "checkbox";
2703 cb.className = "select-row-checkbox";
2704 cb.setAttribute("data-noclick", "");
2705 cb.setAttribute("aria-label", "Select row");
2706 cb.checked = this._selection.has(id);
2707 cb.addEventListener("change", () => {
2708 if (cb.checked) {
2709 this.select(id);
2710 } else {
2711 this.deselect(id);
2712 }
2713 });
2714 td.appendChild(cb);
2715 return td;
2716 }
2717 if (col.key === EXPANDER_KEY) {
2718 const hasChildren = this._subTable ? !!this._subTable(row, rowIndex) : false;
2719 if (!hasChildren) {
2720 return td;
2721 }
2722 const isOpen = this._expanded.has(rowIndex);
2723 const btn = document.createElement("button");
2724 btn.type = "button";
2725 btn.className = "expander";
2726 btn.setAttribute("data-noclick", "");
2727 btn.setAttribute("aria-expanded", isOpen ? "true" : "false");
2728 btn.setAttribute(
2729 "aria-label",
2730 isOpen ? "Collapse row" : "Expand row"
2731 );
2732 btn.textContent = isOpen ? "▾" : "▸";
2733 btn.addEventListener("click", (e) => {
2734 this._toggleRow(rowIndex, row, e);
2735 });
2736 td.appendChild(btn);
2737 return td;
2738 }
2739 const value = row[col.key];
2740 if (col.render) {
2741 const out = col.render(value, row, rowIndex);
2742 this._mountCellContent(td, out);
2743 } else if (value !== null && value !== void 0) {
2744 td.textContent = String(value);
2745 }
2746 return td;
2747 }
2748 _buildSubTableRow(sub, colspan) {
2749 const tr = document.createElement("tr");
2750 tr.classList.add("subtable");
2751 tr.setAttribute("part", "subtable-row");
2752 const td = document.createElement("td");
2753 td.colSpan = colspan;
2754 const inner = document.createElement("div");
2755 inner.classList.add("subtable-inner");
2756 if (sub instanceof Node) {
2757 inner.appendChild(sub);
2758 } else if (isTemplateResult(sub)) {
2759 render(sub, inner);
2760 } else {
2761 const nested = document.createElement("wpd-table");
2762 nested.columns = sub.columns;
2763 nested.data = sub.data;
2764 if (sub.subTable) {
2765 nested.subTable = sub.subTable;
2766 }
2767 inner.appendChild(nested);
2768 }
2769 td.appendChild(inner);
2770 tr.appendChild(td);
2771 return tr;
2772 }
2773 _mountCellContent(td, out) {
2774 if (typeof out === "string") {
2775 td.textContent = out;
2776 return;
2777 }
2778 if (out instanceof Node) {
2779 td.appendChild(out);
2780 return;
2781 }
2782 if (isTemplateResult(out)) {
2783 render(out, td);
2784 }
2785 }
2786 // ------------------------------------------------------------------
2787 // Behavior
2788 // ------------------------------------------------------------------
2789 _onFilterChange(key, value) {
2790 if (value === "") {
2791 delete this._filters[key];
2792 } else {
2793 this._filters[key] = value;
2794 }
2795 this.emit("wpd-table-filter-change", { filters: { ...this._filters } });
2796 const root = this.shadowRoot;
2797 const tbody = root?.querySelector("tbody");
2798 if (tbody) {
2799 const cols = this._effectiveColumns();
2800 const stickyN = this._readStickyColumns();
2801 this._lastStickyIndex = this._computeLastStickyIndex(cols, stickyN);
2802 this._paintBody(tbody, cols, stickyN);
2803 this._applyStickyOffsets();
2804 }
2805 }
2806 _onRowClick(row, index, e) {
2807 const path = e.composedPath?.() ?? [];
2808 for (const node of path) {
2809 if (node instanceof Element && node.hasAttribute("data-noclick")) {
2810 return;
2811 }
2812 if (node === this) {
2813 break;
2814 }
2815 }
2816 this.emit("wpd-table-row-click", { row, index, originalEvent: e });
2817 }
2818 _toggleRow(index, row, e) {
2819 e.stopPropagation();
2820 const isOpen = this._expanded.has(index);
2821 if (isOpen) {
2822 this._expanded.delete(index);
2823 } else {
2824 this._expanded.add(index);
2825 }
2826 this.emit("wpd-table-expand-change", {
2827 row,
2828 index,
2829 expanded: !isOpen
2830 });
2831 this._schedulePaint();
2832 }
2833 _cycleSort(key) {
2834 if (!this._sort || this._sort.key !== key) {
2835 this._sort = { key, direction: "asc" };
2836 } else if (this._sort.direction === "asc") {
2837 this._sort = { key, direction: "desc" };
2838 } else {
2839 this._sort = null;
2840 }
2841 this.emit("wpd-table-sort-change", {
2842 sort: this._sort ? { ...this._sort } : null
2843 });
2844 this._schedulePaint();
2845 }
2846 _emitSelectionChange() {
2847 this.emit("wpd-table-selection-change", {
2848 selection: Array.from(this._selection),
2849 rows: this.selectedRows
2850 });
2851 }
2852 // ------------------------------------------------------------------
2853 // Filtering + sorting
2854 // ------------------------------------------------------------------
2855 _filteredRows() {
2856 const out = [];
2857 const active = Object.keys(this._filters).filter(
2858 (k) => this._filters[k] !== ""
2859 );
2860 for (let i = 0; i < this._data.length; i++) {
2861 const row = this._data[i];
2862 let pass = true;
2863 for (const key of active) {
2864 const col = this._columns.find((c) => c.key === key);
2865 if (col && typeof col.filterRender === "function") {
2866 continue;
2867 }
2868 const filter = this._filters[key] ?? "";
2869 const cell = row[key];
2870 const cellStr = cell === null || cell === void 0 ? "" : String(cell);
2871 if (col?.filter === "select") {
2872 if (cellStr !== filter) {
2873 pass = false;
2874 break;
2875 }
2876 } else if (!cellStr.toLowerCase().includes(filter.toLowerCase())) {
2877 pass = false;
2878 break;
2879 }
2880 }
2881 if (pass) {
2882 out.push({ row, index: i });
2883 }
2884 }
2885 return out;
2886 }
2887 _sortedRows(rows) {
2888 if (!this._sort) {
2889 return rows;
2890 }
2891 const col = this._columns.find((c) => c.key === this._sort.key);
2892 if (!col) {
2893 return rows;
2894 }
2895 const dir = this._sort.direction === "desc" ? -1 : 1;
2896 const out = rows.slice();
2897 out.sort((a, b) => {
2898 const av = col.sortValue ? col.sortValue(a.row, a.row[col.key]) : a.row[col.key];
2899 const bv = col.sortValue ? col.sortValue(b.row, b.row[col.key]) : b.row[col.key];
2900 return compareValues(av, bv) * dir;
2901 });
2902 return out;
2903 }
2904 _uniqueValues(key) {
2905 const seen = /* @__PURE__ */ new Set();
2906 for (const row of this._data) {
2907 const v = row[key];
2908 if (v === null || v === void 0) {
2909 continue;
2910 }
2911 seen.add(String(v));
2912 }
2913 return Array.from(seen).sort();
2914 }
2915 /**
2916 * Selection stats over the VISIBLE (client-side-filtered) rows —
2917 * the same set `selectAll()` operates on. The header select-all
2918 * tri-state derives from these so "checked" always means "every
2919 * row the user can see is selected", even while ids of currently
2920 * hidden rows linger in the selection set.
2921 */
2922 _visibleSelectionStats() {
2923 let total = 0;
2924 let selected = 0;
2925 for (const { row, index } of this._filteredRows()) {
2926 total++;
2927 if (this._selection.has(this._getRowId(row, index))) {
2928 selected++;
2929 }
2930 }
2931 return { total, selected };
2932 }
2933 // ------------------------------------------------------------------
2934 // Sticky columns + attribute reads
2935 // ------------------------------------------------------------------
2936 _readStickyColumns() {
2937 const raw = parseInt(this.getAttribute("sticky-columns") || "0", 10);
2938 return Number.isFinite(raw) && raw > 0 ? raw : 0;
2939 }
2940 _readLoadingRows() {
2941 const raw = parseInt(this.getAttribute("loading-rows") || "5", 10);
2942 return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 100) : 5;
2943 }
2944 _readSelectable() {
2945 const v = this.getAttribute("selectable");
2946 if (v === "single") {
2947 return "single";
2948 }
2949 if (v === "multi" || v === "") {
2950 return "multi";
2951 }
2952 return null;
2953 }
2954 /**
2955 * Sticky-band membership. The first N columns get pinned, with two
2956 * per-column overrides: `column.sticky = true` opts in even outside
2957 * the band; `column.sticky = false` opts out within it.
2958 */
2959 _isStickyIndex(index, stickyN, col) {
2960 if (col.sticky === false) {
2961 return false;
2962 }
2963 if (col.sticky === true) {
2964 return true;
2965 }
2966 return index < stickyN;
2967 }
2968 _computeLastStickyIndex(cols, stickyN) {
2969 let last = -1;
2970 for (let i = 0; i < cols.length; i++) {
2971 if (this._isStickyIndex(i, stickyN, cols[i])) {
2972 last = i;
2973 }
2974 }
2975 return last;
2976 }
2977 _applyCellClasses(cell, col, index, stickyN) {
2978 if (col.key === EXPANDER_KEY) {
2979 cell.classList.add("col-expander");
2980 }
2981 if (col.key === SELECT_KEY) {
2982 cell.classList.add("col-select");
2983 }
2984 if (col.align === "center") {
2985 cell.classList.add("align-center");
2986 }
2987 if (col.align === "end") {
2988 cell.classList.add("align-end");
2989 }
2990 const sticky = this._isStickyIndex(index, stickyN, col);
2991 if (sticky) {
2992 cell.classList.add("is-sticky");
2993 if (index === this._lastStickyIndex) {
2994 cell.classList.add("is-sticky-edge");
2995 }
2996 }
2997 }
2998 _effectiveColumns() {
2999 const out = [];
3000 if (this._readSelectable()) {
3001 out.push({
3002 key: SELECT_KEY,
3003 label: "",
3004 // The descriptor width is painted onto a `<col>`
3005 // element and is the authoritative column-width
3006 // source in table-layout: auto — CSS `td { width }`
3007 // is ignored once `<col>` has a value. Pair with
3008 // the matching `td.col-select` rule (zero
3009 // `padding-inline`, `text-align: center`) so the
3010 // checkbox sits with breathing room on both sides.
3011 width: "40px",
3012 align: "center"
3013 });
3014 }
3015 if (this._subTable) {
3016 out.push({
3017 key: EXPANDER_KEY,
3018 label: "",
3019 // Same contract as col-select. 36px column +
3020 // 20px button + zero padding centers the chevron
3021 // with ~8px on each side.
3022 width: "36px",
3023 align: "center"
3024 });
3025 }
3026 out.push(...this._columns);
3027 return out;
3028 }
3029 /**
3030 * Walk the header row, sum the natural widths of the sticky cells,
3031 * then write cumulative `inset-inline-start` offsets onto every
3032 * row's matching cells.
3033 */
3034 _applyStickyOffsets() {
3035 const root = this.shadowRoot;
3036 if (!root) {
3037 return;
3038 }
3039 const headRow = root.querySelector("thead tr");
3040 if (!headRow) {
3041 return;
3042 }
3043 const ths = Array.from(headRow.children);
3044 const offsets = [];
3045 let acc = 0;
3046 for (let i = 0; i < ths.length; i++) {
3047 offsets[i] = acc;
3048 if (ths[i].classList.contains("is-sticky")) {
3049 acc += ths[i].offsetWidth;
3050 }
3051 }
3052 const rows = root.querySelectorAll(
3053 "thead tr, tbody tr:not(.subtable):not(.empty):not(.skeleton)"
3054 );
3055 rows.forEach((r) => {
3056 const cells = Array.from(r.children);
3057 for (let i = 0; i < cells.length; i++) {
3058 if (cells[i].classList.contains("is-sticky")) {
3059 cells[i].style.insetInlineStart = `${offsets[i]}px`;
3060 }
3061 }
3062 });
3063 this._maybeWarnStickyOffsetRace(ths, offsets);
3064 }
3065 _maybeWarnStickyOffsetRace(ths, offsets) {
3066 if (this._stickyRaceWarned) {
3067 return;
3068 }
3069 const stickyN = this._readStickyColumns();
3070 if (stickyN < 2) {
3071 return;
3072 }
3073 const lastIdx = Math.min(stickyN - 1, ths.length - 1);
3074 if (lastIdx <= 0) {
3075 return;
3076 }
3077 if (offsets[lastIdx] !== 0) {
3078 return;
3079 }
3080 if (this.offsetWidth === 0) {
3081 return;
3082 }
3083 this._stickyRaceWarned = true;
3084 const w0 = ths[0]?.offsetWidth ?? 0;
3085 console.warn(
3086 `[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.`
3087 );
3088 }
3089 _measureHeaderHeight() {
3090 const root = this.shadowRoot;
3091 if (!root) {
3092 return;
3093 }
3094 const headRow = root.querySelector("thead tr");
3095 if (!headRow) {
3096 return;
3097 }
3098 const h = headRow.offsetHeight;
3099 if (h > 0) {
3100 this.style.setProperty("--wpd-table-header-height", `${h}px`);
3101 }
3102 }
3103 /**
3104 * Once-per-element warning for the most common sticky-header
3105 * mistake: forgetting to give the table a scroll container. Without
3106 * a max-height (or a scrolling ancestor), `position: sticky`
3107 * silently does nothing because there's no scrollport for it to
3108 * stick within.
3109 */
3110 _maybeWarnStickyHeader() {
3111 if (this._stickyHeaderWarned) {
3112 return;
3113 }
3114 if (!this.hasAttribute("sticky-header")) {
3115 return;
3116 }
3117 if (this.hasAttribute("loading") || this._data.length < 8) {
3118 return;
3119 }
3120 const scroll = this.shadowRoot?.querySelector(
3121 ".scroll"
3122 );
3123 if (!scroll) {
3124 return;
3125 }
3126 if (scroll.offsetWidth === 0) {
3127 return;
3128 }
3129 if (scroll.scrollHeight <= scroll.clientHeight + 1) {
3130 this._stickyHeaderWarned = true;
3131 console.warn(
3132 "[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."
3133 );
3134 }
3135 }
3136 };
3137 _WpdTable.props = [
3138 "stickyColumns",
3139 "stickyHeader",
3140 "striped",
3141 "hover",
3142 "compact",
3143 "bordered",
3144 "empty",
3145 "loading",
3146 "loadingRows",
3147 "selectable"
3148 ];
3149 _WpdTable.styles = [styles$8];
3150 _WpdTable.help = {
3151 title: "Table",
3152 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.",
3153 status: "experimental",
3154 since: "0.6.0",
3155 props: [
3156 {
3157 name: "sticky-columns",
3158 type: "integer",
3159 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."
3160 },
3161 {
3162 name: "sticky-header",
3163 type: "boolean",
3164 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."
3165 },
3166 { name: "striped", type: "boolean", description: "Zebra rows." },
3167 { name: "hover", type: "boolean", description: "Highlight rows on hover." },
3168 { name: "compact", type: "boolean", description: "Tighter padding + smaller font." },
3169 { name: "bordered", type: "boolean", description: "Vertical cell borders." },
3170 {
3171 name: "empty",
3172 type: "string",
3173 description: "Fallback text shown when there are no rows. For richer empty states, project light-DOM content into the `empty` slot."
3174 },
3175 {
3176 name: "loading",
3177 type: "boolean",
3178 description: "Paint shimmering skeleton rows in place of body content. Filters / sort headers stay live."
3179 },
3180 {
3181 name: "loading-rows",
3182 type: "integer",
3183 description: "Number of skeleton rows when loading. Default 5."
3184 },
3185 {
3186 name: "selectable",
3187 type: '"single" | "multi"',
3188 description: "Auto-prepend a checkbox column. `multi` puts a select-all checkbox in the header; `single` enforces at-most-one selected."
3189 }
3190 ],
3191 events: [
3192 { name: "wpd-table-filter-change", description: "Filter input changed." },
3193 { name: "wpd-table-sort-change", description: "Header click cycled the sort." },
3194 { name: "wpd-table-selection-change", description: "Selection set changed." },
3195 { name: "wpd-table-row-click", description: "Body row clicked (skips data-noclick descendants)." },
3196 { name: "wpd-table-expand-change", description: "Sub-table toggled." }
3197 ],
3198 slots: [
3199 { name: "empty", description: "Custom empty-state content (CTA, illustration, etc.)." }
3200 ],
3201 cssProps: [
3202 { name: "--wpd-table-bg" },
3203 { name: "--wpd-table-border" },
3204 { name: "--wpd-table-column-border" },
3205 { name: "--wpd-table-header-bg" },
3206 { name: "--wpd-table-row-hover" },
3207 { name: "--wpd-table-stripe" },
3208 { name: "--wpd-table-cell-padding" },
3209 { name: "--wpd-table-font-size" },
3210 { name: "--wpd-table-max-height" },
3211 { name: "--wpd-table-skeleton-color" }
3212 ],
3213 example: html`
3214 <wpd-table id="sample-table" sticky-header striped hover></wpd-table>
3215 `
3216 };
3217 let WpdTable = _WpdTable;
3218 function isTemplateResult(v) {
3219 return !!v && v.__wpdHtml === true;
3220 }
3221 function compareValues(a, b) {
3222 if (a === b) {
3223 return 0;
3224 }
3225 if (a === null || a === void 0) {
3226 return -1;
3227 }
3228 if (b === null || b === void 0) {
3229 return 1;
3230 }
3231 if (typeof a === "number" && typeof b === "number") {
3232 return a - b;
3233 }
3234 if (a instanceof Date && b instanceof Date) {
3235 return a.getTime() - b.getTime();
3236 }
3237 const an = Number(a);
3238 const bn = Number(b);
3239 if (Number.isFinite(an) && Number.isFinite(bn)) {
3240 return an - bn;
3241 }
3242 return String(a).localeCompare(String(b));
3243 }
3244 defineComponent("wpd-table", WpdTable);
3245 const tabsStyles = css`:host{display:flex;gap:4px;margin-bottom:10px;border-bottom:1px solid var( --desktop-mode-border,#dcdcde )}`;
3246 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}`;
3247 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 )}`;
3248 const _WpdTab = class _WpdTab extends Component {
3249 render() {
3250 this.setAttribute("role", "tab");
3251 return html`
3252 <button type="button" @click=${() => this._onPick()}>
3253 <slot></slot>
3254 </button>
3255 `;
3256 }
3257 _onPick() {
3258 this.emit("wpd-tab-pick", {
3259 value: this.value
3260 });
3261 }
3262 };
3263 _WpdTab.props = ["value"];
3264 _WpdTab.styles = [tabStyles];
3265 _WpdTab.help = {
3266 title: "Tab",
3267 summary: "Single tab inside a <wpd-tabs> strip. Carries its identifier via `value`; aria-selected + tabindex are mirrored by the parent.",
3268 status: "stable",
3269 since: "0.7.0",
3270 props: [
3271 {
3272 name: "value",
3273 type: "string",
3274 description: "Identifier the tab contributes to the parent strip selection."
3275 }
3276 ],
3277 slots: [
3278 { name: "(default)", description: "Visible tab label." }
3279 ],
3280 events: [
3281 {
3282 name: "wpd-tab-pick",
3283 description: "Internal event bubbled to the parent <wpd-tabs>. Consumers should listen for wpd-tab-change on the strip instead.",
3284 detail: "{ value: string | null }"
3285 }
3286 ]
3287 };
3288 let WpdTab = _WpdTab;
3289 defineComponent("wpd-tab", WpdTab);
3290 const _WpdTabs = class _WpdTabs extends Component {
3291 connectedCallback() {
3292 super.connectedCallback();
3293 this.addEventListener("wpd-tab-pick", (e) => {
3294 const detail = e.detail;
3295 e.stopPropagation();
3296 this.value = detail.value;
3297 this.emit("wpd-tab-change", { value: detail.value });
3298 });
3299 }
3300 /**
3301 * Declarative item-list setter. Replaces the existing `<wpd-tab>`
3302 * children with a fresh set built from a `{ value, label }`
3303 * array. The `value` prop is preserved if it still matches a new
3304 * entry; otherwise it falls back to the first item.
3305 *
3306 * Lets plugins that populate tabs dynamically (route-driven
3307 * admin screens, filtered lists) replace the declarative
3308 * markup with a one-liner:
3309 *
3310 * ```js
3311 * tabs.items = [
3312 * { value: 'calc', label: 'Calc' },
3313 * { value: 'convert', label: 'Convert' },
3314 * ];
3315 * ```
3316 *
3317 * @since 0.5.0
3318 */
3319 set items(list) {
3320 replaceChildren(this, "wpd-tab", list);
3321 const current = this.value;
3322 const stillValid = current !== null && list.some((i) => i.value === current);
3323 if (!stillValid && list.length > 0) {
3324 this.value = list[0].value;
3325 } else {
3326 this.requestUpdate();
3327 }
3328 }
3329 render() {
3330 this.setAttribute("role", "tablist");
3331 const label = this.label || "";
3332 if (label) {
3333 this.setAttribute("aria-label", label);
3334 }
3335 const current = this.value;
3336 queueMicrotask(() => {
3337 const tabs = this.querySelectorAll("wpd-tab");
3338 for (const tab of Array.from(tabs)) {
3339 const v = tab.getAttribute("value");
3340 tab.setAttribute(
3341 "aria-selected",
3342 v === current ? "true" : "false"
3343 );
3344 tab.setAttribute("tabindex", v === current ? "0" : "-1");
3345 }
3346 syncTabpanels(this, current);
3347 });
3348 return html`<slot></slot>`;
3349 }
3350 };
3351 _WpdTabs.props = ["value", "label"];
3352 _WpdTabs.styles = [tabsStyles];
3353 _WpdTabs.help = {
3354 title: "Tabs",
3355 summary: 'Underline-accent tab strip. Pair with sibling <wpd-tabpanel for="…"> elements and the strip auto-toggles their hidden attribute on selection.',
3356 status: "stable",
3357 since: "0.7.0",
3358 props: [
3359 {
3360 name: "value",
3361 type: "string",
3362 description: "Currently active tab value. Mirrored to child <wpd-tab> aria-selected."
3363 },
3364 {
3365 name: "label",
3366 type: "string",
3367 description: "aria-label for the tablist — describe the tab group for assistive tech."
3368 }
3369 ],
3370 slots: [
3371 {
3372 name: "(default)",
3373 description: '<wpd-tab value="…"> children forming the strip.'
3374 }
3375 ],
3376 events: [
3377 {
3378 name: "wpd-tab-change",
3379 description: "Fires when the active tab changes.",
3380 detail: "{ value: string }"
3381 }
3382 ],
3383 example: html`
3384 <wpd-tabs value="one" label="Demo tabs">
3385 <wpd-tab value="one">One</wpd-tab>
3386 <wpd-tab value="two">Two</wpd-tab>
3387 <wpd-tab value="three">Three</wpd-tab>
3388 </wpd-tabs>
3389 <wpd-tabpanel for="one">First panel.</wpd-tabpanel>
3390 <wpd-tabpanel for="two">Second panel.</wpd-tabpanel>
3391 <wpd-tabpanel for="three">Third panel.</wpd-tabpanel>
3392 `
3393 };
3394 let WpdTabs = _WpdTabs;
3395 defineComponent("wpd-tabs", WpdTabs);
3396 const _WpdTabPanel = class _WpdTabPanel extends Component {
3397 // Shadow DOM — the render target for this component is its
3398 // own shadow root, which holds a single `<slot>` that projects
3399 // whatever the caller placed between the `<wpd-tabpanel>` open
3400 // and close tags. Slotted children remain light-DOM descendants
3401 // of the panel element (the slot rendering mechanism doesn't
3402 // move them), so `panel.querySelector(...)` from plugin render
3403 // callbacks keeps working.
3404 //
3405 // Earlier 0.5.0 builds of this component used light DOM with
3406 // a `<slot>` render, which wiped the panel's server-rendered
3407 // template content on first mount — every `render()` writes
3408 // into `_renderRoot`, and with light DOM that's the panel
3409 // itself. Shadow DOM isolates the render surface.
3410 connectedCallback() {
3411 super.connectedCallback();
3412 this.setAttribute("role", "tabpanel");
3413 if (!this.hasAttribute("tabindex")) {
3414 this.setAttribute("tabindex", "0");
3415 }
3416 const owner = findOwningTabs(this);
3417 if (owner) {
3418 syncTabpanels(owner, owner.getAttribute("value"));
3419 }
3420 }
3421 render() {
3422 return html`<slot></slot>`;
3423 }
3424 };
3425 _WpdTabPanel.props = ["for"];
3426 _WpdTabPanel.styles = [tabPanelStyles];
3427 _WpdTabPanel.help = {
3428 title: "Tab panel",
3429 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.',
3430 status: "stable",
3431 since: "0.5.0",
3432 props: [
3433 {
3434 name: "for",
3435 type: "string",
3436 description: "Matches the `value` of the owning <wpd-tab>. Panel is shown when its parent tabs strip is on that value."
3437 }
3438 ],
3439 slots: [
3440 { name: "(default)", description: "Panel body content." }
3441 ]
3442 };
3443 let WpdTabPanel = _WpdTabPanel;
3444 defineComponent("wpd-tabpanel", WpdTabPanel);
3445 function replaceChildren(host, tag, items) {
3446 const existing = host.querySelectorAll(`:scope > ${tag}`);
3447 for (const el of Array.from(existing)) {
3448 el.remove();
3449 }
3450 for (const item of items) {
3451 const el = document.createElement(tag);
3452 el.setAttribute("value", item.value);
3453 el.textContent = item.label;
3454 host.appendChild(el);
3455 }
3456 }
3457 function findOwningTabs(panel) {
3458 const parent = panel.parentElement;
3459 if (!parent) {
3460 return null;
3461 }
3462 const sibling = parent.querySelector(":scope > wpd-tabs");
3463 if (sibling) {
3464 return sibling;
3465 }
3466 return panel.closest("wpd-tabs");
3467 }
3468 function syncTabpanels(tabs, value) {
3469 const panels = /* @__PURE__ */ new Set();
3470 const parent = tabs.parentElement;
3471 if (parent) {
3472 for (const p of Array.from(
3473 parent.querySelectorAll(":scope > wpd-tabpanel")
3474 )) {
3475 panels.add(p);
3476 }
3477 }
3478 for (const p of Array.from(
3479 tabs.querySelectorAll(":scope > wpd-tabpanel")
3480 )) {
3481 panels.add(p);
3482 }
3483 for (const panel of panels) {
3484 const pfor = panel.getAttribute("for");
3485 const active = pfor !== null && pfor === value;
3486 if (active) {
3487 panel.removeAttribute("hidden");
3488 } else {
3489 panel.setAttribute("hidden", "");
3490 }
3491 panel.setAttribute("aria-hidden", active ? "false" : "true");
3492 }
3493 }
3494 const styles$7 = css`:host{display:inline-flex;max-width:100%}.wpd-tag-input{display:inline-flex;flex-wrap:wrap;align-items:center;gap:var( --wpd-tag-input-gap,4px );padding:var( --wpd-tag-input-padding,2px );min-height:24px;max-width:100%}.wpd-tag-input__chips{display:inline-flex;flex-wrap:wrap;align-items:center;gap:var( --wpd-tag-input-gap,4px );min-width:0}.wpd-tag-input__add{appearance:none;display:inline-flex;align-items:center;gap:3px;padding:1px 8px;min-height:22px;font:inherit;font-size:11px;font-weight:500;line-height:1;color:var( --wpd-tag-input-add-fg,#50575e );background:transparent;border:1px dashed var( --wpd-tag-input-add-border,#c3c4c7 );border-radius:999px;cursor:pointer;transition:background-color 0.12s ease,color 0.12s ease,border-color 0.12s ease}.wpd-tag-input__add:hover:not(:disabled ){background:rgba( 0,0,0,0.04 );color:var( --wpd-tag-input-add-fg-hover,#1d2327 );border-color:var( --wpd-tag-input-add-border-hover,#8c8f94 )}.wpd-tag-input__add:focus-visible{outline:none;border-style:solid;box-shadow:0 0 0 2px var( --wp-admin-theme-color,#2271b1 )}.wpd-tag-input__add:disabled{opacity:0.5;cursor:not-allowed}.wpd-tag-input__add svg{display:block}.wpd-tag-input__editor{position:relative;display:inline-flex;align-items:center;flex:0 1 auto;min-width:120px}.wpd-tag-input__input{appearance:none;font:inherit;font-size:12px;line-height:1.4;padding:2px 8px;border:1px solid var( --wpd-tag-input-input-border,#2271b1 );border-radius:999px;background:var( --wpd-tag-input-input-bg,#fff );color:var( --wpd-tag-input-input-fg,#1d2327 );min-width:80px;max-width:240px}.wpd-tag-input__input:focus{outline:none;box-shadow:0 0 0 2px color-mix( in srgb,var( --wp-admin-theme-color,#2271b1 ) 30%,transparent )}.wpd-tag-input__suggestions{position:absolute;top:calc( 100% + 4px );left:0;min-width:220px;max-width:320px;max-height:240px;overflow-y:auto;padding:4px 0;background:var( --wpd-tag-input-pop-bg,#fff );color:var( --wpd-tag-input-pop-fg,#1d2327 );border:1px solid var( --wpd-tag-input-pop-border,#c3c4c7 );border-radius:8px;box-shadow:0 6px 16px rgba( 0,0,0,0.08 ),0 1px 2px rgba( 0,0,0,0.06 );z-index:50}.wpd-tag-input__suggestion-item{display:flex;align-items:center;gap:6px;padding:6px 12px;font-size:13px;cursor:pointer;user-select:none}.wpd-tag-input__suggestion-item[ aria-selected='true' ]{background:color-mix( in srgb,var( --wp-admin-theme-color,#2271b1 ) 10%,transparent );color:var( --wp-admin-theme-color,#2271b1 )}.wpd-tag-input__suggestion-create{font-style:italic;color:var( --wpd-tag-input-create-fg,#50575e );border-top:1px solid var( --wpd-tag-input-pop-divider,#f0f0f1 )}.wpd-tag-input__suggestion-create[ aria-selected='true' ]{color:var( --wp-admin-theme-color,#2271b1 )}.wpd-tag-input__suggestion-empty,.wpd-tag-input__suggestion-loading{display:flex;align-items:center;gap:8px;padding:8px 12px;font-size:12px;color:var( --wpd-tag-input-pop-muted,#646970 )}.wpd-tag-input__suggestion-spinner{display:inline-block;width:10px;height:10px;border-radius:50%;border:2px solid currentColor;border-top-color:transparent;animation:wpd-tag-input-spin 0.8s linear infinite}@keyframes wpd-tag-input-spin{to{transform:rotate( 360deg )}}:host( [ disabled ] ) .wpd-tag-input{opacity:0.6;pointer-events:none}`;
3495 const styles$6 = 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 )}`;
3496 const _WpdChip = class _WpdChip extends Component {
3497 constructor() {
3498 super(...arguments);
3499 this._onHostKeyDown = (e) => {
3500 const dismissible = this.dismissible !== null;
3501 if (!dismissible) {
3502 return;
3503 }
3504 if (e.key === "Backspace" || e.key === "Delete") {
3505 e.preventDefault();
3506 const disabled = this.disabled !== null;
3507 if (disabled) {
3508 return;
3509 }
3510 const label = this.label ?? "";
3511 this.emit("wpd-chip-dismiss", { label });
3512 }
3513 };
3514 }
3515 connectedCallback() {
3516 super.connectedCallback();
3517 this.addEventListener("keydown", this._onHostKeyDown);
3518 }
3519 disconnectedCallback() {
3520 this.removeEventListener("keydown", this._onHostKeyDown);
3521 }
3522 render() {
3523 const label = this.label ?? "";
3524 const dismissible = this.dismissible !== null;
3525 const disabled = this.disabled !== null;
3526 return html`
3527 <span part="chip" class="wpd-chip">
3528 <span class="wpd-chip__icon">
3529 <slot name="icon"></slot>
3530 </span>
3531 <span class="wpd-chip__label">
3532 ${label === "" ? html`<slot></slot>` : label}
3533 </span>
3534 ${dismissible ? html`
3535 <button
3536 part="dismiss"
3537 class="wpd-chip__dismiss"
3538 type="button"
3539 aria-label=${`Remove ${label || "chip"}`}
3540 ?disabled=${disabled}
3541 @click=${(e) => this._onDismiss(e)}
3542 >
3543 ${_iconCross$1()}
3544 </button>
3545 ` : html``}
3546 </span>
3547 `;
3548 }
3549 _onDismiss(e) {
3550 e.stopPropagation();
3551 const disabled = this.disabled !== null;
3552 if (disabled) {
3553 return;
3554 }
3555 const label = this.label ?? "";
3556 this.emit("wpd-chip-dismiss", { label });
3557 }
3558 };
3559 _WpdChip.props = [
3560 "label",
3561 "tone",
3562 "size",
3563 "dismissible",
3564 "disabled",
3565 "pending"
3566 ];
3567 _WpdChip.styles = [styles$6];
3568 _WpdChip.help = {
3569 title: "Chip",
3570 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.",
3571 status: "experimental",
3572 since: "0.8.0",
3573 props: [
3574 {
3575 name: "label",
3576 type: "string",
3577 description: "Visible text. Falls back to the default slot when omitted."
3578 },
3579 {
3580 name: "tone",
3581 type: "'neutral' | 'accent' | 'positive' | 'warning' | 'danger'",
3582 default: "neutral",
3583 description: "Color variant. Mirrors <wpd-badge> tones."
3584 },
3585 {
3586 name: "size",
3587 type: "'default' | 'compact'",
3588 default: "default",
3589 description: "Vertical density. Compact halves horizontal padding for dense lists."
3590 },
3591 {
3592 name: "dismissible",
3593 type: "boolean attribute",
3594 description: "Renders a trailing × button. Click / Enter / Space emits wpd-chip-dismiss."
3595 },
3596 {
3597 name: "disabled",
3598 type: "boolean attribute",
3599 description: "Visually mutes the chip and blocks the dismiss button. Useful while a parent is mid-update."
3600 },
3601 {
3602 name: "pending",
3603 type: "boolean attribute",
3604 description: "Renders a subtle pulse animation while a REST mutation is in flight. Auto-applied by <wpd-tag-input>; safe to set by hand."
3605 }
3606 ],
3607 slots: [
3608 { name: "(default)", description: "Fallback label when `label` is unset." },
3609 {
3610 name: "icon",
3611 description: "Leading icon (Dashicon, SVG, image). Inherits text color."
3612 }
3613 ],
3614 parts: [
3615 { name: "chip", description: "The pill container." },
3616 {
3617 name: "dismiss",
3618 description: "The trailing × button (when `dismissible`)."
3619 }
3620 ],
3621 events: [
3622 {
3623 name: "wpd-chip-dismiss",
3624 description: "Fires when the dismiss button is activated. Detail carries the chip's label so a delegated listener can act without DOM walking.",
3625 detail: "{ label: string }"
3626 }
3627 ],
3628 cssProps: [
3629 { name: "--wpd-chip-bg", description: "Background color." },
3630 { name: "--wpd-chip-fg", description: "Text color." },
3631 { name: "--wpd-chip-border", description: "Border shorthand." },
3632 {
3633 name: "--wpd-chip-padding",
3634 description: "Padding shorthand.",
3635 default: "2px 8px"
3636 },
3637 {
3638 name: "--wpd-chip-radius",
3639 description: "Corner radius.",
3640 default: "999px"
3641 },
3642 {
3643 name: "--wpd-chip-label-max",
3644 description: "Max width of the inner label before ellipsis.",
3645 default: "220px"
3646 }
3647 ],
3648 example: html`
3649 <wpd-cluster gap="6">
3650 <wpd-chip label="Neutral"></wpd-chip>
3651 <wpd-chip label="Accent" tone="accent"></wpd-chip>
3652 <wpd-chip label="Positive" tone="positive"></wpd-chip>
3653 <wpd-chip label="Warning" tone="warning"></wpd-chip>
3654 <wpd-chip label="Danger" tone="danger"></wpd-chip>
3655 <wpd-chip label="Dismissible" dismissible></wpd-chip>
3656 </wpd-cluster>
3657 `
3658 };
3659 let WpdChip = _WpdChip;
3660 defineComponent("wpd-chip", WpdChip);
3661 function _iconCross$1() {
3662 return html`
3663 <svg
3664 viewBox="0 0 12 12"
3665 width="10"
3666 height="10"
3667 aria-hidden="true"
3668 focusable="false"
3669 fill="none"
3670 stroke="currentColor"
3671 stroke-width="1.5"
3672 stroke-linecap="round"
3673 >
3674 <path d="M3 3 L9 9 M9 3 L3 9" />
3675 </svg>
3676 `;
3677 }
3678 const _WpdTagInput = class _WpdTagInput extends Component {
3679 constructor() {
3680 super(...arguments);
3681 this._value = [];
3682 this._suggestions = [];
3683 this._suggestionsLoading = false;
3684 this._query = "";
3685 this._highlight = -1;
3686 this._focusedChip = -1;
3687 this._onDocumentPointerDown = (e) => {
3688 if (!this.isOpen) {
3689 return;
3690 }
3691 const path = e.composedPath();
3692 if (path.includes(this)) {
3693 return;
3694 }
3695 this.closeInput();
3696 };
3697 }
3698 // Resolves to the inline input AFTER each render. Re-queried on
3699 // every `requestUpdate` because the shadow tree builds fresh
3700 // nodes per render.
3701 get _input() {
3702 const root = this.shadowRoot;
3703 return root ? root.querySelector(".wpd-tag-input__input") : null;
3704 }
3705 // --- Public properties (JS-only) -------------------------------------
3706 get value() {
3707 return this._value;
3708 }
3709 set value(next) {
3710 this._value = Array.isArray(next) ? next.slice() : [];
3711 if (this._focusedChip >= this._value.length) {
3712 this._focusedChip = -1;
3713 }
3714 this.requestUpdate();
3715 }
3716 get suggestions() {
3717 return this._suggestions;
3718 }
3719 set suggestions(next) {
3720 this._suggestions = Array.isArray(next) ? next.slice() : [];
3721 this._highlight = this._suggestions.length > 0 ? 0 : -1;
3722 this._suggestionsLoading = false;
3723 this.requestUpdate();
3724 }
3725 get suggestionsLoading() {
3726 return this._suggestionsLoading;
3727 }
3728 set suggestionsLoading(next) {
3729 this._suggestionsLoading = !!next;
3730 this.requestUpdate();
3731 }
3732 get query() {
3733 return this._query;
3734 }
3735 get isOpen() {
3736 return this.open !== null;
3737 }
3738 /**
3739 * Open the inline input + suggestions popover. Equivalent to
3740 * clicking the "+" trigger. Call from the parent to start tag
3741 * entry programmatically (e.g. paste interception).
3742 */
3743 openInput() {
3744 if (this.isOpen) {
3745 return;
3746 }
3747 this.open = "";
3748 this._query = "";
3749 this._highlight = -1;
3750 this.emit("wpd-tag-open", {});
3751 queueMicrotask(() => {
3752 this._input?.focus();
3753 this._emitSuggest("");
3754 });
3755 }
3756 /**
3757 * Close the inline input. Use from a parent to dismiss after a
3758 * background save resolves.
3759 */
3760 closeInput() {
3761 if (!this.isOpen) {
3762 return;
3763 }
3764 this.open = null;
3765 this._query = "";
3766 this._suggestions = [];
3767 this._highlight = -1;
3768 this._suggestionsLoading = false;
3769 this.emit("wpd-tag-close", {});
3770 this.requestUpdate();
3771 }
3772 // --- Lifecycle --------------------------------------------------------
3773 connectedCallback() {
3774 super.connectedCallback();
3775 document.addEventListener("pointerdown", this._onDocumentPointerDown, true);
3776 }
3777 disconnectedCallback() {
3778 document.removeEventListener("pointerdown", this._onDocumentPointerDown, true);
3779 }
3780 // --- Render -----------------------------------------------------------
3781 render() {
3782 const isOpen = this.isOpen;
3783 const disabled = this.disabled !== null;
3784 const readonly = this.readonly !== null;
3785 const removable = this.removable !== null || this.removable === null && !readonly;
3786 const creatable = this.creatable !== null;
3787 const addLabel = this["add-label"] || "+ Add";
3788 const placeholder = this.placeholder || "Add a tag…";
3789 return html`
3790 <span
3791 class="wpd-tag-input"
3792 role="group"
3793 aria-label=${this.label ?? ""}
3794 >
3795 ${this._renderChips(removable, disabled)}
3796 ${this._renderTrailing({
3797 isOpen,
3798 readonly,
3799 disabled,
3800 placeholder,
3801 creatable,
3802 addLabel
3803 })}
3804 </span>
3805 `;
3806 }
3807 _renderTrailing(opts) {
3808 if (opts.isOpen) {
3809 return this._renderEditor(opts.placeholder, opts.creatable);
3810 }
3811 if (opts.readonly || opts.disabled) {
3812 return html``;
3813 }
3814 return this._renderTrigger(opts.addLabel);
3815 }
3816 _renderChips(removable, disabled) {
3817 const tags = this._value;
3818 if (tags.length === 0) {
3819 return html``;
3820 }
3821 return html`
3822 <span class="wpd-tag-input__chips" role="list">
3823 ${tags.map((tag, idx) => {
3824 const tone = tag.tone ?? "neutral";
3825 return html`
3826 <wpd-chip
3827 role="listitem"
3828 size="compact"
3829 tone=${tone}
3830 label=${tag.label}
3831 ?dismissible=${removable && !disabled}
3832 ?disabled=${disabled}
3833 ?pending=${!!tag.pending}
3834 tabindex=${idx === this._focusedChip ? "0" : "-1"}
3835 data-idx=${String(idx)}
3836 @wpd-chip-dismiss=${(e) => this._onChipDismiss(e, tag)}
3837 @focus=${() => this._focusedChip = idx}
3838 ></wpd-chip>
3839 `;
3840 })}
3841 </span>
3842 `;
3843 }
3844 _renderTrigger(addLabel) {
3845 const disabled = this.disabled !== null;
3846 return html`
3847 <button
3848 type="button"
3849 class="wpd-tag-input__add"
3850 aria-label=${addLabel}
3851 aria-haspopup="listbox"
3852 aria-expanded="false"
3853 ?disabled=${disabled}
3854 @click=${() => this.openInput()}
3855 >
3856 ${_iconPlus()}
3857 <span>${addLabel}</span>
3858 </button>
3859 `;
3860 }
3861 _renderEditor(placeholder, creatable) {
3862 const showSuggestions = this._suggestions.length > 0 || this._suggestionsLoading || creatable && this._query.trim().length > 0;
3863 return html`
3864 <span class="wpd-tag-input__editor">
3865 <input
3866 class="wpd-tag-input__input"
3867 type="text"
3868 autocomplete="off"
3869 autocapitalize="off"
3870 spellcheck="false"
3871 placeholder=${placeholder}
3872 .value=${this._query}
3873 aria-autocomplete="list"
3874 aria-expanded=${showSuggestions ? "true" : "false"}
3875 aria-activedescendant=${this._highlight >= 0 ? `wpd-tag-suggestion-${this._highlight}` : ""}
3876 @input=${(e) => this._onInput(e)}
3877 @keydown=${(e) => this._onInputKeyDown(e)}
3878 @blur=${(e) => this._onInputBlur(e)}
3879 />
3880 ${showSuggestions ? this._renderSuggestions(creatable) : html``}
3881 </span>
3882 `;
3883 }
3884 _renderSuggestions(creatable) {
3885 const trimmed = this._query.trim();
3886 const items = this._suggestions;
3887 const showCreate = creatable && trimmed.length > 0 && !items.some((s) => s.label.toLowerCase() === trimmed.toLowerCase()) && !this._value.some((v) => v.label.toLowerCase() === trimmed.toLowerCase());
3888 return html`
3889 <div
3890 class="wpd-tag-input__suggestions"
3891 role="listbox"
3892 >
3893 ${this._suggestionsLoading ? html`
3894 <div class="wpd-tag-input__suggestion-loading">
3895 <span class="wpd-tag-input__suggestion-spinner" aria-hidden="true"></span>
3896 <span>Searching…</span>
3897 </div>
3898 ` : html``}
3899 ${items.length === 0 && !this._suggestionsLoading && !showCreate ? html`
3900 <div class="wpd-tag-input__suggestion-empty">
3901 ${trimmed.length > 0 ? "No matches." : "Type to search."}
3902 </div>
3903 ` : html``}
3904 ${items.map((item, idx) => {
3905 const selected = idx === this._highlight;
3906 return html`
3907 <div
3908 id=${`wpd-tag-suggestion-${idx}`}
3909 role="option"
3910 aria-selected=${selected ? "true" : "false"}
3911 class="wpd-tag-input__suggestion-item"
3912 @mousedown=${(e) => {
3913 e.preventDefault();
3914 this._addSuggestion(item, false);
3915 }}
3916 @mouseenter=${() => {
3917 this._highlight = idx;
3918 this.requestUpdate();
3919 }}
3920 >
3921 <span>${item.label}</span>
3922 </div>
3923 `;
3924 })}
3925 ${showCreate ? html`
3926 <div
3927 id=${`wpd-tag-suggestion-${items.length}`}
3928 role="option"
3929 aria-selected=${this._highlight === items.length ? "true" : "false"}
3930 class="wpd-tag-input__suggestion-item wpd-tag-input__suggestion-create"
3931 @mousedown=${(e) => {
3932 e.preventDefault();
3933 this._addSuggestion(
3934 { label: trimmed },
3935 true
3936 );
3937 }}
3938 @mouseenter=${() => {
3939 this._highlight = items.length;
3940 this.requestUpdate();
3941 }}
3942 >
3943 Create "${trimmed}"
3944 </div>
3945 ` : html``}
3946 </div>
3947 `;
3948 }
3949 // --- Event handlers ---------------------------------------------------
3950 _onChipDismiss(e, tag) {
3951 e.stopPropagation();
3952 this.emit("wpd-tag-remove", { tag });
3953 }
3954 _onInput(e) {
3955 const value = e.target.value;
3956 this._query = value;
3957 this._emitSuggest(value);
3958 }
3959 _emitSuggest(query) {
3960 const minQuery = parseInt(
3961 this["min-query"] || "0",
3962 10
3963 ) || 0;
3964 if (query.length < minQuery) {
3965 this._suggestions = [];
3966 this._suggestionsLoading = false;
3967 this.requestUpdate();
3968 return;
3969 }
3970 this._suggestionsLoading = true;
3971 this.requestUpdate();
3972 this.emit("wpd-tag-suggest", { query });
3973 }
3974 _onInputKeyDown(e) {
3975 const creatable = this.creatable !== null;
3976 const items = this._suggestions;
3977 const trimmed = this._query.trim();
3978 const showCreate = creatable && trimmed.length > 0 && !items.some((s) => s.label.toLowerCase() === trimmed.toLowerCase()) && !this._value.some((v) => v.label.toLowerCase() === trimmed.toLowerCase());
3979 const totalSelectable = items.length + (showCreate ? 1 : 0);
3980 switch (e.key) {
3981 case "ArrowDown": {
3982 if (totalSelectable === 0) {
3983 return;
3984 }
3985 e.preventDefault();
3986 this._highlight = this._highlight + 1 >= totalSelectable ? 0 : this._highlight + 1;
3987 this.requestUpdate();
3988 return;
3989 }
3990 case "ArrowUp": {
3991 if (totalSelectable === 0) {
3992 return;
3993 }
3994 e.preventDefault();
3995 this._highlight = this._highlight <= 0 ? totalSelectable - 1 : this._highlight - 1;
3996 this.requestUpdate();
3997 return;
3998 }
3999 case "Enter": {
4000 e.preventDefault();
4001 if (this._highlight >= 0 && this._highlight < items.length) {
4002 this._addSuggestion(items[this._highlight], false);
4003 return;
4004 }
4005 if (this._highlight === items.length && showCreate) {
4006 this._addSuggestion({ label: trimmed }, true);
4007 return;
4008 }
4009 if (showCreate && trimmed.length > 0) {
4010 this._addSuggestion({ label: trimmed }, true);
4011 return;
4012 }
4013 return;
4014 }
4015 case "Escape": {
4016 e.preventDefault();
4017 this.closeInput();
4018 return;
4019 }
4020 case "Backspace": {
4021 if (this._query === "" && this._value.length > 0) {
4022 e.preventDefault();
4023 const lastIdx = this._value.length - 1;
4024 if (this._focusedChip === lastIdx) {
4025 this.emit("wpd-tag-remove", {
4026 tag: this._value[lastIdx]
4027 });
4028 this._focusedChip = -1;
4029 } else {
4030 this._focusedChip = lastIdx;
4031 this.requestUpdate();
4032 }
4033 }
4034 return;
4035 }
4036 default:
4037 if (this._focusedChip !== -1) {
4038 this._focusedChip = -1;
4039 }
4040 }
4041 }
4042 _onInputBlur(_e) {
4043 queueMicrotask(() => {
4044 if (!this.shadowRoot?.activeElement) {
4045 this.closeInput();
4046 }
4047 });
4048 }
4049 _addSuggestion(tag, isNew) {
4050 const exists = this._value.some(
4051 (v) => v.label.toLowerCase() === tag.label.toLowerCase()
4052 );
4053 if (exists) {
4054 this._query = "";
4055 this._highlight = -1;
4056 this._suggestions = [];
4057 this.requestUpdate();
4058 this._input?.focus();
4059 return;
4060 }
4061 this.emit("wpd-tag-add", { tag, isNew });
4062 this._query = "";
4063 this._highlight = -1;
4064 this._suggestions = [];
4065 this._suggestionsLoading = false;
4066 this.requestUpdate();
4067 queueMicrotask(() => {
4068 this._input?.focus();
4069 });
4070 }
4071 };
4072 _WpdTagInput.props = [
4073 "label",
4074 "placeholder",
4075 "add-label",
4076 "creatable",
4077 "removable",
4078 "disabled",
4079 "readonly",
4080 "size",
4081 "min-query",
4082 "open"
4083 ];
4084 _WpdTagInput.styles = [styles$7];
4085 _WpdTagInput.help = {
4086 title: "Tag input",
4087 summary: "Multi-tag picker with autocomplete and free-form creation. Purely presentational — emits wpd-tag-suggest / wpd-tag-add / wpd-tag-remove and lets the consumer drive REST + optimistic UI.",
4088 status: "experimental",
4089 since: "0.8.0",
4090 props: [
4091 {
4092 name: "label",
4093 type: "string",
4094 description: "Accessible label for the inline input."
4095 },
4096 {
4097 name: "placeholder",
4098 type: "string",
4099 description: "Native placeholder for the inline input.",
4100 default: "Add a tag…"
4101 },
4102 {
4103 name: "add-label",
4104 type: "string",
4105 description: 'Label of the "+" trigger button.',
4106 default: "+ Add"
4107 },
4108 {
4109 name: "creatable",
4110 type: "boolean attribute",
4111 description: "Allow Enter on a non-matching query to emit `wpd-tag-add` with `isNew: true`. Off by default — opt in for taxonomies the user is allowed to extend."
4112 },
4113 {
4114 name: "removable",
4115 type: "boolean attribute",
4116 description: "Show × on every chip and emit `wpd-tag-remove` on click. On by default; switch off for read-only views."
4117 },
4118 {
4119 name: "disabled",
4120 type: "boolean attribute",
4121 description: "Disables the entire control. Chips render but the trigger / input / dismiss buttons are inert."
4122 },
4123 {
4124 name: "readonly",
4125 type: "boolean attribute",
4126 description: 'Hides the "+" trigger and chip × buttons. Same as setting `creatable=false` and `removable=false` together.'
4127 },
4128 {
4129 name: "size",
4130 type: "'default' | 'compact'",
4131 default: "default",
4132 description: "Density preset. Compact suits dense table cells."
4133 },
4134 {
4135 name: "min-query",
4136 type: "integer (string)",
4137 default: "0",
4138 description: "Minimum query length before `wpd-tag-suggest` fires. Set to 1 or 2 for taxonomies with thousands of terms."
4139 },
4140 {
4141 name: "open",
4142 type: "boolean attribute",
4143 description: "Two-way reflected: present while the inline input is showing. Setting it externally opens / closes the picker."
4144 }
4145 ],
4146 events: [
4147 {
4148 name: "wpd-tag-suggest",
4149 description: "Fires when the user types in the input. Consumer fetches suggestions and assigns them back via `el.suggestions = […]`.",
4150 detail: "{ query: string }"
4151 },
4152 {
4153 name: "wpd-tag-add",
4154 description: "Fires when the user picks a suggestion or, with `creatable`, presses Enter on a free-form value. Consumer mutates `value`.",
4155 detail: "{ tag: WpdTagItem; isNew: boolean }"
4156 },
4157 {
4158 name: "wpd-tag-remove",
4159 description: "Fires when × on a chip is activated. Consumer mutates `value`.",
4160 detail: "{ tag: WpdTagItem }"
4161 },
4162 {
4163 name: "wpd-tag-open",
4164 description: "Fires when the inline input opens.",
4165 detail: "{}"
4166 },
4167 {
4168 name: "wpd-tag-close",
4169 description: "Fires when the inline input closes.",
4170 detail: "{}"
4171 }
4172 ],
4173 cssProps: [
4174 {
4175 name: "--wpd-tag-input-gap",
4176 description: "Gap between chips / between chips and trigger.",
4177 default: "4px"
4178 },
4179 {
4180 name: "--wpd-tag-input-padding",
4181 description: "Padding around the chip row.",
4182 default: "2px"
4183 },
4184 {
4185 name: "--wpd-tag-input-add-fg",
4186 description: 'Foreground color of the "+ Add" trigger.'
4187 },
4188 { name: "--wpd-tag-input-pop-bg", description: "Suggestions popover background." }
4189 ],
4190 example: html`
4191 <wpd-tag-input
4192 label="Tags"
4193 placeholder="Add a tag…"
4194 creatable
4195 ></wpd-tag-input>
4196 `
4197 };
4198 let WpdTagInput = _WpdTagInput;
4199 defineComponent("wpd-tag-input", WpdTagInput);
4200 function _iconPlus() {
4201 return html`
4202 <svg
4203 viewBox="0 0 12 12"
4204 width="9"
4205 height="9"
4206 aria-hidden="true"
4207 focusable="false"
4208 fill="none"
4209 stroke="currentColor"
4210 stroke-width="2"
4211 stroke-linecap="round"
4212 >
4213 <path d="M6 2 L6 10 M2 6 L10 6" />
4214 </svg>
4215 `;
4216 }
4217 const styles$5 = css`:host{display:flex;width:100%;min-width:0;max-width:100%;align-items:stretch}.wpd-cat{display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:var( --wpd-cat-padding,2px );min-height:24px;max-width:100%;width:100%}.wpd-cat__chips{display:inline-flex;flex-wrap:wrap;align-items:center;gap:var( --wpd-cat-gap,4px );min-width:0}.wpd-cat__chains{display:flex;flex-wrap:wrap;gap:4px;min-width:0}.wpd-cat__viz-host{display:flex;align-items:center;gap:4px;min-width:0;flex:1 1 auto;max-width:100%;min-height:28px;cursor:pointer;position:relative}.wpd-cat__viz-svg{display:block;width:100%;max-width:100%;min-width:0;overflow:visible;flex:1 1 auto}.wpd-cat__viz-svg .wpd-cat-edge{fill:none;stroke:var( --wpd-cat-edge-color,currentColor );stroke-width:1.25;stroke-linecap:round;opacity:0.55;transition:stroke-width 0.18s ease,opacity 0.18s ease}.wpd-cat__viz-svg .wpd-cat-edge[ data-active='true' ]{stroke-width:2;opacity:1}.wpd-cat__viz-svg .wpd-cat-node{cursor:pointer;transition:r 0.2s cubic-bezier( 0.34,1.56,0.64,1 ),fill 0.18s ease,stroke-width 0.18s ease,filter 0.18s ease}.wpd-cat__viz-svg .wpd-cat-node[ data-selected='true' ]{filter:drop-shadow( 0 0 6px var( --wpd-cat-node-glow,rgba( 0,0,0,0.18 ) ) )}.wpd-cat__viz-svg .wpd-cat-node:hover,.wpd-cat__viz-svg .wpd-cat-node:focus-visible{filter:drop-shadow( 0 0 8px var( --wpd-cat-node-glow,rgba( 0,0,0,0.3 ) ) );outline:none}.wpd-cat__viz-svg .wpd-cat-label{font-family:var( --wpd-font,system-ui,sans-serif );font-size:10.5px;fill:var( --wpd-cat-label-fg,#1d2327 );font-weight:500;pointer-events:none;user-select:none}.wpd-cat__viz-svg .wpd-cat-label[ data-selected='false' ]{fill:var( --wpd-cat-label-muted,#8c8f94 );font-weight:400;font-style:italic}.wpd-cat__trigger{appearance:none;display:inline-flex;align-items:center;gap:4px;padding:1px 8px;min-height:22px;font:inherit;font-size:11px;font-weight:500;line-height:1;color:var( --wpd-cat-trigger-fg,#50575e );background:transparent;border:1px dashed var( --wpd-cat-trigger-border,#c3c4c7 );border-radius:999px;cursor:pointer;transition:background-color 0.12s ease,color 0.12s ease,border-color 0.12s ease}.wpd-cat__trigger:hover:not(:disabled ){background:rgba( 0,0,0,0.04 );color:var( --wpd-cat-trigger-fg-hover,#1d2327 );border-color:var( --wpd-cat-trigger-border-hover,#8c8f94 )}.wpd-cat__trigger:focus-visible{outline:none;border-style:solid;box-shadow:0 0 0 2px var( --wp-admin-theme-color,#2271b1 )}.wpd-cat__trigger svg{display:block}.wpd-cat__uncategorized{display:inline-flex;align-items:center;gap:4px;padding:1px 10px;min-height:22px;font-size:11px;font-weight:500;line-height:1.6;color:var( --wpd-cat-uncat-fg,#8c8f94 );background:transparent;border:1px dashed var( --wpd-cat-uncat-border,#c3c4c7 );border-radius:999px;font-style:italic}.wpd-cat__popover{position:fixed;top:0;left:0;min-width:280px;max-width:360px;max-height:360px;display:flex;flex-direction:column;background:var( --wpd-cat-pop-bg,#fff );color:var( --wpd-cat-pop-fg,#1d2327 );border:1px solid var( --wpd-cat-pop-border,#c3c4c7 );border-radius:8px;box-shadow:0 6px 16px rgba( 0,0,0,0.08 ),0 1px 2px rgba( 0,0,0,0.06 );z-index:1000}.wpd-cat__editor{position:relative;display:inline-flex;align-items:center}.wpd-cat__search{appearance:none;font:inherit;font-size:13px;padding:8px 12px;border:0;border-bottom:1px solid var( --wpd-cat-pop-divider,#f0f0f1 );background:transparent;color:inherit;outline:none}.wpd-cat__search:focus{border-bottom-color:var( --wp-admin-theme-color,#2271b1 )}.wpd-cat__tree{flex:1 1 auto;min-height:0;overflow-y:auto;padding:4px 0}.wpd-cat__row-block{display:contents}.wpd-cat__row{display:flex;align-items:center;gap:6px;padding:4px 8px 4px var( --wpd-cat-row-indent,12px );cursor:pointer;user-select:none;font-size:13px;line-height:1.4;position:relative}.wpd-cat__row:hover,.wpd-cat__row[ data-focused='true' ]{background:rgba( 0,0,0,0.04 )}.wpd-cat__row[ data-selected='true' ]{color:var( --wp-admin-theme-color,#2271b1 );font-weight:600}.wpd-cat__row::before{content:'';position:absolute;left:0;top:0;bottom:0;width:var( --wpd-cat-guide-width,0px );border-left:1px dotted var( --wpd-cat-guide-color,rgba( 0,0,0,0.08 ) )}.wpd-cat__expander{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:0;background:transparent;color:inherit;cursor:pointer;flex-shrink:0;opacity:0.65}.wpd-cat__expander:hover{opacity:1}.wpd-cat__expander svg{display:block;transition:transform 0.12s ease}.wpd-cat__row[ data-expanded='true' ] .wpd-cat__expander svg{transform:rotate( 90deg )}.wpd-cat__expander--placeholder{visibility:hidden}.wpd-cat__create-row{display:flex;align-items:center;padding:2px 8px 2px var( --wpd-cat-row-indent,28px );position:relative}.wpd-cat__create-row::before{content:'';position:absolute;left:0;top:0;bottom:0;width:var( --wpd-cat-guide-width,0px );border-left:1px dotted var( --wpd-cat-guide-color,rgba( 0,0,0,0.08 ) )}.wpd-cat__create-wrap{display:inline-flex;align-items:center;flex:1 1 auto;min-width:0;gap:4px;padding:1px 1px 1px 0;border:1px solid transparent;border-radius:6px;background:transparent;transition:border-color 0.12s ease,background-color 0.12s ease,box-shadow 0.12s ease}.wpd-cat__create-wrap:hover{border-color:rgba( 0,0,0,0.12 );background:var( --wpd-cat-pop-bg,#fff )}.wpd-cat__create-wrap:focus-within{border-color:var( --wp-admin-theme-color,#2271b1 );background:var( --wpd-cat-pop-bg,#fff );box-shadow:0 0 0 2px color-mix( in srgb,var( --wp-admin-theme-color,#2271b1 ) 15%,transparent )}.wpd-cat__create-input{flex:1 1 auto;min-width:0;appearance:none;font:inherit;font-size:12px;padding:3px 6px;border:0;background:transparent;color:inherit;outline:none}.wpd-cat__create-input::placeholder{color:var( --wpd-cat-pop-muted,#8c8f94 );font-style:italic;opacity:1}.wpd-cat__create-submit{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;flex-shrink:0;padding:0;border:0;border-radius:4px;background:transparent;color:var( --wpd-cat-pop-muted,#8c8f94 );cursor:pointer;transition:background-color 0.12s ease,color 0.12s ease}.wpd-cat__create-wrap:focus-within .wpd-cat__create-submit:not( [ disabled ] ){background:var( --wp-admin-theme-color,#2271b1 );color:#fff}.wpd-cat__create-submit:hover:not( [ disabled ] ){filter:brightness( 1.05 )}.wpd-cat__create-submit[ disabled ]{cursor:default;opacity:0.5}.wpd-cat__create-submit svg{display:block;width:11px;height:11px}.wpd-cat__create-spinner{display:inline-block;width:12px;height:12px;margin:0 4px;border-radius:50%;border:2px solid var( --wp-admin-theme-color,#2271b1 );border-top-color:transparent;animation:wpd-cat-spin 0.8s linear infinite;flex-shrink:0}.wpd-cat__check{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;flex-shrink:0;border:1.5px solid var( --wpd-cat-check-border,#8c8f94 );border-radius:3px;color:transparent;transition:background-color 0.12s ease,border-color 0.12s ease,color 0.12s ease}.wpd-cat__row[ data-selected='true' ] .wpd-cat__check{background:var( --wp-admin-theme-color,#2271b1 );border-color:var( --wp-admin-theme-color,#2271b1 );color:#fff}.wpd-cat__check svg{display:block;width:10px;height:10px}.wpd-cat__label{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wpd-cat__delete{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:18px;height:18px;border:0;border-radius:50%;padding:0;background:transparent;color:var( --wpd-cat-delete-color,#d63638 );cursor:pointer;opacity:0;transition:opacity 0.12s ease,background-color 0.12s ease}.wpd-cat__row:hover .wpd-cat__delete,.wpd-cat__row[ data-focused='true' ] .wpd-cat__delete,.wpd-cat__delete:focus-visible{opacity:1}.wpd-cat__delete:hover,.wpd-cat__delete:focus-visible{background:rgba( 214,54,56,0.12 )}.wpd-cat__delete svg{display:block;width:10px;height:10px}.wpd-cat__match{background:rgba( 252,211,77,0.45 );border-radius:2px;padding:0 1px}.wpd-cat__empty,.wpd-cat__loading{padding:12px;font-size:12px;color:var( --wpd-cat-pop-muted,#646970 );text-align:center}.wpd-cat__loading-spinner{display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid currentColor;border-top-color:transparent;animation:wpd-cat-spin 0.8s linear infinite;margin-inline-end:8px;vertical-align:middle}@keyframes wpd-cat-spin{to{transform:rotate( 360deg )}}.wpd-cat__footer{padding:8px 12px;font-size:11px;color:var( --wpd-cat-pop-muted,#646970 );border-top:1px solid var( --wpd-cat-pop-divider,#f0f0f1 );border-radius:10px;background:var( --wpd-cat-pop-footer-bg,#fafafb );display:flex;align-items:center;gap:6px;line-height:1.4}.wpd-cat__footer .dashicons{font-size:14px;width:14px;height:14px;flex-shrink:0}:host( [ disabled ] ) .wpd-cat{opacity:0.6;pointer-events:none}`;
4218 const CHEVRON_W = "10px";
4219 const styles$4 = css`:host{display:inline-flex;max-width:100%;align-items:center;min-width:0;font-family:var( --wpd-font,system-ui,sans-serif );font-size:12px;line-height:1;font-weight:500}.wpd-crumb-chain{display:inline-flex;flex-wrap:nowrap;align-items:stretch;max-width:100%;min-width:0;min-height:22px;border-radius:999px;overflow:hidden;filter:drop-shadow( 0 1px 1px rgba( 0,0,0,0.06 ) )}.wpd-crumb{display:inline-flex;align-items:center;justify-content:center;gap:5px;min-height:22px;padding:2px 12px;background:var( --wpd-crumb-bg,#c3c4c7 );color:var( --wpd-crumb-fg,#1d2327 );text-align:center;min-width:0;max-width:100%;flex-shrink:1;font-size:12px;font-weight:500;letter-spacing:0.01em;white-space:nowrap;cursor:grab;transition:filter 0.15s ease,transform 0.15s ease,background-color 0.12s ease}.wpd-crumb:active{cursor:grabbing}.wpd-crumb:hover{filter:brightness( 1.06 )}.wpd-crumb__remove{cursor:pointer}.wpd-crumb--first{padding-inline-end:22px;clip-path:polygon( 0 0,calc( 100% - ${CHEVRON_W} ) 0,100% 50%,calc( 100% - ${CHEVRON_W} ) 100%,0 100% )}.wpd-crumb--middle{padding-inline:22px;margin-inline-start:calc( -1 * ${CHEVRON_W} );clip-path:polygon( ${CHEVRON_W} 0,calc( 100% - ${CHEVRON_W} ) 0,100% 50%,calc( 100% - ${CHEVRON_W} ) 100%,${CHEVRON_W} 100%,0 50% )}.wpd-crumb--last{padding-inline-start:22px;padding-inline-end:14px;margin-inline-start:calc( -1 * ${CHEVRON_W} );clip-path:polygon( ${CHEVRON_W} 0,100% 0,100% 100%,${CHEVRON_W} 100%,0 50% )}.wpd-crumb--solo{padding:2px 12px;border-radius:999px}.wpd-crumb__label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.4}.wpd-crumb__remove{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;flex-shrink:0;padding:0;border:0;border-radius:50%;background:transparent;color:inherit;cursor:pointer;opacity:0.65;transition:opacity 0.12s ease,background-color 0.12s ease,transform 0.12s ease}.wpd-crumb__remove:hover,.wpd-crumb__remove:focus-visible{opacity:1;background:rgba( 0,0,0,0.22 );outline:none;transform:scale( 1.1 )}.wpd-crumb__remove svg{display:block;width:8px;height:8px}.wpd-crumb-chain:hover{filter:drop-shadow( 0 2px 3px rgba( 0,0,0,0.12 ) )}:host( [ disabled ] ) .wpd-crumb-chain{opacity:0.55;pointer-events:none}`;
4220 var __freeze = Object.freeze;
4221 var __defProp = Object.defineProperty;
4222 var __template = (cooked, raw) => __freeze(__defProp(cooked, "raw", { value: __freeze(cooked.slice()) }));
4223 var _a;
4224 const _WpdCrumbChain = class _WpdCrumbChain extends Component {
4225 constructor() {
4226 super(...arguments);
4227 this._segments = [];
4228 }
4229 get segments() {
4230 return this._segments;
4231 }
4232 set segments(next) {
4233 this._segments = Array.isArray(next) ? next.slice() : [];
4234 this.requestUpdate();
4235 }
4236 render() {
4237 const removable = this.removable !== null;
4238 const segments = this._segments;
4239 if (segments.length === 0) {
4240 return html``;
4241 }
4242 return html`
4243 <div class="wpd-crumb-chain" role="group">
4244 ${segments.map((seg, idx) => {
4245 const variant = pickVariant(idx, segments.length);
4246 const bg = seg.color ?? "rgba( 0, 0, 0, 0.08 )";
4247 const fg = pickForegroundColor(bg);
4248 const styleStr = `--wpd-crumb-bg: ${bg}; --wpd-crumb-fg: ${fg};`;
4249 return html`
4250 <span
4251 class=${`wpd-crumb wpd-crumb--${variant}`}
4252 style=${styleStr}
4253 title=${seg.name}
4254 draggable="true"
4255 @click=${(e) => this._onSegmentClick(e, idx, seg)}
4256 @dragstart=${(e) => this._onSegmentDragStart(e, idx, seg)}
4257 >
4258 <span class="wpd-crumb__label">${seg.name}</span>
4259 ${removable ? html`
4260 <button
4261 type="button"
4262 class="wpd-crumb__remove"
4263 aria-label=${`Remove ${seg.name}`}
4264 draggable="false"
4265 @click=${(e) => this._onRemove(e, idx, seg)}
4266 >${_iconCross()}</button>
4267 ` : html``}
4268 </span>
4269 `;
4270 })}
4271 </div>
4272 `;
4273 }
4274 _onSegmentDragStart(e, index, segment) {
4275 const target = e.target;
4276 if (target?.closest(".wpd-crumb__remove")) {
4277 e.preventDefault();
4278 return;
4279 }
4280 const dragSegments = this._segments.slice(index);
4281 if (e.dataTransfer) {
4282 const ghost = buildDragGhost(dragSegments);
4283 document.body.appendChild(ghost);
4284 const rect = e.currentTarget?.getBoundingClientRect();
4285 const offsetX = rect ? Math.min(30, rect.width / 2) : 16;
4286 const offsetY = rect ? Math.min(16, rect.height / 2) : 12;
4287 e.dataTransfer.setDragImage(ghost, offsetX, offsetY);
4288 requestAnimationFrame(() => ghost.remove());
4289 }
4290 this.emit("wpd-chain-segment-dragstart", {
4291 index,
4292 id: segment.id,
4293 segment,
4294 segments: dragSegments,
4295 dragEvent: e
4296 });
4297 }
4298 _onSegmentClick(e, index, segment) {
4299 const target = e.target;
4300 if (target?.closest(".wpd-crumb__remove")) {
4301 return;
4302 }
4303 this.emit("wpd-chain-segment-click", {
4304 index,
4305 id: segment.id,
4306 segment
4307 });
4308 }
4309 _onRemove(e, index, segment) {
4310 e.stopPropagation();
4311 this.emit("wpd-chain-remove", { index, id: segment.id, segment });
4312 }
4313 };
4314 _WpdCrumbChain.props = ["removable", "disabled"];
4315 _WpdCrumbChain.styles = [styles$4];
4316 _WpdCrumbChain.help = {
4317 title: "Crumb chain",
4318 summary: "Chevron-interlocking breadcrumb. Segments slot together like puzzle pieces, with each segment in its own color so the eye reads root → leaf as a single merged path. Reusable for any parent → child → grandchild relationship.",
4319 status: "experimental",
4320 since: "0.8.0",
4321 props: [
4322 {
4323 name: "removable",
4324 type: "boolean attribute",
4325 description: "Show an × on every segment. Activating it emits `wpd-chain-remove` with the clicked segment + index — consumers cascade the removal down the chain (segment + descendants)."
4326 },
4327 {
4328 name: "disabled",
4329 type: "boolean attribute",
4330 description: "Visually mute the chain and ignore pointer + keyboard input."
4331 }
4332 ],
4333 events: [
4334 {
4335 name: "wpd-chain-remove",
4336 description: "Fires when × on ANY segment is activated. Detail carries the clicked segment + its index. Consumers typically delete the segment AND every descendant in the chain (mirrors the drag semantic, where the same gesture would carry the same set of ids).",
4337 detail: "{ index: number; id?: number | string; segment: WpdCrumbSegment }"
4338 },
4339 {
4340 name: "wpd-chain-segment-click",
4341 description: 'Fires when ANY segment is clicked. Useful for navigation drills (click "Tech" to filter to Tech).',
4342 detail: "{ index: number; id?: number | string; segment: WpdCrumbSegment }"
4343 },
4344 {
4345 name: "wpd-chain-segment-dragstart",
4346 description: 'Fires when a drag begins from any segment OTHER than the × remove button. Detail carries the segments from the drag-source to the leaf so consumers can ship ids for "this branch" — a drag from the middle segment moves the segment + every descendant in the chain.',
4347 detail: "{ index: number; id?: number | string; segment: WpdCrumbSegment; segments: WpdCrumbSegment[]; dragEvent: DragEvent }"
4348 }
4349 ],
4350 example: html(_a || (_a = __template([`
4351 <wpd-crumb-chain id="example-chain" removable></wpd-crumb-chain>
4352 <script>
4353 document.getElementById( 'example-chain' ).segments = [
4354 { id: 1, name: 'Tech', color: '#2271b1' },
4355 { id: 2, name: 'Web Dev', color: '#3a8ed4' },
4356 { id: 3, name: 'Frontend', color: '#5cb0ff' },
4357 ];
4358 <\/script>
4359 `])))
4360 };
4361 let WpdCrumbChain = _WpdCrumbChain;
4362 defineComponent("wpd-crumb-chain", WpdCrumbChain);
4363 const DRAG_GHOST_CHEVRON = 10;
4364 function buildDragGhost(segments) {
4365 const wrap = document.createElement("div");
4366 wrap.style.cssText = [
4367 "display: inline-flex",
4368 "align-items: stretch",
4369 "border-radius: 999px",
4370 "overflow: hidden",
4371 "filter: drop-shadow( 0 1px 2px rgba( 0, 0, 0, 0.18 ) )",
4372 'font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
4373 "font-size: 12px",
4374 "line-height: 1",
4375 "font-weight: 500",
4376 // Position offscreen but rendered — display:none / visibility:
4377 // hidden produce a blank drag-image snapshot.
4378 "position: fixed",
4379 "top: -10000px",
4380 "left: -10000px",
4381 "pointer-events: none",
4382 "z-index: 2147483647"
4383 ].join("; ");
4384 const total = segments.length;
4385 segments.forEach((seg, idx) => {
4386 const span = document.createElement("span");
4387 const bg = seg.color ?? "rgba( 0, 0, 0, 0.08 )";
4388 const fg = pickForegroundColor(bg);
4389 const variant = pickVariant(idx, total);
4390 const styleParts = [
4391 "display: inline-flex",
4392 "align-items: center",
4393 "justify-content: center",
4394 "min-height: 22px",
4395 `background: ${bg}`,
4396 `color: ${fg}`,
4397 "white-space: nowrap",
4398 "box-sizing: border-box",
4399 "letter-spacing: 0.01em"
4400 ];
4401 const c = DRAG_GHOST_CHEVRON;
4402 if (variant === "solo") {
4403 styleParts.push("padding: 2px 12px", "border-radius: 999px");
4404 } else if (variant === "first") {
4405 styleParts.push(
4406 "padding: 2px 22px 2px 12px",
4407 `clip-path: polygon( 0 0, calc( 100% - ${c}px ) 0, 100% 50%, calc( 100% - ${c}px ) 100%, 0 100% )`
4408 );
4409 } else if (variant === "middle") {
4410 styleParts.push(
4411 "padding: 2px 22px",
4412 `margin-inline-start: -${c}px`,
4413 `clip-path: polygon( ${c}px 0, calc( 100% - ${c}px ) 0, 100% 50%, calc( 100% - ${c}px ) 100%, ${c}px 100%, 0 50% )`
4414 );
4415 } else {
4416 styleParts.push(
4417 "padding: 2px 14px 2px 22px",
4418 `margin-inline-start: -${c}px`,
4419 `clip-path: polygon( ${c}px 0, 100% 0, 100% 100%, ${c}px 100%, 0 50% )`
4420 );
4421 }
4422 span.style.cssText = styleParts.join("; ");
4423 span.textContent = seg.name;
4424 wrap.appendChild(span);
4425 });
4426 return wrap;
4427 }
4428 function pickVariant(index, total) {
4429 if (total === 1) {
4430 return "solo";
4431 }
4432 if (index === 0) {
4433 return "first";
4434 }
4435 if (index === total - 1) {
4436 return "last";
4437 }
4438 return "middle";
4439 }
4440 let _readbackCanvas = null;
4441 function pickForegroundColor(bg) {
4442 if (!_readbackCanvas) {
4443 _readbackCanvas = document.createElement("canvas");
4444 _readbackCanvas.width = 1;
4445 _readbackCanvas.height = 1;
4446 }
4447 const ctx = _readbackCanvas.getContext("2d", { willReadFrequently: true });
4448 if (!ctx) {
4449 return "#1d2327";
4450 }
4451 try {
4452 ctx.clearRect(0, 0, 1, 1);
4453 ctx.fillStyle = bg;
4454 ctx.fillRect(0, 0, 1, 1);
4455 const data = ctx.getImageData(0, 0, 1, 1).data;
4456 const a = data[3] / 255;
4457 const r = data[0] * a + 255 * (1 - a);
4458 const g = data[1] * a + 255 * (1 - a);
4459 const b = data[2] * a + 255 * (1 - a);
4460 const lin = (c) => {
4461 const v = c / 255;
4462 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
4463 };
4464 const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
4465 return L > 0.55 ? "#1d2327" : "#fff";
4466 } catch {
4467 return "#1d2327";
4468 }
4469 }
4470 function _iconCross() {
4471 return html`
4472 <svg
4473 viewBox="0 0 12 12"
4474 aria-hidden="true"
4475 focusable="false"
4476 fill="none"
4477 stroke="currentColor"
4478 stroke-width="2"
4479 stroke-linecap="round"
4480 >
4481 <path d="M3 3 L9 9 M9 3 L3 9" />
4482 </svg>
4483 `;
4484 }
4485 const UNCATEGORIZED_SLUG = "uncategorized";
4486 const UNCATEGORIZED_DEFAULT_ID = 1;
4487 function _isUncategorized(item) {
4488 if (item.id === UNCATEGORIZED_DEFAULT_ID) {
4489 return true;
4490 }
4491 return (item.name || "").toLowerCase() === UNCATEGORIZED_SLUG;
4492 }
4493 const _WpdCategoryPicker = class _WpdCategoryPicker extends Component {
4494 constructor() {
4495 super(...arguments);
4496 this._items = [];
4497 this._value = [];
4498 this._query = "";
4499 this._collapsed = /* @__PURE__ */ new Set();
4500 this._focusedRow = -1;
4501 this._creatingValues = /* @__PURE__ */ new Map();
4502 this._creatingPending = /* @__PURE__ */ new Set();
4503 this._onCellClick = (e) => {
4504 const target = e.target;
4505 if (target?.closest(".wpd-cat-node")) {
4506 return;
4507 }
4508 if (this.isOpen) {
4509 return;
4510 }
4511 const disabled = this.disabled !== null;
4512 const readonly = this.readonly !== null;
4513 if (disabled || readonly) {
4514 return;
4515 }
4516 this.openPicker();
4517 };
4518 this._onDocPointerDown = (e) => {
4519 if (!this.isOpen) {
4520 return;
4521 }
4522 const path = e.composedPath();
4523 if (path.includes(this)) {
4524 return;
4525 }
4526 this.closePicker();
4527 };
4528 this._onLayoutChange = () => {
4529 if (!this.isOpen) {
4530 return;
4531 }
4532 this.closePicker();
4533 };
4534 this._onDocKeydown = (e) => {
4535 if (this.isOpen && e.key === "Escape") {
4536 e.preventDefault();
4537 this.closePicker();
4538 }
4539 };
4540 }
4541 get items() {
4542 return this._items;
4543 }
4544 set items(next) {
4545 this._items = Array.isArray(next) ? next.slice() : [];
4546 this.requestUpdate();
4547 }
4548 get value() {
4549 return this._value;
4550 }
4551 set value(next) {
4552 this._value = Array.isArray(next) ? next.slice() : [];
4553 this.requestUpdate();
4554 }
4555 get isOpen() {
4556 return this.open !== null;
4557 }
4558 openPicker() {
4559 if (this.isOpen) {
4560 return;
4561 }
4562 this.open = "";
4563 this._query = "";
4564 this._focusedRow = 0;
4565 this.emit("wpd-categories-open", {});
4566 queueMicrotask(() => {
4567 this._positionPopover();
4568 this._searchInput?.focus();
4569 });
4570 }
4571 closePicker() {
4572 if (!this.isOpen) {
4573 return;
4574 }
4575 this.open = null;
4576 this._query = "";
4577 this._focusedRow = -1;
4578 this.emit("wpd-categories-close", {});
4579 this.requestUpdate();
4580 }
4581 connectedCallback() {
4582 super.connectedCallback();
4583 document.addEventListener("pointerdown", this._onDocPointerDown, true);
4584 document.addEventListener("keydown", this._onDocKeydown, true);
4585 window.addEventListener("resize", this._onLayoutChange, { passive: true });
4586 window.addEventListener("scroll", this._onLayoutChange, {
4587 passive: true,
4588 capture: true
4589 });
4590 }
4591 disconnectedCallback() {
4592 document.removeEventListener("pointerdown", this._onDocPointerDown, true);
4593 document.removeEventListener("keydown", this._onDocKeydown, true);
4594 window.removeEventListener("resize", this._onLayoutChange);
4595 window.removeEventListener("scroll", this._onLayoutChange, { capture: true });
4596 }
4597 get _searchInput() {
4598 return this.shadowRoot?.querySelector(".wpd-cat__search") ?? null;
4599 }
4600 // --- Render -----------------------------------------------------------
4601 render() {
4602 const isOpen = this.isOpen;
4603 const disabled = this.disabled !== null;
4604 const readonly = this.readonly !== null;
4605 const loading = this.loading !== null;
4606 const addLabel = this["add-label"] || "Categorize";
4607 const placeholder = this.placeholder || "Search categories…";
4608 const maxVisible = Math.max(
4609 0,
4610 parseInt(
4611 this["max-visible"] || "2",
4612 10
4613 ) || 2
4614 );
4615 return html`
4616 <span class="wpd-cat" role="group">
4617 ${this._renderChipRow(maxVisible, readonly, disabled, addLabel)}
4618 ${isOpen ? this._renderPopover(placeholder, loading) : html``}
4619 </span>
4620 `;
4621 }
4622 _renderChipRow(_maxVisible, readonly, disabled, _addLabel) {
4623 const selectedItems = this._selectedItemsInOrder();
4624 if (selectedItems.length === 0) {
4625 return html`
4626 <span class="wpd-cat__chips" role="list">
4627 <span
4628 class="wpd-cat__uncategorized"
4629 title=${'Posts with no category appear as "Uncategorized" in WordPress.'}
4630 @click=${this._onCellClick}
4631 >${"Uncategorized"}</span>
4632 </span>
4633 `;
4634 }
4635 const chains = this._buildChains(selectedItems);
4636 return html`
4637 <div
4638 class="wpd-cat__chains"
4639 role="list"
4640 @click=${this._onCellClick}
4641 >
4642 ${chains.map(
4643 (chain) => this._renderChain(chain, readonly, disabled)
4644 )}
4645 </div>
4646 `;
4647 }
4648 /**
4649 * Build a `WpdCrumbSegment[]` per LEAF selection. A "leaf
4650 * selection" is a selected term that has no other selected
4651 * descendant. When the user has selected a parent AND its
4652 * children AND its grandchildren, only the deepest (leaf)
4653 * selection produces a chain — the chain itself walks
4654 * root → leaf and includes every path segment. Segments that
4655 * the user explicitly picked AND segments that just sit on the
4656 * path render the same way visually; the user's intent ("this
4657 * post is filed under Parent → Child → Grandchild") is what
4658 * gets shown, regardless of which subset of the path they
4659 * happened to tick.
4660 *
4661 * Two leaves under the same parent produce two chains; the
4662 * shared parent appears in both, which matches the user's
4663 * mental model ("filed under Tech/Web Dev/Frontend AND
4664 * Tech/Web Dev/Backend") without the ambiguity of merged-tree
4665 * visualizations.
4666 */
4667 _buildChains(selectedItems) {
4668 const byId = /* @__PURE__ */ new Map();
4669 for (const item of this._items) {
4670 byId.set(item.id, item);
4671 }
4672 const selectedIds = new Set(selectedItems.map((s) => s.id));
4673 const hasSelectedDescendant = (ancestorId) => {
4674 for (const otherId of selectedIds) {
4675 if (otherId === ancestorId) {
4676 continue;
4677 }
4678 let cursor = byId.get(otherId);
4679 let safety = 16;
4680 while (cursor && safety-- > 0) {
4681 if (cursor.parent === ancestorId) {
4682 return true;
4683 }
4684 if (!cursor.parent) {
4685 break;
4686 }
4687 cursor = byId.get(cursor.parent);
4688 }
4689 }
4690 return false;
4691 };
4692 const chainLeaves = selectedItems.filter(
4693 (item) => !hasSelectedDescendant(item.id)
4694 );
4695 const chains = [];
4696 for (const leaf of chainLeaves) {
4697 const path = [];
4698 let cursor = leaf;
4699 let safety = 16;
4700 while (cursor && safety-- > 0) {
4701 if (cursor === leaf || selectedIds.has(cursor.id)) {
4702 path.unshift(cursor);
4703 }
4704 if (!cursor.parent) {
4705 break;
4706 }
4707 cursor = byId.get(cursor.parent);
4708 }
4709 const segments = path.map((item) => ({
4710 id: item.id,
4711 name: item.name
4712 }));
4713 chains.push({ id: leaf.id, segments });
4714 }
4715 return chains;
4716 }
4717 _renderChain(chain, readonly, disabled) {
4718 const removable = !readonly && !disabled;
4719 const onRemove = (e) => {
4720 e.stopPropagation();
4721 const detail = e.detail;
4722 const startIdx = typeof detail?.index === "number" ? detail.index : chain.segments.length - 1;
4723 const idsToRemove = /* @__PURE__ */ new Set();
4724 for (const seg of chain.segments.slice(startIdx)) {
4725 if (typeof seg.id === "number") {
4726 idsToRemove.add(seg.id);
4727 }
4728 }
4729 const next = this._value.filter(
4730 (id) => !idsToRemove.has(id)
4731 );
4732 if (next.length === this._value.length) {
4733 return;
4734 }
4735 this.emit("wpd-categories-change", { value: next });
4736 };
4737 const el = document.createElement("wpd-crumb-chain");
4738 el.segments = chain.segments;
4739 if (removable) {
4740 el.setAttribute("removable", "");
4741 }
4742 el.addEventListener("wpd-chain-remove", onRemove);
4743 return html`<div role="listitem">${el}</div>`;
4744 }
4745 _renderPopover(placeholder, loading) {
4746 const tree = this._buildTree();
4747 const filtered = this._filterTree(tree, this._query);
4748 const flat = this._flattenForDisplay(filtered);
4749 if (this._focusedRow >= flat.length) {
4750 this._focusedRow = flat.length > 0 ? flat.length - 1 : -1;
4751 }
4752 return html`
4753 <div class="wpd-cat__popover" role="dialog" aria-label="Choose categories">
4754 <input
4755 class="wpd-cat__search"
4756 type="text"
4757 autocomplete="off"
4758 placeholder=${placeholder}
4759 .value=${this._query}
4760 @input=${(e) => this._onSearchInput(e)}
4761 @keydown=${(e) => this._onSearchKeydown(e, flat)}
4762 />
4763 <div class="wpd-cat__tree" role="listbox" aria-multiselectable="true">
4764 ${this._renderCreateRow(0, 12, 0, "")}
4765 ${this._renderTreeBody(loading, flat)}
4766 </div>
4767 <div class="wpd-cat__footer">
4768 <span class="dashicons dashicons-info-outline" aria-hidden="true"></span>
4769 <span>
4770 Posts with no category appear as
4771 <strong>Uncategorized</strong>.
4772 </span>
4773 </div>
4774 </div>
4775 `;
4776 }
4777 _renderTreeBody(loading, flat) {
4778 if (loading) {
4779 return html`
4780 <div class="wpd-cat__loading">
4781 <span class="wpd-cat__loading-spinner" aria-hidden="true"></span>
4782 ${"Loading categories…"}
4783 </div>
4784 `;
4785 }
4786 if (flat.length === 0) {
4787 return html`
4788 <div class="wpd-cat__empty">
4789 ${this._items.length === 0 ? "No categories yet — create one in WordPress to assign." : "No matches."}
4790 </div>
4791 `;
4792 }
4793 return flat.map((entry, idx) => this._renderRow(entry, idx, flat.length));
4794 }
4795 _renderRow(entry, idx, _total) {
4796 const { node, hasChildren } = entry;
4797 const isSelected = this._value.includes(node.item.id);
4798 const isExpanded = !this._collapsed.has(node.item.id);
4799 const indent = 12 + node.depth * 16;
4800 const guide = node.depth > 0 ? node.depth * 16 : 0;
4801 const isFocused = idx === this._focusedRow;
4802 return html`
4803 <div class="wpd-cat__row-block">
4804 <div
4805 class="wpd-cat__row"
4806 role="option"
4807 aria-selected=${isSelected ? "true" : "false"}
4808 data-selected=${isSelected ? "true" : "false"}
4809 data-expanded=${isExpanded ? "true" : "false"}
4810 data-focused=${isFocused ? "true" : "false"}
4811 data-row-id=${String(node.item.id)}
4812 style=${`--wpd-cat-row-indent: ${indent}px; --wpd-cat-guide-width: ${guide}px;`}
4813 @mouseenter=${() => {
4814 this._focusedRow = idx;
4815 this.requestUpdate();
4816 }}
4817 @click=${(e) => {
4818 e.preventDefault();
4819 this._toggleSelection(node.item.id);
4820 }}
4821 >
4822 ${hasChildren ? html`<button
4823 type="button"
4824 class="wpd-cat__expander"
4825 aria-label=${isExpanded ? "Collapse" : "Expand"}
4826 @click=${(e) => {
4827 e.stopPropagation();
4828 this._toggleExpand(node.item.id);
4829 }}
4830 >${_iconCaretRight()}</button>` : html`<span class="wpd-cat__expander wpd-cat__expander--placeholder" aria-hidden="true">${_iconCaretRight()}</span>`}
4831 <span class="wpd-cat__check" aria-hidden="true">${_iconCheck()}</span>
4832 <span class="wpd-cat__label">${this._highlight(node.item.name, this._query)}</span>
4833 ${_isUncategorized(node.item) ? html`` : html`<button
4834 type="button"
4835 class="wpd-cat__delete"
4836 aria-label=${`Delete ${node.item.name}`}
4837 title=${`Delete ${node.item.name}`}
4838 @click=${(e) => this._onDeleteClick(e, node.item)}
4839 >${_iconCrossSmall()}</button>`}
4840 </div>
4841 ${isExpanded && !_isUncategorized(node.item) ? this._renderCreateRow(
4842 node.item.id,
4843 12 + (node.depth + 1) * 16,
4844 (node.depth + 1) * 16,
4845 node.item.name
4846 ) : html``}
4847 </div>
4848 `;
4849 }
4850 /**
4851 * Render an always-visible inline create-input. One sits at the
4852 * top of the popover (parentId 0 = create a root category) and
4853 * one sits beneath every visible row (create a child of that
4854 * row). Indent + guide-line align the child input with where the
4855 * new term will appear in the tree, so the user reads "this
4856 * input creates a sibling of the children below".
4857 *
4858 * The "+" submit button lives inside the input chrome; pressing
4859 * it (or Enter) emits `wpd-categories-create`. Esc clears the
4860 * field. While the consumer is processing the create REST call,
4861 * the field disables and a spinner replaces the submit button.
4862 */
4863 _renderCreateRow(parentId, indent, guide, parentName) {
4864 const value = this._creatingValues.get(parentId) ?? "";
4865 const pending = this._creatingPending.has(parentId);
4866 const placeholder = parentId === 0 ? "Add new category…" : `Add child of "${parentName}"…`;
4867 return html`
4868 <div
4869 class="wpd-cat__create-row"
4870 style=${`--wpd-cat-row-indent: ${indent}px; --wpd-cat-guide-width: ${guide}px;`}
4871 @click=${(e) => e.stopPropagation()}
4872 >
4873 <div class="wpd-cat__create-wrap">
4874 <input
4875 class="wpd-cat__create-input"
4876 type="text"
4877 autocomplete="off"
4878 spellcheck="false"
4879 placeholder=${placeholder}
4880 aria-label=${placeholder}
4881 .value=${value}
4882 ?disabled=${pending}
4883 @input=${(e) => this._onCreateInput(e, parentId)}
4884 @keydown=${(e) => this._onCreateKeydown(e, parentId)}
4885 />
4886 ${pending ? html`<span class="wpd-cat__create-spinner" aria-hidden="true"></span>` : html`<button
4887 type="button"
4888 class="wpd-cat__create-submit"
4889 aria-label=${parentId === 0 ? "Create category" : `Create child of ${parentName}`}
4890 ?disabled=${value.trim().length === 0}
4891 @click=${(e) => {
4892 e.stopPropagation();
4893 this._submitCreate(parentId);
4894 }}
4895 >${_iconPlusSmall()}</button>`}
4896 </div>
4897 </div>
4898 `;
4899 }
4900 _onCreateInput(e, parentId) {
4901 const value = e.target.value;
4902 if (value === "") {
4903 this._creatingValues.delete(parentId);
4904 } else {
4905 this._creatingValues.set(parentId, value);
4906 }
4907 this.requestUpdate();
4908 }
4909 _onCreateKeydown(e, parentId) {
4910 if (e.key === "Escape") {
4911 e.preventDefault();
4912 this._creatingValues.delete(parentId);
4913 this.requestUpdate();
4914 return;
4915 }
4916 if (e.key === "Enter") {
4917 e.preventDefault();
4918 this._submitCreate(parentId);
4919 }
4920 }
4921 _submitCreate(parentId) {
4922 const name = (this._creatingValues.get(parentId) ?? "").trim();
4923 if (!name || this._creatingPending.has(parentId)) {
4924 return;
4925 }
4926 this._creatingPending.add(parentId);
4927 this.requestUpdate();
4928 this.emit("wpd-categories-create", { name, parent: parentId });
4929 }
4930 /**
4931 * Public API — call after a successful create-handler run to
4932 * clear the inline input for that parent. Consumers usually
4933 * mutate `items` + `value` first (so the new term appears + is
4934 * selected), then call `endCreating( parent )` to clear the
4935 * field.
4936 *
4937 * @param parent The parent id used in the create event detail
4938 * (`0` for a root-level create).
4939 *
4940 * @public
4941 */
4942 endCreating(parent = 0) {
4943 this._creatingPending.delete(parent);
4944 this._creatingValues.delete(parent);
4945 this.requestUpdate();
4946 }
4947 /**
4948 * Public API — call from a consumer's catch path when the
4949 * create REST request fails. Keeps the typed text intact so the
4950 * user can retry with the same name; only the pending flag
4951 * clears.
4952 *
4953 * @param parent The parent id used in the create event detail.
4954 * @param _error Reserved for future use (e.g. surfacing the
4955 * error in the input chrome).
4956 *
4957 * @public
4958 */
4959 failCreating(parent = 0, _error) {
4960 this._creatingPending.delete(parent);
4961 this.requestUpdate();
4962 }
4963 // --- Tree helpers ----------------------------------------------------
4964 _buildTree() {
4965 const byId = /* @__PURE__ */ new Map();
4966 for (const item of this._items) {
4967 byId.set(item.id, { item, children: [], depth: 0 });
4968 }
4969 const roots = [];
4970 for (const node of byId.values()) {
4971 const parentId = node.item.parent;
4972 if (parentId && byId.has(parentId)) {
4973 const parentNode = byId.get(parentId);
4974 parentNode.children.push(node);
4975 } else {
4976 roots.push(node);
4977 }
4978 }
4979 const setDepth = (node, depth) => {
4980 node.depth = depth;
4981 for (const child of node.children) {
4982 setDepth(child, depth + 1);
4983 }
4984 };
4985 for (const root of roots) {
4986 setDepth(root, 0);
4987 }
4988 const sortRecursive = (nodes) => {
4989 nodes.sort((a, b) => {
4990 const aUncat = _isUncategorized(a.item);
4991 const bUncat = _isUncategorized(b.item);
4992 if (aUncat !== bUncat) {
4993 return aUncat ? -1 : 1;
4994 }
4995 return a.item.name.localeCompare(b.item.name);
4996 });
4997 for (const n of nodes) {
4998 sortRecursive(n.children);
4999 }
5000 };
5001 sortRecursive(roots);
5002 return roots;
5003 }
5004 _filterTree(tree, query) {
5005 const trimmed = query.trim().toLowerCase();
5006 if (!trimmed) {
5007 return tree;
5008 }
5009 const matches = (node) => {
5010 const ownMatch = node.item.name.toLowerCase().includes(trimmed);
5011 if (ownMatch) {
5012 return {
5013 item: node.item,
5014 children: node.children.slice(),
5015 depth: node.depth
5016 };
5017 }
5018 const childrenMatched = node.children.map(matches).filter((n) => n !== null);
5019 if (childrenMatched.length > 0) {
5020 return {
5021 item: node.item,
5022 children: childrenMatched,
5023 depth: node.depth
5024 };
5025 }
5026 return null;
5027 };
5028 return tree.map(matches).filter((n) => n !== null);
5029 }
5030 _flattenForDisplay(tree) {
5031 const out = [];
5032 const isSearching = this._query.trim() !== "";
5033 const walk = (nodes) => {
5034 for (const node of nodes) {
5035 out.push({
5036 node,
5037 visible: true,
5038 hasChildren: node.children.length > 0
5039 });
5040 const collapsed = this._collapsed.has(node.item.id) && !isSearching;
5041 if (!collapsed && node.children.length > 0) {
5042 walk(node.children);
5043 }
5044 }
5045 };
5046 walk(tree);
5047 return out;
5048 }
5049 _selectedItemsInOrder() {
5050 const byId = /* @__PURE__ */ new Map();
5051 for (const item of this._items) {
5052 byId.set(item.id, item);
5053 }
5054 const real = [];
5055 const uncatItems = [];
5056 for (const id of this._value) {
5057 const item = byId.get(id);
5058 if (!item) {
5059 continue;
5060 }
5061 if (item.name.toLowerCase() === UNCATEGORIZED_SLUG || item.id === 1) {
5062 uncatItems.push(item);
5063 } else {
5064 real.push(item);
5065 }
5066 }
5067 if (real.length > 0) {
5068 return real;
5069 }
5070 return uncatItems.length > 0 ? [] : real;
5071 }
5072 _highlight(label, query) {
5073 const trimmed = query.trim();
5074 if (!trimmed) {
5075 return label;
5076 }
5077 const lower = label.toLowerCase();
5078 const needle = trimmed.toLowerCase();
5079 const idx = lower.indexOf(needle);
5080 if (idx === -1) {
5081 return label;
5082 }
5083 return html`${label.slice(0, idx)}<span class="wpd-cat__match"
5084 >${label.slice(idx, idx + trimmed.length)}</span
5085 >${label.slice(idx + trimmed.length)}`;
5086 }
5087 // --- Mutations -------------------------------------------------------
5088 _toggleSelection(id) {
5089 const next = this._value.includes(id) ? this._value.filter((v) => v !== id) : [...this._value, id];
5090 this.emit("wpd-categories-change", { value: next });
5091 }
5092 _onDeleteClick(e, item) {
5093 e.stopPropagation();
5094 e.preventDefault();
5095 this.emit("wpd-categories-delete", { id: item.id, name: item.name });
5096 }
5097 _toggleExpand(id) {
5098 if (this._collapsed.has(id)) {
5099 this._collapsed.delete(id);
5100 } else {
5101 this._collapsed.add(id);
5102 }
5103 this.requestUpdate();
5104 }
5105 _onSearchInput(e) {
5106 this._query = e.target.value;
5107 this._focusedRow = 0;
5108 this.requestUpdate();
5109 }
5110 _onSearchKeydown(e, flat) {
5111 switch (e.key) {
5112 case "ArrowDown": {
5113 if (flat.length === 0) {
5114 return;
5115 }
5116 e.preventDefault();
5117 this._focusedRow = this._focusedRow + 1 >= flat.length ? 0 : this._focusedRow + 1;
5118 this.requestUpdate();
5119 this._scrollFocusedIntoView();
5120 return;
5121 }
5122 case "ArrowUp": {
5123 if (flat.length === 0) {
5124 return;
5125 }
5126 e.preventDefault();
5127 this._focusedRow = this._focusedRow <= 0 ? flat.length - 1 : this._focusedRow - 1;
5128 this.requestUpdate();
5129 this._scrollFocusedIntoView();
5130 return;
5131 }
5132 case "ArrowRight": {
5133 if (this._focusedRow < 0 || this._focusedRow >= flat.length) {
5134 return;
5135 }
5136 const entry = flat[this._focusedRow];
5137 if (entry.hasChildren && this._collapsed.has(entry.node.item.id)) {
5138 e.preventDefault();
5139 this._toggleExpand(entry.node.item.id);
5140 }
5141 return;
5142 }
5143 case "ArrowLeft": {
5144 if (this._focusedRow < 0 || this._focusedRow >= flat.length) {
5145 return;
5146 }
5147 const entry = flat[this._focusedRow];
5148 if (entry.hasChildren && !this._collapsed.has(entry.node.item.id)) {
5149 e.preventDefault();
5150 this._toggleExpand(entry.node.item.id);
5151 }
5152 return;
5153 }
5154 case "Enter":
5155 case " ": {
5156 if (this._focusedRow < 0 || this._focusedRow >= flat.length) {
5157 return;
5158 }
5159 e.preventDefault();
5160 const entry = flat[this._focusedRow];
5161 this._toggleSelection(entry.node.item.id);
5162 return;
5163 }
5164 case "Escape": {
5165 e.preventDefault();
5166 this.closePicker();
5167 }
5168 }
5169 }
5170 _scrollFocusedIntoView() {
5171 queueMicrotask(() => {
5172 const tree = this.shadowRoot?.querySelector(".wpd-cat__tree");
5173 if (!tree) {
5174 return;
5175 }
5176 const row = tree.querySelector(
5177 `.wpd-cat__row[data-focused="true"]`
5178 );
5179 if (!row) {
5180 return;
5181 }
5182 const rRect = row.getBoundingClientRect();
5183 const tRect = tree.getBoundingClientRect();
5184 if (rRect.top < tRect.top) {
5185 row.scrollIntoView({ block: "nearest" });
5186 } else if (rRect.bottom > tRect.bottom) {
5187 row.scrollIntoView({ block: "nearest" });
5188 }
5189 });
5190 }
5191 /**
5192 * Anchor the `position: fixed` popover to the trigger button.
5193 * Flips up when the popover would overflow the viewport bottom,
5194 * right-aligns when it would overflow the right edge. Runs on
5195 * every open after the popover has rendered (so we can read its
5196 * actual measured size, not a guess).
5197 *
5198 * Why fixed-positioning: the table cell scrolls inside
5199 * `<wpd-table>`'s shadow DOM, which has its own
5200 * `overflow: auto`. An `absolute` popover anchored to the cell
5201 * would be clipped by both the cell scroll AND the table
5202 * scroll. Fixed positioning escapes every ancestor's overflow
5203 * and lands the popover wherever we tell it relative to the
5204 * viewport.
5205 */
5206 _positionPopover() {
5207 const popover = this.shadowRoot?.querySelector(
5208 ".wpd-cat__popover"
5209 );
5210 if (!popover) {
5211 return;
5212 }
5213 const anchorRect = this.getBoundingClientRect();
5214 const popRect = popover.getBoundingClientRect();
5215 const viewportW = window.innerWidth;
5216 const viewportH = window.innerHeight;
5217 const margin = 8;
5218 let top = anchorRect.bottom + 4;
5219 const overflowBottom = top + popRect.height + margin > viewportH;
5220 const fitsAbove = anchorRect.top - 4 - popRect.height >= margin;
5221 if (overflowBottom && fitsAbove) {
5222 top = anchorRect.top - 4 - popRect.height;
5223 } else if (overflowBottom) {
5224 top = Math.max(margin, viewportH - popRect.height - margin);
5225 }
5226 let left = anchorRect.left;
5227 if (left + popRect.width + margin > viewportW) {
5228 left = anchorRect.right - popRect.width;
5229 }
5230 left = Math.max(
5231 margin,
5232 Math.min(left, viewportW - popRect.width - margin)
5233 );
5234 popover.style.top = `${top}px`;
5235 popover.style.left = `${left}px`;
5236 }
5237 };
5238 _WpdCategoryPicker.props = [
5239 "placeholder",
5240 "add-label",
5241 "disabled",
5242 "readonly",
5243 "open",
5244 "loading",
5245 "max-visible"
5246 ];
5247 _WpdCategoryPicker.styles = [styles$5];
5248 _WpdCategoryPicker.help = {
5249 title: "Category picker",
5250 summary: 'Hierarchical multi-select for taxonomy terms. Compact chip row + tree popover with search, collapsible branches, indent guides, keyboard navigation. Aligns with WordPress core: any subset selectable, "Uncategorized" rendered as a muted dashed sentinel when the value is empty.',
5251 status: "experimental",
5252 since: "0.8.0",
5253 props: [
5254 {
5255 name: "placeholder",
5256 type: "string",
5257 default: "Search categories…",
5258 description: "Native placeholder for the picker search input."
5259 },
5260 {
5261 name: "add-label",
5262 type: "string",
5263 default: "Categorize",
5264 description: "Currently inert — labeled the dedicated trigger button, which was replaced by the click-to-open cell. Parsed but unused."
5265 },
5266 {
5267 name: "disabled",
5268 type: "boolean attribute",
5269 description: "Disables every interactive surface."
5270 },
5271 {
5272 name: "readonly",
5273 type: "boolean attribute",
5274 description: "Prevents opening the picker and hides the per-segment remove buttons on the crumb chains."
5275 },
5276 {
5277 name: "open",
5278 type: "boolean attribute",
5279 description: "Two-way reflected: present while the picker popover is open. Setting it externally opens / closes the popover."
5280 },
5281 {
5282 name: "loading",
5283 type: "boolean attribute",
5284 description: 'Show a "Loading categories…" spinner inside the popover. Use while the consumer is fetching the term list.'
5285 },
5286 {
5287 name: "max-visible",
5288 type: "integer (string)",
5289 default: "2",
5290 description: 'Currently inert — configured the "+N" overflow chip, which was replaced by the crumb-chain rendering. Parsed but unused.'
5291 }
5292 ],
5293 events: [
5294 {
5295 name: "wpd-categories-change",
5296 description: "Fires when the user toggles a row in the picker. Detail carries the new full id list — consumer mutates `value` (optimistically) and runs REST.",
5297 detail: "{ value: number[] }"
5298 },
5299 {
5300 name: "wpd-categories-open",
5301 description: "Fires when the popover opens.",
5302 detail: "{}"
5303 },
5304 {
5305 name: "wpd-categories-close",
5306 description: "Fires when the popover closes.",
5307 detail: "{}"
5308 },
5309 {
5310 name: "wpd-categories-create",
5311 description: "Fires when the user submits the inline create-child input. Consumer is expected to POST to the taxonomy REST endpoint, append the new term to `items`, and (optionally) auto-select it by adding the new id to `value`. Picker shows a per-row spinner while the create is in flight; the consumer clears it by calling `picker.endCreating( parent )` on success or `picker.failCreating( parent )` on error.",
5312 detail: "{ name: string; parent: number }"
5313 },
5314 {
5315 name: "wpd-categories-delete",
5316 description: "Fires when the per-row × button is activated. The button only renders on hover/keyboard-focus and is suppressed for the WP Uncategorized fallback. Consumer is responsible for confirmation + REST + invalidating any cached tree (typically broadcasts `desktop-mode.term.changed`).",
5317 detail: "{ id: number; name: string }"
5318 }
5319 ],
5320 example: html`
5321 <wpd-category-picker placeholder="Search categories…"></wpd-category-picker>
5322 `
5323 };
5324 let WpdCategoryPicker = _WpdCategoryPicker;
5325 defineComponent("wpd-category-picker", WpdCategoryPicker);
5326 function _iconCaretRight() {
5327 return html`
5328 <svg
5329 viewBox="0 0 12 12"
5330 width="8"
5331 height="8"
5332 aria-hidden="true"
5333 focusable="false"
5334 fill="none"
5335 stroke="currentColor"
5336 stroke-width="2"
5337 stroke-linecap="round"
5338 stroke-linejoin="round"
5339 >
5340 <path d="M5 3 L8 6 L5 9" />
5341 </svg>
5342 `;
5343 }
5344 function _iconPlusSmall() {
5345 return html`
5346 <svg
5347 viewBox="0 0 12 12"
5348 width="11"
5349 height="11"
5350 aria-hidden="true"
5351 focusable="false"
5352 fill="none"
5353 stroke="currentColor"
5354 stroke-width="2"
5355 stroke-linecap="round"
5356 >
5357 <path d="M6 3 L6 9 M3 6 L9 6" />
5358 </svg>
5359 `;
5360 }
5361 function _iconCheck() {
5362 return html`
5363 <svg
5364 viewBox="0 0 12 12"
5365 aria-hidden="true"
5366 focusable="false"
5367 fill="none"
5368 stroke="currentColor"
5369 stroke-width="2"
5370 stroke-linecap="round"
5371 stroke-linejoin="round"
5372 >
5373 <path d="M2.5 6 L5 8.5 L9.5 4" />
5374 </svg>
5375 `;
5376 }
5377 function _iconCrossSmall() {
5378 return html`
5379 <svg
5380 viewBox="0 0 12 12"
5381 aria-hidden="true"
5382 focusable="false"
5383 fill="none"
5384 stroke="currentColor"
5385 stroke-width="2"
5386 stroke-linecap="round"
5387 >
5388 <path d="M3 3 L9 9 M9 3 L3 9" />
5389 </svg>
5390 `;
5391 }
5392 function hashTitleToHue(input) {
5393 if (!input) {
5394 return 214;
5395 }
5396 let hash = 5381;
5397 for (let i = 0; i < input.length; i++) {
5398 hash = Math.imul(hash, 33) + input.charCodeAt(i);
5399 }
5400 return (hash % 360 + 360) % 360;
5401 }
5402 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}}`;
5403 const SIZE_MAP = {
5404 xs: 20,
5405 sm: 24,
5406 md: 40,
5407 lg: 64,
5408 xl: 96
5409 };
5410 const VALID_PRESENCE = /* @__PURE__ */ new Set(["online", "inactive", "offline"]);
5411 const _WpdAvatar = class _WpdAvatar extends Component {
5412 constructor() {
5413 super(...arguments);
5414 this._presenceHandler = null;
5415 this._imgFailed = false;
5416 this._onPointerMove = null;
5417 this._onPointerEnter = null;
5418 this._onPointerLeave = null;
5419 this._tiltRaf = 0;
5420 this._pendingTiltX = "0deg";
5421 this._pendingTiltY = "0deg";
5422 this._pendingGlareX = "50%";
5423 this._pendingGlareY = "50%";
5424 }
5425 connectedCallback() {
5426 super.connectedCallback();
5427 this._maybeAttachPresenceListener();
5428 this._attachHoverEffect();
5429 }
5430 disconnectedCallback() {
5431 if (this._presenceHandler) {
5432 document.removeEventListener(
5433 "desktop-mode-presence-changed",
5434 this._presenceHandler
5435 );
5436 this._presenceHandler = null;
5437 }
5438 this._detachHoverEffect();
5439 }
5440 attributeChangedCallback(name, oldValue, newValue) {
5441 super.attributeChangedCallback(name, oldValue, newValue);
5442 if (name === "src") {
5443 this._imgFailed = false;
5444 }
5445 if (name === "user-id" || name === "presence") {
5446 this._maybeAttachPresenceListener();
5447 }
5448 }
5449 render() {
5450 const src = this._attr("src");
5451 const name = this._attr("name") || "";
5452 const altRaw = this._attr("alt");
5453 const alt = altRaw !== null ? altRaw : name;
5454 const sizeRaw = this._attr("size");
5455 const size = this._resolveSize(sizeRaw);
5456 const presence = this._presenceForRender();
5457 const clickable = this._attr("clickable") !== null;
5458 this.style.setProperty("--wpd-avatar-size", `${size}px`);
5459 const initialsBg = src && !this._imgFailed ? "" : this._initialsBg(name);
5460 const inner = src && !this._imgFailed ? html`<img
5461 src=${src}
5462 alt=${alt}
5463 @error=${() => this._onImgError()}
5464 loading="lazy"
5465 />` : this._initials(name);
5466 const dot = presence ? html`<span
5467 class=${`wpd-avatar__dot wpd-avatar__dot--${presence}`}
5468 aria-label=${this._presenceLabel(presence)}
5469 ></span>` : html``;
5470 if (clickable) {
5471 return html`
5472 <button
5473 type="button"
5474 class="wpd-avatar__tile"
5475 aria-label=${alt || "User"}
5476 style=${initialsBg ? `background:${initialsBg};` : ""}
5477 @click=${(e) => this._onClick(e)}
5478 >${inner}</button>
5479 ${dot}
5480 `;
5481 }
5482 return html`
5483 <div
5484 class="wpd-avatar__tile"
5485 role="img"
5486 aria-label=${alt || "User"}
5487 style=${initialsBg ? `background:${initialsBg};` : ""}
5488 >${inner}</div>
5489 ${dot}
5490 `;
5491 }
5492 _attr(name) {
5493 return this.getAttribute(name);
5494 }
5495 _resolveSize(raw) {
5496 if (!raw) {
5497 return 32;
5498 }
5499 if (raw in SIZE_MAP) {
5500 return SIZE_MAP[raw];
5501 }
5502 const n = Number(raw);
5503 return Number.isFinite(n) && n > 0 ? n : 32;
5504 }
5505 _initials(name) {
5506 const trimmed = name.trim();
5507 if (!trimmed) {
5508 return "?";
5509 }
5510 return Array.from(trimmed)[0]?.toUpperCase() ?? "?";
5511 }
5512 _initialsBg(name) {
5513 const hue = hashTitleToHue(name);
5514 return `linear-gradient(135deg, hsl(${hue} 62% 55%), hsl(${(hue + 24) % 360} 58% 42%))`;
5515 }
5516 _presenceForRender() {
5517 const raw = this._attr("presence");
5518 if (raw && VALID_PRESENCE.has(raw)) {
5519 return raw;
5520 }
5521 return null;
5522 }
5523 _presenceLabel(p) {
5524 switch (p) {
5525 case "online":
5526 return "Online";
5527 case "inactive":
5528 return "Inactive";
5529 case "offline":
5530 return "Offline";
5531 }
5532 }
5533 _onImgError() {
5534 this._imgFailed = true;
5535 this.requestUpdate();
5536 }
5537 _onClick(e) {
5538 const userId = this._attr("user-id");
5539 const detail = {
5540 userId: userId !== null ? Number(userId) || null : null,
5541 originalEvent: e
5542 };
5543 this.emit("wpd-avatar-click", detail);
5544 }
5545 /**
5546 * Wire up the pointer-driven tilt + glare. Listens on the host so
5547 * one set of bindings covers both the clickable `<button>` and
5548 * the decorative `<div>` rendering branches. The actual math
5549 * runs in `_handlePointerMove`; this method just owns the
5550 * bind/unbind plumbing.
5551 *
5552 * Bails entirely when `prefers-reduced-motion: reduce` is set —
5553 * the CSS has its own `@media` guard for the visual layer, but
5554 * skipping the JS too saves the per-event work for users who
5555 * won't benefit from it.
5556 */
5557 _attachHoverEffect() {
5558 const reduceMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
5559 if (reduceMotion) {
5560 return;
5561 }
5562 this._onPointerEnter = () => {
5563 this.style.setProperty("--wpd-avatar-hover", "1");
5564 };
5565 this._onPointerLeave = () => {
5566 this.style.setProperty("--wpd-avatar-hover", "0");
5567 this._pendingTiltX = "0deg";
5568 this._pendingTiltY = "0deg";
5569 this._pendingGlareX = "50%";
5570 this._pendingGlareY = "50%";
5571 this._flushTilt();
5572 };
5573 this._onPointerMove = (e) => {
5574 const rect = this.getBoundingClientRect();
5575 if (rect.width === 0 || rect.height === 0) {
5576 return;
5577 }
5578 const nx = (e.clientX - rect.left) / rect.width - 0.5;
5579 const ny = (e.clientY - rect.top) / rect.height - 0.5;
5580 const MAX = 14;
5581 this._pendingTiltY = `${(nx * MAX).toFixed(2)}deg`;
5582 this._pendingTiltX = `${(-ny * MAX).toFixed(2)}deg`;
5583 const gx = Math.max(0, Math.min(100, (nx + 0.5) * 100));
5584 const gy = Math.max(0, Math.min(100, (ny + 0.5) * 100));
5585 this._pendingGlareX = `${gx.toFixed(1)}%`;
5586 this._pendingGlareY = `${gy.toFixed(1)}%`;
5587 if (!this._tiltRaf) {
5588 this._tiltRaf = requestAnimationFrame(() => this._flushTilt());
5589 }
5590 };
5591 this.addEventListener("pointerenter", this._onPointerEnter);
5592 this.addEventListener("pointerleave", this._onPointerLeave);
5593 this.addEventListener("pointermove", this._onPointerMove);
5594 }
5595 _flushTilt() {
5596 this._tiltRaf = 0;
5597 this.style.setProperty("--wpd-avatar-tilt-x", this._pendingTiltX);
5598 this.style.setProperty("--wpd-avatar-tilt-y", this._pendingTiltY);
5599 this.style.setProperty("--wpd-avatar-glare-x", this._pendingGlareX);
5600 this.style.setProperty("--wpd-avatar-glare-y", this._pendingGlareY);
5601 }
5602 _detachHoverEffect() {
5603 if (this._onPointerMove) {
5604 this.removeEventListener("pointermove", this._onPointerMove);
5605 this._onPointerMove = null;
5606 }
5607 if (this._onPointerEnter) {
5608 this.removeEventListener("pointerenter", this._onPointerEnter);
5609 this._onPointerEnter = null;
5610 }
5611 if (this._onPointerLeave) {
5612 this.removeEventListener("pointerleave", this._onPointerLeave);
5613 this._onPointerLeave = null;
5614 }
5615 if (this._tiltRaf) {
5616 cancelAnimationFrame(this._tiltRaf);
5617 this._tiltRaf = 0;
5618 }
5619 }
5620 _maybeAttachPresenceListener() {
5621 const userId = this._attr("user-id");
5622 const explicit = this._attr("presence");
5623 const wantsListener = !!userId && !explicit;
5624 if (wantsListener && !this._presenceHandler) {
5625 this._presenceHandler = (e) => {
5626 const detail = e.detail;
5627 if (!detail) {
5628 return;
5629 }
5630 if (String(detail.userId) !== String(userId)) {
5631 return;
5632 }
5633 if (detail.newStatus && VALID_PRESENCE.has(detail.newStatus)) {
5634 this.setAttribute("presence", detail.newStatus);
5635 }
5636 };
5637 document.addEventListener(
5638 "desktop-mode-presence-changed",
5639 this._presenceHandler
5640 );
5641 } else if (!wantsListener && this._presenceHandler) {
5642 document.removeEventListener(
5643 "desktop-mode-presence-changed",
5644 this._presenceHandler
5645 );
5646 this._presenceHandler = null;
5647 }
5648 }
5649 };
5650 _WpdAvatar.props = ["src", "alt", "name", "size", "presence", "userId", "clickable"];
5651 _WpdAvatar.styles = [avatarStyles];
5652 _WpdAvatar.help = {
5653 title: "Avatar",
5654 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.",
5655 status: "stable",
5656 since: "0.6.0",
5657 props: [
5658 { name: "src", type: "string", description: "Image URL. Falls back to initials when empty or load fails." },
5659 { name: "alt", type: "string", description: "Alt text for the image. Defaults to `name` when omitted." },
5660 { name: "name", type: "string", description: "Used for initials + hue fallback when no src." },
5661 {
5662 name: "size",
5663 type: 'number | "xs" | "sm" | "md" | "lg" | "xl"',
5664 description: "Pixel size or named preset. Default 32 (sm-ish). Sets --wpd-avatar-size."
5665 },
5666 {
5667 name: "presence",
5668 type: '"online" | "inactive" | "offline"',
5669 description: "Presence dot color. Omit for no dot."
5670 },
5671 {
5672 name: "user-id",
5673 type: "number",
5674 description: "When set AND presence is unset, auto-subscribes to desktop-mode-presence-changed and updates the dot."
5675 },
5676 {
5677 name: "clickable",
5678 type: "boolean attribute",
5679 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."
5680 }
5681 ],
5682 events: [
5683 {
5684 name: "wpd-avatar-click",
5685 description: "Fires on click when the `clickable` attribute is set. Detail carries userId when set.",
5686 detail: "{ userId: number | null }"
5687 }
5688 ],
5689 cssProps: [
5690 { name: "--wpd-avatar-size", description: "Tile size in any CSS length. Set automatically by the size attribute." },
5691 { name: "--wpd-avatar-dot-ring", description: "Background color used as the dot ring (matches surrounding panel by default)." }
5692 ],
5693 example: html`
5694 <wpd-avatar name="Daniel" size="40" presence="online"></wpd-avatar>
5695 `
5696 };
5697 let WpdAvatar = _WpdAvatar;
5698 defineComponent("wpd-avatar", WpdAvatar);
5699 const selectStyles = css`:host{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var( --desktop-mode-text,#1d2327 );min-width:0}:host( [ hidden ] ){display:none}.wpd-select__label{font-size:12px;color:var( --desktop-mode-muted,#646970 )}.wpd-select__wrap{position:relative;display:flex;align-items:center;width:100%}select{appearance:none;-webkit-appearance:none;display:block;width:100%;min-width:0;padding:7px 28px 7px 12px;background:rgba( 0,0,0,0.05 );border:1px solid transparent;border-radius:7px;font:inherit;font-size:13px;color:var( --desktop-mode-text,#1d2327 );cursor:pointer;transition:background-color 0.12s ease,border-color 0.12s ease,box-shadow 0.12s ease}select:hover{background:rgba( 0,0,0,0.08 )}select:focus-visible{outline:none;border-color:var( --wp-admin-theme-color,#2271b1 );box-shadow:0 0 0 1px var( --wp-admin-theme-color,#2271b1 )}select:disabled{opacity:0.5;cursor:not-allowed}.wpd-select__chevron{position:absolute;inset-inline-end:10px;top:50%;transform:translateY( -50% );pointer-events:none;color:var( --desktop-mode-muted,#646970 );display:inline-block}select:hover ~ .wpd-select__chevron,select:focus-visible ~ .wpd-select__chevron{color:var( --desktop-mode-text,#1d2327 )}`;
5700 const optionStyles = css`:host{display:none}`;
5701 const _WpdOption = class _WpdOption extends Component {
5702 render() {
5703 return html``;
5704 }
5705 };
5706 _WpdOption.props = ["value", "disabled"];
5707 _WpdOption.styles = [optionStyles];
5708 _WpdOption.help = {
5709 title: "Option",
5710 summary: "Opaque data carrier for <wpd-select>. Carries its identifier in `value` and its visible label in textContent. Not rendered directly — the parent reads these and builds a native <select>.",
5711 status: "stable",
5712 since: "0.5.0",
5713 props: [
5714 {
5715 name: "value",
5716 type: "string",
5717 description: "Option identifier read by the parent <wpd-select>."
5718 },
5719 {
5720 name: "disabled",
5721 type: "boolean attribute",
5722 description: "Renders the option disabled in the parent <select>."
5723 }
5724 ],
5725 slots: [
5726 { name: "(default)", description: "Label text read from textContent." }
5727 ]
5728 };
5729 let WpdOption = _WpdOption;
5730 defineComponent("wpd-option", WpdOption);
5731 const _WpdSelect = class _WpdSelect extends Component {
5732 constructor() {
5733 super(...arguments);
5734 this._optionObserver = null;
5735 }
5736 /**
5737 * Declarative item-list setter. Replaces the existing
5738 * `<wpd-option>` children with a fresh set; preserves `value`
5739 * when it still matches, otherwise clears to the placeholder.
5740 *
5741 * Same shape as the setter on `<wpd-segmented>` so callers can
5742 * swap tag names (segmented ↔ select) without touching the
5743 * populate code when an option list outgrows the pill bar.
5744 *
5745 * ```js
5746 * select.items = [
5747 * { value: 'eur', label: 'Euro' },
5748 * { value: 'usd', label: 'US Dollar' },
5749 * ];
5750 * ```
5751 *
5752 * @since 0.5.0
5753 */
5754 set items(list) {
5755 const existing = this.querySelectorAll(":scope > wpd-option");
5756 for (const el of Array.from(existing)) {
5757 el.remove();
5758 }
5759 for (const item of list) {
5760 const opt = document.createElement("wpd-option");
5761 opt.setAttribute("value", item.value);
5762 opt.textContent = item.label;
5763 this.appendChild(opt);
5764 }
5765 const current = this.value;
5766 const stillValid = current !== null && list.some((i) => i.value === current);
5767 if (!stillValid && list.length > 0) {
5768 this.value = list[0].value;
5769 }
5770 this.requestUpdate();
5771 }
5772 connectedCallback() {
5773 super.connectedCallback();
5774 ensureAutoId(this);
5775 this._optionObserver = new MutationObserver(() => this.requestUpdate());
5776 this._optionObserver.observe(this, {
5777 childList: true,
5778 subtree: true,
5779 attributes: true,
5780 attributeFilter: ["value", "disabled"],
5781 characterData: true
5782 });
5783 }
5784 disconnectedCallback() {
5785 this._optionObserver?.disconnect();
5786 this._optionObserver = null;
5787 }
5788 render() {
5789 const label = this.label || "";
5790 const current = this.value;
5791 const placeholder = this.placeholder || "";
5792 const disabled = this.disabled !== null;
5793 const name = this.name || "";
5794 if (label) {
5795 this.setAttribute("aria-label", label);
5796 } else {
5797 this.removeAttribute("aria-label");
5798 }
5799 const selectAriaLabel = label || placeholder;
5800 const options = this._readOptions();
5801 const hostId = this.id || "wpd-unnamed";
5802 const selectId = `${hostId}__input`;
5803 return html`
5804 ${label ? html`<label
5805 class="wpd-select__label"
5806 for=${selectId}
5807 >${label}</label>` : html``}
5808 <span class="wpd-select__wrap">
5809 <select
5810 id=${selectId}
5811 ?disabled=${disabled}
5812 aria-label=${selectAriaLabel}
5813 name=${name}
5814 @change=${(e) => this._onChange(e)}
5815 >
5816 ${placeholder && !current ? html`<option value="" disabled selected>
5817 ${placeholder}
5818 </option>` : html``}
5819 ${options.map(
5820 (o) => html`
5821 <option
5822 value=${o.value}
5823 ?disabled=${o.disabled}
5824 ?selected=${o.value === current}
5825 >
5826 ${o.label}
5827 </option>
5828 `
5829 )}
5830 </select>
5831 <!--
5832 Inline SVG — the previous dashicons-classed span
5833 never painted because the global Dashicons font
5834 stylesheet cannot cross the shadow-root boundary.
5835 An inline SVG lives inside the shadow tree, inherits
5836 currentColor via the stroke attribute, and needs
5837 no external CSS.
5838 -->
5839 <svg
5840 class="wpd-select__chevron"
5841 viewBox="0 0 12 12"
5842 width="12"
5843 height="12"
5844 aria-hidden="true"
5845 focusable="false"
5846 >
5847 <path
5848 d="M3 5l3 3 3-3"
5849 stroke="currentColor"
5850 stroke-width="1.4"
5851 stroke-linecap="round"
5852 stroke-linejoin="round"
5853 fill="none"
5854 ></path>
5855 </svg>
5856 </span>
5857 `;
5858 }
5859 _readOptions() {
5860 const out = [];
5861 const children = this.querySelectorAll(":scope > wpd-option");
5862 for (const child of Array.from(children)) {
5863 const value = child.getAttribute("value");
5864 if (value === null) {
5865 continue;
5866 }
5867 out.push({
5868 value,
5869 label: (child.textContent || value).trim(),
5870 disabled: child.hasAttribute("disabled")
5871 });
5872 }
5873 return out;
5874 }
5875 _onChange(e) {
5876 const sel = e.target;
5877 const next = sel.value;
5878 this.value = next;
5879 this.emit("wpd-pick", { value: next });
5880 }
5881 };
5882 _WpdSelect.props = [
5883 "value",
5884 "label",
5885 "placeholder",
5886 "disabled",
5887 "name"
5888 ];
5889 _WpdSelect.styles = [selectStyles];
5890 _WpdSelect.help = {
5891 title: "Select",
5892 summary: "Dropdown picker that wraps a native <select>. Mirrors the <wpd-segmented> contract (set value, listen for wpd-pick) so callers can swap tag names when a list outgrows a pill bar.",
5893 status: "stable",
5894 since: "0.5.0",
5895 props: [
5896 {
5897 name: "value",
5898 type: "string",
5899 description: "Currently selected option value."
5900 },
5901 {
5902 name: "label",
5903 type: "string",
5904 description: "Visible label rendered above the select and forwarded to the native control as aria-label."
5905 },
5906 {
5907 name: "placeholder",
5908 type: "string",
5909 description: "Disabled leading option shown when no value is set."
5910 },
5911 {
5912 name: "disabled",
5913 type: "boolean attribute",
5914 description: "Disables the native select and dims the chrome."
5915 },
5916 {
5917 name: "name",
5918 type: "string",
5919 description: "Forwarded to the native <select name=…> for form submission."
5920 }
5921 ],
5922 slots: [
5923 { name: "(default)", description: '<wpd-option value="…"> children.' }
5924 ],
5925 events: [
5926 {
5927 name: "wpd-pick",
5928 description: "Fires when the user picks a new option.",
5929 detail: "{ value: string }"
5930 }
5931 ],
5932 cssProps: [
5933 { name: "--desktop-mode-text", description: "Label + value colour." },
5934 { name: "--desktop-mode-muted", description: "Placeholder + chevron colour." }
5935 ],
5936 example: html`
5937 <wpd-select value="eur" label="Currency">
5938 <wpd-option value="eur">Euro</wpd-option>
5939 <wpd-option value="usd">US Dollar</wpd-option>
5940 <wpd-option value="jpy">Japanese Yen</wpd-option>
5941 </wpd-select>
5942 `
5943 };
5944 let WpdSelect = _WpdSelect;
5945 defineComponent("wpd-select", WpdSelect);
5946 const multiselectStyles = css`
5947 :host {
5948 display: flex;
5949 flex-direction: column;
5950 gap: 4px;
5951 font-size: 13px;
5952 color: var( --desktop-mode-text, #1d2327 );
5953 min-width: 0;
5954 }
5955
5956 :host( [ hidden ] ) {
5957 display: none;
5958 }
5959
5960 .wpd-multiselect__label {
5961 font-size: 12px;
5962 color: var( --desktop-mode-muted, #646970 );
5963 }
5964
5965 .wpd-multiselect__trigger {
5966 appearance: none;
5967 display: inline-flex;
5968 align-items: center;
5969 justify-content: space-between;
5970 gap: 8px;
5971 width: 100%;
5972 min-width: 0;
5973 padding: 7px 12px 7px 12px;
5974 background: rgba( 0, 0, 0, 0.05 );
5975 border: 1px solid transparent;
5976 border-radius: 7px;
5977 font: inherit;
5978 font-size: 13px;
5979 color: var( --desktop-mode-text, #1d2327 );
5980 cursor: pointer;
5981 text-align: start;
5982 transition: background-color 0.12s ease, border-color 0.12s ease,
5983 box-shadow 0.12s ease;
5984 }
5985
5986 .wpd-multiselect__trigger:hover {
5987 background: rgba( 0, 0, 0, 0.08 );
5988 }
5989
5990 .wpd-multiselect__trigger:focus-visible {
5991 outline: none;
5992 border-color: var( --wp-admin-theme-color, #2271b1 );
5993 box-shadow: 0 0 0 1px var( --wp-admin-theme-color, #2271b1 );
5994 }
5995
5996 .wpd-multiselect__trigger:disabled {
5997 opacity: 0.5;
5998 cursor: not-allowed;
5999 }
6000
6001 .wpd-multiselect__trigger[ data-active='true' ] {
6002 color: var( --wp-admin-theme-color, #2271b1 );
6003 font-weight: 600;
6004 }
6005
6006 .wpd-multiselect__summary {
6007 flex: 1 1 auto;
6008 min-width: 0;
6009 overflow: hidden;
6010 text-overflow: ellipsis;
6011 white-space: nowrap;
6012 }
6013
6014 .wpd-multiselect__chevron {
6015 color: var( --desktop-mode-muted, #646970 );
6016 flex-shrink: 0;
6017 transition: color 0.12s ease, transform 0.18s ease;
6018 }
6019
6020 .wpd-multiselect__trigger:hover .wpd-multiselect__chevron,
6021 .wpd-multiselect__trigger:focus-visible .wpd-multiselect__chevron {
6022 color: var( --desktop-mode-text, #1d2327 );
6023 }
6024
6025 :host( [ open ] ) .wpd-multiselect__chevron {
6026 transform: rotate( 180deg );
6027 }
6028 `;
6029 function _installGlobalPopoverStyles() {
6030 const STYLE_ID = "wpd-multiselect-popover-styles";
6031 if (document.getElementById(STYLE_ID)) {
6032 return;
6033 }
6034 const style = document.createElement("style");
6035 style.id = STYLE_ID;
6036 style.textContent = `
6037 .wpd-multiselect__popover {
6038 position: fixed;
6039 z-index: 100000;
6040 max-height: 320px;
6041 overflow-y: auto;
6042 min-width: 200px;
6043 padding: 4px 0;
6044 background: var( --desktop-mode-window-bg, #fff );
6045 color: var( --desktop-mode-text, #1d2327 );
6046 border: 1px solid var( --desktop-mode-window-border, #c3c4c7 );
6047 border-radius: 8px;
6048 box-shadow: 0 8px 28px rgba( 0, 0, 0, 0.18 );
6049 font: inherit;
6050 font-size: 13px;
6051 }
6052
6053 .wpd-multiselect__clear {
6054 display: block;
6055 width: 100%;
6056 padding: 6px 12px;
6057 font: inherit;
6058 font-size: 11px;
6059 font-weight: 600;
6060 letter-spacing: 0.04em;
6061 text-transform: uppercase;
6062 text-align: start;
6063 border: 0;
6064 border-bottom: 1px solid var( --desktop-mode-window-border, #dcdcde );
6065 background: transparent;
6066 color: var( --wp-admin-theme-color, #2271b1 );
6067 cursor: pointer;
6068 }
6069
6070 .wpd-multiselect__clear:hover {
6071 background: color-mix(
6072 in srgb,
6073 var( --wp-admin-theme-color, #2271b1 ) 10%,
6074 transparent
6075 );
6076 }
6077
6078 .wpd-multiselect__option {
6079 display: flex;
6080 align-items: center;
6081 gap: 8px;
6082 padding: 6px 12px;
6083 cursor: pointer;
6084 user-select: none;
6085 }
6086
6087 .wpd-multiselect__option:hover {
6088 background: rgba( 0, 0, 0, 0.05 );
6089 }
6090
6091 .wpd-multiselect__option[ data-disabled='true' ] {
6092 opacity: 0.5;
6093 cursor: not-allowed;
6094 }
6095
6096 .wpd-multiselect__option > span {
6097 flex: 1 1 auto;
6098 min-width: 0;
6099 overflow: hidden;
6100 text-overflow: ellipsis;
6101 white-space: nowrap;
6102 }
6103
6104 .wpd-multiselect__option > input[ type='checkbox' ] {
6105 margin: 0;
6106 flex-shrink: 0;
6107 accent-color: var( --wp-admin-theme-color, #2271b1 );
6108 }
6109
6110 .wpd-multiselect__empty {
6111 padding: 8px 12px;
6112 color: var( --desktop-mode-muted, #646970 );
6113 font-style: italic;
6114 }
6115
6116 .wpd-multiselect__loading {
6117 display: flex;
6118 align-items: center;
6119 gap: 8px;
6120 padding: 8px 12px;
6121 color: var( --desktop-mode-muted, #646970 );
6122 font-size: 12px;
6123 }
6124
6125 .wpd-multiselect__spinner {
6126 display: inline-block;
6127 width: 12px;
6128 height: 12px;
6129 border-radius: 50%;
6130 border: 2px solid currentColor;
6131 border-top-color: transparent;
6132 animation: wpd-multiselect-spin 0.8s linear infinite;
6133 }
6134
6135 @keyframes wpd-multiselect-spin {
6136 to { transform: rotate( 360deg ); }
6137 }
6138 `;
6139 document.head.appendChild(style);
6140 }
6141 if (typeof document !== "undefined") {
6142 _installGlobalPopoverStyles();
6143 }
6144 const _WpdMultiselect = class _WpdMultiselect extends Component {
6145 constructor() {
6146 super(...arguments);
6147 this._optionObserver = null;
6148 this._popover = null;
6149 this._teardownOpen = null;
6150 this._hasMore = false;
6151 this._loadingMore = false;
6152 }
6153 /**
6154 * Declarative item-list setter. Replaces the existing
6155 * `<wpd-option>` children with a fresh set; preserves any values
6156 * that still match.
6157 *
6158 * @since 0.8.0
6159 */
6160 set items(list) {
6161 const existing = this.querySelectorAll(":scope > wpd-option");
6162 for (const el of Array.from(existing)) {
6163 el.remove();
6164 }
6165 for (const item of list) {
6166 const opt = document.createElement("wpd-option");
6167 opt.setAttribute("value", item.value);
6168 opt.textContent = item.label;
6169 this.appendChild(opt);
6170 }
6171 this._loadingMore = false;
6172 const validSet = new Set(list.map((i) => i.value));
6173 const next = this._readValues().filter((v) => validSet.has(v));
6174 this._writeValueAttribute(next);
6175 this.requestUpdate();
6176 this._refreshPopover();
6177 }
6178 /** Programmatic getter for the parsed selection. */
6179 get values() {
6180 return this._readValues();
6181 }
6182 /**
6183 * Programmatic setter — accepts an array of values; serialises
6184 * back to the `value` attribute as a comma-joined string.
6185 */
6186 set values(next) {
6187 const arr = Array.isArray(next) ? next.map((v) => String(v)).filter((v) => v !== "") : [];
6188 this._writeValueAttribute(arr);
6189 this.requestUpdate();
6190 this._refreshPopover();
6191 }
6192 /** Whether more pages are available (drives the load-more emit). */
6193 get hasMore() {
6194 return this._hasMore;
6195 }
6196 set hasMore(next) {
6197 this._hasMore = !!next;
6198 this._refreshPopover();
6199 }
6200 /**
6201 * Whether a load-more fetch is currently in flight. While true,
6202 * the popover paints a small spinner row and suppresses further
6203 * `wpd-multiselect-load-more` emits.
6204 */
6205 get loadingMore() {
6206 return this._loadingMore;
6207 }
6208 set loadingMore(next) {
6209 this._loadingMore = !!next;
6210 this._refreshPopover();
6211 }
6212 /**
6213 * Append additional options without dropping any already in the
6214 * tree. Used by infinite-scroll consumers — call when the next
6215 * page lands, then set `loadingMore = false` and update
6216 * `hasMore` based on whether more pages remain.
6217 *
6218 * @since 0.8.0
6219 */
6220 appendItems(more) {
6221 this._loadingMore = false;
6222 if (!more || more.length === 0) {
6223 this._refreshPopover();
6224 return;
6225 }
6226 const existing = new Set(
6227 Array.from(this.querySelectorAll(":scope > wpd-option")).map(
6228 (el) => el.getAttribute("value")
6229 )
6230 );
6231 for (const item of more) {
6232 if (existing.has(item.value)) {
6233 continue;
6234 }
6235 const opt = document.createElement("wpd-option");
6236 opt.setAttribute("value", item.value);
6237 opt.textContent = item.label;
6238 this.appendChild(opt);
6239 }
6240 this.requestUpdate();
6241 this._refreshPopover();
6242 }
6243 connectedCallback() {
6244 super.connectedCallback();
6245 ensureAutoId(this);
6246 this._optionObserver = new MutationObserver(() => {
6247 this.requestUpdate();
6248 this._refreshPopover();
6249 });
6250 this._optionObserver.observe(this, {
6251 childList: true,
6252 subtree: true,
6253 attributes: true,
6254 attributeFilter: ["value", "disabled"],
6255 characterData: true
6256 });
6257 }
6258 disconnectedCallback() {
6259 this._optionObserver?.disconnect();
6260 this._optionObserver = null;
6261 this._closePopover();
6262 }
6263 render() {
6264 const label = this.label || "";
6265 const placeholder = this.placeholder || "All";
6266 const disabled = this.disabled !== null;
6267 if (label) {
6268 this.setAttribute("aria-label", label);
6269 } else {
6270 this.removeAttribute("aria-label");
6271 }
6272 const triggerAriaLabel = label || placeholder;
6273 const summary = this._summarize(placeholder);
6274 const isActive = this._readValues().length > 0;
6275 const hostId = this.id || "wpd-unnamed";
6276 const triggerId = `${hostId}__trigger`;
6277 return html`
6278 ${label ? html`<label
6279 class="wpd-multiselect__label"
6280 for=${triggerId}
6281 >${label}</label>` : html``}
6282 <button
6283 id=${triggerId}
6284 type="button"
6285 class="wpd-multiselect__trigger"
6286 aria-haspopup="listbox"
6287 aria-expanded=${this._isOpen() ? "true" : "false"}
6288 aria-label=${triggerAriaLabel}
6289 ?disabled=${disabled}
6290 data-active=${isActive ? "true" : "false"}
6291 @click=${(e) => this._onTriggerClick(e)}
6292 >
6293 <span class="wpd-multiselect__summary">${summary}</span>
6294 <svg
6295 class="wpd-multiselect__chevron"
6296 viewBox="0 0 12 12"
6297 width="12"
6298 height="12"
6299 aria-hidden="true"
6300 focusable="false"
6301 >
6302 <path
6303 d="M3 5l3 3 3-3"
6304 stroke="currentColor"
6305 stroke-width="1.4"
6306 stroke-linecap="round"
6307 stroke-linejoin="round"
6308 fill="none"
6309 />
6310 </svg>
6311 </button>
6312 `;
6313 }
6314 _readOptions() {
6315 const out = [];
6316 const children = this.querySelectorAll(":scope > wpd-option");
6317 for (const child of Array.from(children)) {
6318 const value = child.getAttribute("value");
6319 if (value === null) {
6320 continue;
6321 }
6322 out.push({
6323 value,
6324 label: (child.textContent || value).trim(),
6325 disabled: child.hasAttribute("disabled")
6326 });
6327 }
6328 return out;
6329 }
6330 _readValues() {
6331 const raw = this.value ?? "";
6332 return raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
6333 }
6334 _writeValueAttribute(vals) {
6335 const next = vals.join(",");
6336 this.value = next;
6337 }
6338 _summarize(placeholder) {
6339 const vals = this._readValues();
6340 if (vals.length === 0) {
6341 return placeholder;
6342 }
6343 const opts = this._readOptions();
6344 const byValue = new Map(opts.map((o) => [o.value, o.label]));
6345 if (vals.length === 1) {
6346 return byValue.get(vals[0]) ?? vals[0];
6347 }
6348 return `${vals.length} selected`;
6349 }
6350 _isOpen() {
6351 return this.open !== null;
6352 }
6353 _onTriggerClick(e) {
6354 e.stopPropagation();
6355 e.preventDefault();
6356 const disabled = this.disabled !== null;
6357 if (disabled) {
6358 return;
6359 }
6360 if (this._popover) {
6361 this._closePopover();
6362 } else {
6363 this._openPopover();
6364 }
6365 }
6366 _openPopover() {
6367 if (this._popover) {
6368 return;
6369 }
6370 const popover = document.createElement("div");
6371 popover.className = "wpd-multiselect__popover";
6372 popover.setAttribute("role", "listbox");
6373 popover.setAttribute("aria-multiselectable", "true");
6374 popover.style.setProperty(
6375 "--wp-admin-theme-color",
6376 getComputedStyle(this).getPropertyValue(
6377 "--wp-admin-theme-color"
6378 ) || "#2271b1"
6379 );
6380 document.body.appendChild(popover);
6381 this._popover = popover;
6382 this._refreshPopover();
6383 this._placePopover();
6384 const onDocPointer = (ev) => {
6385 const target = ev.target;
6386 if (!target) {
6387 return;
6388 }
6389 const trigger = this.shadowRoot?.querySelector(
6390 ".wpd-multiselect__trigger"
6391 );
6392 if (popover.contains(target)) {
6393 return;
6394 }
6395 if (trigger && trigger.contains(target)) {
6396 return;
6397 }
6398 this._closePopover();
6399 };
6400 const onKey = (ev) => {
6401 if (ev.key === "Escape") {
6402 ev.stopPropagation();
6403 this._closePopover();
6404 const trigger = this.shadowRoot?.querySelector(
6405 ".wpd-multiselect__trigger"
6406 );
6407 trigger?.focus();
6408 }
6409 };
6410 const onResizeScroll = () => this._placePopover();
6411 const onPopoverScroll = () => {
6412 if (!this._hasMore || this._loadingMore) {
6413 return;
6414 }
6415 const sh = popover.scrollHeight;
6416 const ch = popover.clientHeight;
6417 const st = popover.scrollTop;
6418 if (sh - (st + ch) < 64) {
6419 this.emit("wpd-multiselect-load-more", {});
6420 }
6421 };
6422 setTimeout(() => {
6423 document.addEventListener("pointerdown", onDocPointer, true);
6424 }, 0);
6425 document.addEventListener("keydown", onKey, true);
6426 window.addEventListener("resize", onResizeScroll);
6427 window.addEventListener("scroll", onResizeScroll, true);
6428 popover.addEventListener("scroll", onPopoverScroll);
6429 this._teardownOpen = () => {
6430 document.removeEventListener("pointerdown", onDocPointer, true);
6431 document.removeEventListener("keydown", onKey, true);
6432 window.removeEventListener("resize", onResizeScroll);
6433 window.removeEventListener("scroll", onResizeScroll, true);
6434 popover.removeEventListener("scroll", onPopoverScroll);
6435 };
6436 this.setAttribute("open", "");
6437 this.requestUpdate();
6438 this.emit("wpd-multiselect-open", {});
6439 }
6440 _closePopover() {
6441 if (this._teardownOpen) {
6442 this._teardownOpen();
6443 this._teardownOpen = null;
6444 }
6445 if (this._popover) {
6446 this._popover.remove();
6447 this._popover = null;
6448 this.removeAttribute("open");
6449 this.requestUpdate();
6450 this.emit("wpd-multiselect-close", {});
6451 }
6452 }
6453 _refreshPopover() {
6454 const popover = this._popover;
6455 if (!popover) {
6456 return;
6457 }
6458 const options = this._readOptions();
6459 const selected = new Set(this._readValues());
6460 popover.replaceChildren();
6461 if (options.length === 0) {
6462 const empty = document.createElement("div");
6463 empty.className = "wpd-multiselect__empty";
6464 empty.textContent = "No options";
6465 popover.appendChild(empty);
6466 return;
6467 }
6468 if (selected.size > 0) {
6469 const clear = document.createElement("button");
6470 clear.type = "button";
6471 clear.className = "wpd-multiselect__clear";
6472 clear.textContent = "Clear";
6473 clear.addEventListener("click", (e) => {
6474 e.preventDefault();
6475 e.stopPropagation();
6476 this._writeValueAttribute([]);
6477 this.requestUpdate();
6478 this._refreshPopover();
6479 this._emitPick();
6480 });
6481 popover.appendChild(clear);
6482 }
6483 for (const opt of options) {
6484 const row = document.createElement("label");
6485 row.className = "wpd-multiselect__option";
6486 row.setAttribute("role", "option");
6487 row.setAttribute(
6488 "aria-selected",
6489 selected.has(opt.value) ? "true" : "false"
6490 );
6491 if (opt.disabled) {
6492 row.setAttribute("aria-disabled", "true");
6493 row.dataset.disabled = "true";
6494 }
6495 const cb = document.createElement("input");
6496 cb.type = "checkbox";
6497 cb.checked = selected.has(opt.value);
6498 cb.disabled = opt.disabled;
6499 cb.addEventListener("change", () => {
6500 const cur = new Set(this._readValues());
6501 if (cb.checked) {
6502 cur.add(opt.value);
6503 } else {
6504 cur.delete(opt.value);
6505 }
6506 const ordered = options.map((o) => o.value).filter((v) => cur.has(v));
6507 this._writeValueAttribute(ordered);
6508 row.setAttribute(
6509 "aria-selected",
6510 cb.checked ? "true" : "false"
6511 );
6512 this.requestUpdate();
6513 this._refreshPopover();
6514 this._emitPick();
6515 });
6516 const labelText = document.createElement("span");
6517 labelText.textContent = opt.label;
6518 row.appendChild(cb);
6519 row.appendChild(labelText);
6520 popover.appendChild(row);
6521 }
6522 if (this._loadingMore) {
6523 const loading = document.createElement("div");
6524 loading.className = "wpd-multiselect__loading";
6525 const spinner = document.createElement("span");
6526 spinner.className = "wpd-multiselect__spinner";
6527 spinner.setAttribute("aria-hidden", "true");
6528 const text = document.createElement("span");
6529 text.textContent = "Loading…";
6530 loading.appendChild(spinner);
6531 loading.appendChild(text);
6532 popover.appendChild(loading);
6533 }
6534 }
6535 _placePopover() {
6536 const popover = this._popover;
6537 const trigger = this.shadowRoot?.querySelector(
6538 ".wpd-multiselect__trigger"
6539 );
6540 if (!popover || !trigger) {
6541 return;
6542 }
6543 const rect = trigger.getBoundingClientRect();
6544 const vw = window.innerWidth;
6545 const vh = window.innerHeight;
6546 const minW = Math.max(rect.width, 200);
6547 popover.style.minWidth = `${minW}px`;
6548 let left = rect.left;
6549 if (left + minW > vw - 8) {
6550 left = Math.max(8, vw - minW - 8);
6551 }
6552 popover.style.left = `${left}px`;
6553 popover.style.top = `${rect.bottom + 4}px`;
6554 const popH = popover.offsetHeight || 200;
6555 if (rect.bottom + 4 + popH > vh - 8) {
6556 popover.style.top = `${Math.max(8, rect.top - popH - 4)}px`;
6557 }
6558 }
6559 _emitPick() {
6560 const values = this._readValues();
6561 this.emit("wpd-pick", {
6562 value: values.join(","),
6563 values
6564 });
6565 }
6566 };
6567 _WpdMultiselect.props = [
6568 "value",
6569 "label",
6570 "placeholder",
6571 "disabled",
6572 "name",
6573 "open"
6574 ];
6575 _WpdMultiselect.styles = [multiselectStyles];
6576 _WpdMultiselect.help = {
6577 title: "Multi-select",
6578 summary: "Multi-select dropdown picker that mirrors <wpd-select> ergonomically. Trigger button shows a one-line summary; clicking opens a checkbox popover. value is a comma-joined id list so it round-trips through plain string attributes.",
6579 status: "experimental",
6580 since: "0.8.0",
6581 props: [
6582 {
6583 name: "value",
6584 type: "string (comma-joined ids)",
6585 description: 'Currently selected option values, joined by commas (e.g. "1,4"). Empty string means no selection.'
6586 },
6587 {
6588 name: "label",
6589 type: "string",
6590 description: "Visible label rendered above the trigger and forwarded as aria-label to the trigger button."
6591 },
6592 {
6593 name: "placeholder",
6594 type: "string",
6595 description: 'Trigger summary when no option is checked. Defaults to "All".'
6596 },
6597 {
6598 name: "disabled",
6599 type: "boolean attribute",
6600 description: "Disables the trigger and dims the chrome."
6601 },
6602 {
6603 name: "name",
6604 type: "string",
6605 description: "Reserved for HTML form submission; not yet wired to a form field."
6606 },
6607 {
6608 name: "open",
6609 type: "boolean attribute",
6610 description: "Read-only reflection of the popover state, set by the component when it opens/closes. Useful from a CSS selector; toggling it programmatically does not open/close the popover — click the trigger instead."
6611 }
6612 ],
6613 slots: [
6614 { name: "(default)", description: '<wpd-option value="…"> children.' }
6615 ],
6616 events: [
6617 {
6618 name: "wpd-pick",
6619 description: "Fires when the user toggles any option. Detail carries both shapes — `value` is the comma-joined attribute round-trip, `values` is the parsed array.",
6620 detail: "{ value: string; values: string[] }"
6621 },
6622 {
6623 name: "wpd-multiselect-open",
6624 description: "Fires when the popover opens.",
6625 detail: "{}"
6626 },
6627 {
6628 name: "wpd-multiselect-close",
6629 description: "Fires when the popover closes.",
6630 detail: "{}"
6631 },
6632 {
6633 name: "wpd-multiselect-load-more",
6634 description: "Fires when the user scrolls near the bottom of the popover and `hasMore` is true. Consumer fetches the next page and calls `picker.appendItems(...)` to extend the list. While the fetch is in flight, set `picker.loadingMore = true` to show the spinner row and prevent re-firing.",
6635 detail: "{}"
6636 }
6637 ],
6638 cssProps: [
6639 { name: "--desktop-mode-text", description: "Label + value colour." },
6640 { name: "--desktop-mode-muted", description: "Placeholder + chevron colour." }
6641 ],
6642 example: html`
6643 <wpd-multiselect value="1,4" label="Authors">
6644 <wpd-option value="1">Daniel</wpd-option>
6645 <wpd-option value="4">Peter</wpd-option>
6646 <wpd-option value="9">Pat</wpd-option>
6647 </wpd-multiselect>
6648 `
6649 };
6650 let WpdMultiselect = _WpdMultiselect;
6651 defineComponent("wpd-multiselect", WpdMultiselect);
6652 const styles$3 = css`:host{display:inline;color:inherit;font:inherit}`;
6653 const _instances = /* @__PURE__ */ new Set();
6654 let _ticker = null;
6655 const TICK_INTERVAL_MS = 3e4;
6656 function startTicker() {
6657 if (_ticker !== null) {
6658 return;
6659 }
6660 _ticker = window.setInterval(() => {
6661 for (const i of _instances) {
6662 i.tick();
6663 }
6664 }, TICK_INTERVAL_MS);
6665 }
6666 function stopTickerIfIdle() {
6667 if (_ticker !== null && _instances.size === 0) {
6668 window.clearInterval(_ticker);
6669 _ticker = null;
6670 }
6671 }
6672 function parseDatetime(raw) {
6673 if (!raw) {
6674 return null;
6675 }
6676 const tryDate = (v) => {
6677 const d = new Date(v);
6678 return Number.isNaN(d.getTime()) ? null : d;
6679 };
6680 if (raw.includes("T") || raw.endsWith("Z")) {
6681 return tryDate(raw);
6682 }
6683 return tryDate(raw.replace(" ", "T") + "Z");
6684 }
6685 let _rtfCache = null;
6686 function getRtf() {
6687 if (!_rtfCache) {
6688 const lang = typeof navigator !== "undefined" && navigator.language || "en";
6689 _rtfCache = new Intl.RelativeTimeFormat(lang, { numeric: "auto" });
6690 }
6691 return _rtfCache;
6692 }
6693 function relativeText(date, now) {
6694 const rtf = getRtf();
6695 const diffMs = date.getTime() - now;
6696 const diffSec = Math.round(diffMs / 1e3);
6697 const abs = Math.abs;
6698 if (abs(diffSec) < 45) {
6699 return rtf.format(0, "second");
6700 }
6701 const diffMin = Math.round(diffSec / 60);
6702 if (abs(diffMin) < 45) {
6703 return rtf.format(diffMin, "minute");
6704 }
6705 const diffHour = Math.round(diffMin / 60);
6706 if (abs(diffHour) < 22) {
6707 return rtf.format(diffHour, "hour");
6708 }
6709 const diffDay = Math.round(diffHour / 24);
6710 if (abs(diffDay) < 26) {
6711 return rtf.format(diffDay, "day");
6712 }
6713 const diffMonth = Math.round(diffDay / 30);
6714 if (abs(diffMonth) < 11) {
6715 return rtf.format(diffMonth, "month");
6716 }
6717 const diffYear = Math.round(diffDay / 365);
6718 return rtf.format(diffYear, "year");
6719 }
6720 const _WpdRelativeTime = class _WpdRelativeTime extends Component {
6721 connectedCallback() {
6722 super.connectedCallback();
6723 _instances.add(this);
6724 startTicker();
6725 }
6726 disconnectedCallback() {
6727 _instances.delete(this);
6728 stopTickerIfIdle();
6729 }
6730 /** Public — the shared ticker calls this on every interval. */
6731 tick() {
6732 this.requestUpdate();
6733 }
6734 render() {
6735 const raw = this.datetime;
6736 const date = parseDatetime(raw);
6737 if (!date) {
6738 return html`<span>${raw ?? ""}</span>`;
6739 }
6740 const text = relativeText(date, Date.now());
6741 const absolute = date.toLocaleString();
6742 return html`<time datetime=${date.toISOString()} title=${absolute}
6743 >${text}</time
6744 >`;
6745 }
6746 };
6747 _WpdRelativeTime.props = ["datetime"];
6748 _WpdRelativeTime.styles = [styles$3];
6749 _WpdRelativeTime.help = {
6750 title: "Relative time",
6751 summary: 'Auto-ticking relative timestamp. Renders "5 minutes ago" / "yesterday" / "in 3 hours" via Intl.RelativeTimeFormat and updates itself every 30s while connected. Useful for any list cell that should age live (recycle bin, notifications, activity log) without forcing the surrounding view to repaint.',
6752 status: "experimental",
6753 since: "0.6.0",
6754 props: [
6755 {
6756 name: "datetime",
6757 type: 'ISO 8601 string OR MySQL-style "Y-m-d H:i:s" (treated as UTC)',
6758 description: "The moment the relative copy is anchored to. Accepts the format WordPress hands back from `*_gmt` columns directly."
6759 }
6760 ],
6761 slots: [],
6762 cssProps: [],
6763 example: html`<wpd-relative-time
6764 datetime="${new Date(Date.now() - 1e3 * 60 * 5).toISOString()}"
6765 ></wpd-relative-time>`
6766 };
6767 let WpdRelativeTime = _WpdRelativeTime;
6768 defineComponent("wpd-relative-time", WpdRelativeTime);
6769 const wpdFormStyles = css`:host{display:block;container-type:inline-size;container-name:wpd-form;font-size:13px;color:var( --desktop-mode-text,#1d2327 )}:host( [ hidden ] ){display:none}.header{margin:0 0 18px}.header:empty{display:none}.fields{display:grid;grid-template-columns:1fr;gap:14px 16px;margin:0 0 18px}@container wpd-form ( min-width:480px ){.fields{grid-template-columns:repeat( 2,minmax( 0,1fr ) )}}@container wpd-form ( min-width:760px ){:host( [ columns="3" ] ) .fields{grid-template-columns:repeat( 3,minmax( 0,1fr ) )}}:host( [ columns="1" ] ) .fields{grid-template-columns:1fr}:host( [ columns="2" ] ) .fields{grid-template-columns:repeat( 2,minmax( 0,1fr ) )}::slotted( [ full-width ] ){grid-column:1 / -1}::slotted( [ slot ] ){display:contents}.error{margin:0 0 14px;padding:10px 12px;border-radius:6px;background:rgba( 179,45,46,0.10 );color:#b32d2e;font-size:13px;line-height:1.4}.error[ hidden ]{display:none}.footer{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end;border-top:1px solid var( --desktop-mode-border,#dcdcde );padding-top:14px}:host( [ align="start" ] ) .footer{justify-content:flex-start}:host( [ align="stretch" ] ) .footer{justify-content:stretch}:host( [ align="stretch" ] ) .footer .footer-actions{flex:1 1 auto}.footer-leading,.footer-trailing{display:contents}.footer-actions{display:inline-flex;gap:8px;align-items:center;margin-inline-start:auto}:host( [ align="start" ] ) .footer-actions{margin-inline-start:0}:host( [ busy ] ){pointer-events:none}:host( [ busy ] ) .fields{opacity:0.6}:host( [ busy ] ) .footer{pointer-events:auto}.busy-spinner{display:inline-flex;width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;animation:wpd-form-spin 0.7s linear infinite;vertical-align:-2px;margin-inline-end:6px}@keyframes wpd-form-spin{to{transform:rotate( 360deg )}}@media ( prefers-reduced-motion:reduce ){.busy-spinner{animation-duration:2s}}`;
6770 const _WpdForm = class _WpdForm extends Component {
6771 constructor() {
6772 super(...arguments);
6773 this._initial = /* @__PURE__ */ new Map();
6774 this._captured = false;
6775 this._fieldChangeListener = null;
6776 this._enterSubmitListener = null;
6777 }
6778 connectedCallback() {
6779 super.connectedCallback();
6780 queueMicrotask(() => this._captureInitialValues());
6781 this._fieldChangeListener = (e) => this._onAnyFieldInput(e);
6782 this.addEventListener("wpd-input-change", this._fieldChangeListener);
6783 this.addEventListener("wpd-input-commit", this._fieldChangeListener);
6784 this.addEventListener("wpd-checkbox-change", this._fieldChangeListener);
6785 this.addEventListener("wpd-select-change", this._fieldChangeListener);
6786 this.addEventListener("change", this._fieldChangeListener);
6787 this._enterSubmitListener = () => this.submit();
6788 this.addEventListener("wpd-submit", this._enterSubmitListener);
6789 }
6790 disconnectedCallback() {
6791 if (this._fieldChangeListener) {
6792 this.removeEventListener("wpd-input-change", this._fieldChangeListener);
6793 this.removeEventListener("wpd-input-commit", this._fieldChangeListener);
6794 this.removeEventListener("wpd-checkbox-change", this._fieldChangeListener);
6795 this.removeEventListener("wpd-select-change", this._fieldChangeListener);
6796 this.removeEventListener("change", this._fieldChangeListener);
6797 this._fieldChangeListener = null;
6798 }
6799 if (this._enterSubmitListener) {
6800 this.removeEventListener("wpd-submit", this._enterSubmitListener);
6801 this._enterSubmitListener = null;
6802 }
6803 }
6804 render() {
6805 const submitLabel = this["submit-label"] || "Submit";
6806 const resetLabel = this["reset-label"] || "Reset";
6807 const error = this.error || "";
6808 const busy = this.busy !== null;
6809 const showResetRaw = this["show-reset"];
6810 const showReset = showResetRaw !== "false";
6811 return html`
6812 <div class="header" part="header">
6813 <slot name="header"></slot>
6814 </div>
6815 <div class="fields" part="fields">
6816 <slot></slot>
6817 </div>
6818 <slot name="error">
6819 ${error ? html`<p class="error" role="alert" part="error">${error}</p>` : html`<p class="error" role="alert" part="error" hidden></p>`}
6820 </slot>
6821 <footer class="footer" part="footer">
6822 <span class="footer-leading"
6823 ><slot name="footer-leading"></slot
6824 ></span>
6825 <span class="footer-actions">
6826 ${showReset ? html`<wpd-button
6827 variant="ghost"
6828 data-wpd-form-action="reset"
6829 ?disabled=${busy}
6830 @click=${() => this.reset()}
6831 >${resetLabel}</wpd-button>` : html``}
6832 <wpd-button
6833 variant="primary"
6834 data-wpd-form-action="submit"
6835 ?disabled=${busy}
6836 @click=${() => this.submit()}
6837 >
6838 ${busy ? html`<span class="busy-spinner" aria-hidden="true"></span>` : html``}
6839 ${submitLabel}
6840 </wpd-button>
6841 </span>
6842 <span class="footer-trailing"
6843 ><slot name="footer-trailing"></slot
6844 ></span>
6845 </footer>
6846 `;
6847 }
6848 // ─── Public API ──────────────────────────────────────────────────
6849 /**
6850 * Collect every named descendant's current value. Checkboxes
6851 * return `boolean`; everything else returns whatever the field
6852 * surfaces on its `value` property (or attribute as fallback).
6853 */
6854 getValues() {
6855 const out = {};
6856 for (const field of this._namedFields()) {
6857 const name = field.getAttribute("name");
6858 if (!name) {
6859 continue;
6860 }
6861 out[name] = this._readField(field);
6862 }
6863 return out;
6864 }
6865 /**
6866 * Apply a partial values map to the matching named fields.
6867 * Unknown names are skipped silently (fields may not be
6868 * mounted yet).
6869 */
6870 setValues(patch) {
6871 for (const [name, value] of Object.entries(patch)) {
6872 const field = this._fieldByName(name);
6873 if (!field) {
6874 continue;
6875 }
6876 this._writeField(field, value);
6877 }
6878 }
6879 /** Toggle the busy attribute (also re-renders to refresh the spinner). */
6880 setBusy(busy) {
6881 if (busy) {
6882 this.setAttribute("busy", "");
6883 } else {
6884 this.removeAttribute("busy");
6885 }
6886 }
6887 /**
6888 * Set the top-of-form error banner. Pass `null` (or empty
6889 * string) to clear. Equivalent to setting the `error` attribute.
6890 */
6891 setError(message) {
6892 if (message) {
6893 this.setAttribute("error", message);
6894 } else {
6895 this.removeAttribute("error");
6896 }
6897 }
6898 /**
6899 * Mark a single field invalid (or clear it). Useful for
6900 * server-returned per-field errors — e.g. "username already
6901 * exists". The optional `message` is set via the field's
6902 * `error` attribute when supported (currently a no-op for
6903 * fields that don't render one — falls back to the `invalid`
6904 * highlight only).
6905 */
6906 setFieldInvalid(name, invalid = true, message = null) {
6907 const field = this._fieldByName(name);
6908 if (!field) {
6909 return;
6910 }
6911 if (invalid) {
6912 field.setAttribute("invalid", "");
6913 if (message !== null) {
6914 field.setAttribute("error", message);
6915 }
6916 } else {
6917 field.removeAttribute("invalid");
6918 field.removeAttribute("error");
6919 }
6920 }
6921 /** Clear the form-level error AND every per-field invalid mark. */
6922 clearErrors() {
6923 this.setError(null);
6924 for (const field of this._namedFields()) {
6925 field.removeAttribute("invalid");
6926 field.removeAttribute("error");
6927 }
6928 }
6929 /**
6930 * Restore every field to its initial value (the snapshot taken
6931 * at first connection). Fires `wpd-form-reset` afterwards.
6932 */
6933 reset() {
6934 this.clearErrors();
6935 for (const [name, snap] of this._initial.entries()) {
6936 const field = this._fieldByName(name);
6937 if (!field) {
6938 continue;
6939 }
6940 if (snap.checked !== null) {
6941 field.checked = snap.checked;
6942 if (snap.checked) {
6943 field.setAttribute("checked", "");
6944 } else {
6945 field.removeAttribute("checked");
6946 }
6947 continue;
6948 }
6949 this._writeField(field, snap.value);
6950 }
6951 this.dispatchEvent(
6952 new CustomEvent("wpd-form-reset", {
6953 bubbles: true,
6954 composed: true,
6955 detail: { form: this }
6956 })
6957 );
6958 }
6959 /**
6960 * Programmatic submit. Same path the submit button + Enter key
6961 * take. Runs required-field validation, then dispatches a
6962 * cancellable `wpd-form-submit`.
6963 */
6964 submit() {
6965 const failures = [];
6966 for (const field of this._namedFields()) {
6967 const name = field.getAttribute("name");
6968 if (!name) {
6969 continue;
6970 }
6971 const required = field.hasAttribute("required");
6972 if (!required) {
6973 continue;
6974 }
6975 const value = this._readField(field);
6976 const empty = value === null || value === void 0 || value === "" || Array.isArray(value) && value.length === 0;
6977 if (empty) {
6978 field.setAttribute("invalid", "");
6979 const labelAttr = field.getAttribute("label");
6980 failures.push(labelAttr || name);
6981 }
6982 }
6983 if (failures.length > 0) {
6984 const list = failures.join(", ");
6985 this.setError(`Required: ${list}`);
6986 return;
6987 }
6988 const values = this.getValues();
6989 const event = new CustomEvent("wpd-form-submit", {
6990 bubbles: true,
6991 composed: true,
6992 cancelable: true,
6993 detail: { values, form: this }
6994 });
6995 this.dispatchEvent(event);
6996 }
6997 // ─── Internals ───────────────────────────────────────────────────
6998 _captureInitialValues() {
6999 if (this._captured) {
7000 return;
7001 }
7002 const fields = this._namedFields();
7003 if (fields.length === 0) {
7004 return;
7005 }
7006 for (const field of fields) {
7007 const name = field.getAttribute("name");
7008 if (!name) {
7009 continue;
7010 }
7011 const isCheckbox = field.tagName === "WPD-CHECKBOX" || field.tagName === "WPD-CHECKBOX-LABEL" || field.tagName === "INPUT" && field.type === "checkbox";
7012 this._initial.set(name, {
7013 value: this._readField(field),
7014 checked: isCheckbox ? Boolean(field.checked) : null
7015 });
7016 }
7017 this._captured = true;
7018 }
7019 _namedFields() {
7020 return Array.from(
7021 this.querySelectorAll("[name]")
7022 );
7023 }
7024 _fieldByName(name) {
7025 const safe = typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(name) : name.replace(/["\\]/g, "\\$&");
7026 return this.querySelector(`[name="${safe}"]`);
7027 }
7028 _readField(field) {
7029 const tag = field.tagName.toUpperCase();
7030 const isCheckbox = tag === "WPD-CHECKBOX" || tag === "WPD-CHECKBOX-LABEL" || tag === "INPUT" && field.type === "checkbox";
7031 if (isCheckbox) {
7032 if (typeof field.checked === "boolean") {
7033 return field.checked;
7034 }
7035 return field.hasAttribute("checked");
7036 }
7037 if (field.value !== void 0 && field.value !== null) {
7038 return field.value;
7039 }
7040 return field.getAttribute("value") ?? "";
7041 }
7042 _writeField(field, value) {
7043 const tag = field.tagName.toUpperCase();
7044 const isCheckbox = tag === "WPD-CHECKBOX" || tag === "WPD-CHECKBOX-LABEL" || tag === "INPUT" && field.type === "checkbox";
7045 if (isCheckbox) {
7046 const next = Boolean(value);
7047 field.checked = next;
7048 if (next) {
7049 field.setAttribute("checked", "");
7050 } else {
7051 field.removeAttribute("checked");
7052 }
7053 return;
7054 }
7055 const str = value === null || value === void 0 ? "" : String(value);
7056 field.value = str;
7057 field.setAttribute("value", str);
7058 }
7059 _onAnyFieldInput(e) {
7060 const target = e.target;
7061 if (!target) {
7062 return;
7063 }
7064 const name = target.getAttribute?.("name");
7065 if (!name) {
7066 return;
7067 }
7068 this.dispatchEvent(
7069 new CustomEvent("wpd-form-input", {
7070 bubbles: true,
7071 composed: true,
7072 detail: {
7073 name,
7074 value: this._readField(target),
7075 form: this
7076 }
7077 })
7078 );
7079 if (target.hasAttribute("invalid")) {
7080 target.removeAttribute("invalid");
7081 }
7082 }
7083 };
7084 _WpdForm.props = [
7085 "submit-label",
7086 "reset-label",
7087 "error",
7088 "busy",
7089 "columns",
7090 "min-column",
7091 "show-reset",
7092 "align"
7093 ];
7094 _WpdForm.styles = [wpdFormStyles];
7095 _WpdForm.help = {
7096 title: "Form",
7097 summary: "Container-query-driven responsive form. Auto-collects named fields, validates required, exposes setError / setFieldInvalid / setBusy / reset, fires wpd-form-submit with the collected values map.",
7098 status: "experimental",
7099 since: "0.8.1",
7100 props: [
7101 {
7102 name: "submit-label",
7103 type: "string",
7104 default: "Submit",
7105 description: "Label of the primary submit button."
7106 },
7107 {
7108 name: "reset-label",
7109 type: "string",
7110 default: "Reset",
7111 description: "Label of the reset button."
7112 },
7113 {
7114 name: "error",
7115 type: "string",
7116 description: "Top-of-form error banner. Show / hide via attribute OR setError(); equivalent."
7117 },
7118 {
7119 name: "busy",
7120 type: "boolean attribute",
7121 description: "Loading state — disables the form + flashes a spinner."
7122 },
7123 {
7124 name: "columns",
7125 type: '"auto" | "1" | "2" | "3"',
7126 default: "auto",
7127 description: 'Fixed column count, or "auto" for container-query 1↔2 (or up to 3 above 760px).'
7128 },
7129 {
7130 name: "show-reset",
7131 type: "boolean attribute",
7132 default: "true",
7133 description: 'Whether the reset button is rendered. Rendered by default; pass the literal `show-reset="false"` to hide it — omitting the attribute keeps it visible.'
7134 },
7135 {
7136 name: "align",
7137 type: '"end" | "start" | "stretch"',
7138 default: "end",
7139 description: "Footer button alignment."
7140 }
7141 ],
7142 slots: [
7143 { name: "(default)", description: "Form fields. `[name]` descendants are auto-collected." },
7144 { name: "header", description: "Heading / lede above the fields." },
7145 { name: "error", description: "Custom error UI; replaces the default banner when slotted." },
7146 { name: "footer-leading", description: "Extras left of the action buttons." },
7147 { name: "footer-trailing", description: "Extras right of the action buttons." }
7148 ],
7149 events: [
7150 {
7151 name: "wpd-form-submit",
7152 description: "Cancellable. Fires on submit after required-field validation passes.",
7153 detail: "{ values: Record<string, unknown>, form: WpdForm }"
7154 },
7155 {
7156 name: "wpd-form-reset",
7157 description: "Fires after fields have been restored to their initial values.",
7158 detail: "{ form: WpdForm }"
7159 },
7160 {
7161 name: "wpd-form-input",
7162 description: "Bubbles every keystroke / change inside any descendant field; useful for live validation.",
7163 detail: "{ name: string, value: unknown, form: WpdForm }"
7164 }
7165 ],
7166 example: html`
7167 <wpd-form submit-label="Add user">
7168 <wpd-text-field name="username" label="Username" required></wpd-text-field>
7169 <wpd-text-field name="email" type="email" label="Email" required></wpd-text-field>
7170 <wpd-text-field name="password" label="Password" full-width></wpd-text-field>
7171 </wpd-form>
7172 `
7173 };
7174 let WpdForm = _WpdForm;
7175 defineComponent("wpd-form", WpdForm);
7176 const textareaStyles = css`:host{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var( --desktop-mode-text,#1d2327 );min-width:0}:host( [ hidden ] ){display:none}.wpd-textarea__label{font-size:12px;color:var( --desktop-mode-muted,#646970 )}textarea{appearance:none;-webkit-appearance:none;display:block;width:100%;min-width:0;box-sizing:border-box;padding:8px 10px;background:var( --desktop-mode-window-bg,#fff );border:1px solid var( --desktop-mode-border,#dcdcde );border-radius:6px;font:inherit;font-size:13px;line-height:1.45;color:var( --desktop-mode-text,#1d2327 );resize:vertical;transition:border-color 0.12s ease,box-shadow 0.12s ease}textarea:hover{border-color:var( --desktop-mode-muted,#8c8f94 )}textarea:focus-visible{outline:none;border-color:var( --wp-admin-theme-color,#2271b1 );box-shadow:0 0 0 1px var( --wp-admin-theme-color,#2271b1 )}textarea:disabled{opacity:0.55;cursor:not-allowed;background:rgba( 0,0,0,0.03 )}textarea[ aria-invalid='true' ]{border-color:#d63638}textarea[ aria-invalid='true' ]:focus-visible{box-shadow:0 0 0 1px #d63638}:host( [ auto-grow ] ) textarea{resize:none;overflow:hidden}`;
7177 const _WpdTextarea = class _WpdTextarea extends Component {
7178 constructor() {
7179 super(...arguments);
7180 this._textareaEl = null;
7181 }
7182 connectedCallback() {
7183 super.connectedCallback();
7184 ensureAutoId(this);
7185 }
7186 render() {
7187 const label = this._attr("label") || "";
7188 const value = this._attr("value") ?? "";
7189 const placeholder = this._attr("placeholder") || "";
7190 const disabled = this._boolAttr("disabled");
7191 const readonly = this._boolAttr("readonly");
7192 const ariaLabel = this._attr("aria-label") || label;
7193 const name = this._attr("name") || "";
7194 const rows = Number(this._attr("rows")) || 3;
7195 const maxLength = this._attr("maxlength");
7196 const minLength = this._attr("minlength");
7197 const invalid = this._boolAttr("invalid");
7198 const hostId = this.id || "wpd-unnamed";
7199 const fieldId = `${hostId}__field`;
7200 return html`
7201 ${label ? html`<label class="wpd-textarea__label" for=${fieldId}>${label}</label>` : html``}
7202 <textarea
7203 id=${fieldId}
7204 part="textarea"
7205 .value=${value}
7206 placeholder=${placeholder}
7207 ?disabled=${disabled}
7208 ?readonly=${readonly}
7209 rows=${rows}
7210 maxlength=${maxLength ?? ""}
7211 minlength=${minLength ?? ""}
7212 name=${name}
7213 aria-invalid=${invalid ? "true" : "false"}
7214 aria-label=${ariaLabel || ""}
7215 @input=${(e) => this._onInput(e)}
7216 @change=${(e) => this._onChange(e)}
7217 @keydown=${(e) => this._onKeyDown(e)}
7218 ></textarea>
7219 `;
7220 }
7221 _attr(name) {
7222 return this.getAttribute(name);
7223 }
7224 _boolAttr(name) {
7225 return this.getAttribute(name) !== null;
7226 }
7227 _onInput(e) {
7228 const ta = e.target;
7229 this._textareaEl = ta;
7230 this.setAttribute("value", ta.value);
7231 this.emit("wpd-input-change", { value: ta.value });
7232 if (this._boolAttr("auto-grow")) {
7233 this._autosize(ta);
7234 }
7235 }
7236 _onChange(e) {
7237 const ta = e.target;
7238 this.emit("wpd-input-commit", { value: ta.value });
7239 }
7240 _onKeyDown(e) {
7241 if (!this._boolAttr("submit-on-enter")) {
7242 return;
7243 }
7244 if (e.key === "Enter" && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) {
7245 e.preventDefault();
7246 const ta = e.target;
7247 this.emit("wpd-submit", { value: ta.value });
7248 }
7249 }
7250 /**
7251 * Grow the textarea height to fit content, capped at `max-rows`.
7252 * Resets to scroll-height each input then clamps; cheap because
7253 * the browser caches layout.
7254 */
7255 _autosize(ta) {
7256 const maxRows = Number(this._attr("max-rows")) || 8;
7257 const cs = window.getComputedStyle(ta);
7258 const fontSize = parseFloat(cs.fontSize) || 13;
7259 const lineHeightRaw = cs.lineHeight;
7260 const lineHeight = lineHeightRaw === "normal" ? fontSize * 1.45 : parseFloat(lineHeightRaw) || fontSize * 1.45;
7261 const paddingTop = parseFloat(cs.paddingTop) || 0;
7262 const paddingBottom = parseFloat(cs.paddingBottom) || 0;
7263 const max = lineHeight * maxRows + paddingTop + paddingBottom;
7264 ta.style.height = "auto";
7265 const next = Math.min(ta.scrollHeight, max);
7266 ta.style.height = `${next}px`;
7267 }
7268 /** Public helper for callers that programmatically set `.value` and want autosize to re-run. */
7269 refreshAutosize() {
7270 if (this._textareaEl && this._boolAttr("auto-grow")) {
7271 this._autosize(this._textareaEl);
7272 }
7273 }
7274 /** Imperatively focus the underlying textarea. */
7275 focusInput() {
7276 const root = this.shadowRoot ?? this;
7277 const ta = root.querySelector("textarea");
7278 ta?.focus();
7279 }
7280 /** Imperatively clear the value. */
7281 clear() {
7282 this.setAttribute("value", "");
7283 const root = this.shadowRoot ?? this;
7284 const ta = root.querySelector("textarea");
7285 if (ta) {
7286 ta.value = "";
7287 if (this._boolAttr("auto-grow")) {
7288 this._autosize(ta);
7289 }
7290 }
7291 }
7292 };
7293 _WpdTextarea.props = [
7294 "label",
7295 "value",
7296 "placeholder",
7297 "disabled",
7298 "readonly",
7299 "ariaLabel",
7300 "name",
7301 "rows",
7302 "maxlength",
7303 "minlength",
7304 "invalid",
7305 "autoGrow",
7306 "maxRows",
7307 "submitOnEnter"
7308 ];
7309 _WpdTextarea.styles = [textareaStyles];
7310 _WpdTextarea.help = {
7311 title: "Textarea",
7312 summary: "Multi-line text input. Same event shape as wpd-text-field. Optional auto-grow up to max-rows; optional submit-on-enter (Enter sends, Shift+Enter newlines).",
7313 status: "stable",
7314 since: "0.6.0",
7315 props: [
7316 { name: "label", type: "string", description: "Visible label above the textarea." },
7317 { name: "value", type: "string", description: "Current value; reflected two-way." },
7318 { name: "placeholder", type: "string", description: "Native placeholder." },
7319 { name: "disabled", type: "boolean attribute" },
7320 { name: "readonly", type: "boolean attribute" },
7321 { name: "aria-label", type: "string", description: "Accessible label when no visible label is rendered." },
7322 { name: "name", type: "string", description: "Forwarded to native textarea for form submission." },
7323 { name: "rows", type: "integer (string)", default: "3", description: "Initial visible row count." },
7324 { name: "maxlength", type: "integer (string)" },
7325 { name: "minlength", type: "integer (string)" },
7326 { name: "invalid", type: "boolean attribute", description: "Sets aria-invalid + error styling." },
7327 { name: "auto-grow", type: "boolean attribute", description: "Grows up to max-rows as the user types." },
7328 { name: "max-rows", type: "integer (string)", default: "8" },
7329 {
7330 name: "submit-on-enter",
7331 type: "boolean attribute",
7332 description: "Enter fires wpd-submit; Shift+Enter inserts a newline."
7333 }
7334 ],
7335 events: [
7336 { name: "wpd-input-change", description: "Fires on every keystroke.", detail: "{ value: string }" },
7337 { name: "wpd-input-commit", description: "Fires on blur / native change.", detail: "{ value: string }" },
7338 {
7339 name: "wpd-submit",
7340 description: "Fires on Enter (without Shift) when submit-on-enter is set.",
7341 detail: "{ value: string }"
7342 }
7343 ],
7344 example: html`
7345 <wpd-textarea label="Message" rows="3" auto-grow max-rows="8" submit-on-enter></wpd-textarea>
7346 `
7347 };
7348 let WpdTextarea = _WpdTextarea;
7349 defineComponent("wpd-textarea", WpdTextarea);
7350 let _mountsPromise = null;
7351 function loadMounts() {
7352 if (!_mountsPromise) {
7353 _mountsPromise = Promise.resolve().then(() => userEditRender);
7354 }
7355 return _mountsPromise;
7356 }
7357 class WpdUserProfile extends HTMLElement {
7358 constructor() {
7359 super(...arguments);
7360 this._initialized = false;
7361 this._mountedFor = null;
7362 }
7363 static get observedAttributes() {
7364 return ["user-id"];
7365 }
7366 connectedCallback() {
7367 if (!this._initialized) {
7368 this._initialized = true;
7369 this._renderShell();
7370 }
7371 void this._mountIfNeeded();
7372 }
7373 attributeChangedCallback(name, oldValue, newValue) {
7374 if (name !== "user-id" || oldValue === newValue) {
7375 return;
7376 }
7377 if (this._initialized) {
7378 void this._mountIfNeeded();
7379 }
7380 }
7381 /**
7382 * Build the layout shell (sidebar + main column + activity
7383 * region). Same class names as the inline Profile tab in the
7384 * Users window so the existing posts-window.css rules style
7385 * both contexts identically.
7386 */
7387 _renderShell() {
7388 this.classList.add("desktop-mode-user-profile");
7389 this.innerHTML = `
7390 <div class="desktop-mode-users__edit-layout" data-wpd-user-profile-layout>
7391 <aside class="desktop-mode-users__edit-aside" data-wpd-user-profile-aside></aside>
7392 <main class="desktop-mode-users__edit-main">
7393 <div data-wpd-user-profile-form></div>
7394 <div class="desktop-mode-users__edit-activity" data-wpd-user-profile-activity></div>
7395 </main>
7396 </div>
7397 `;
7398 }
7399 async _mountIfNeeded() {
7400 const userIdAttr = this.getAttribute("user-id");
7401 const userId = userIdAttr ? parseInt(userIdAttr, 10) : 0;
7402 if (!Number.isFinite(userId) || userId <= 0) {
7403 return;
7404 }
7405 if (userId === this._mountedFor) {
7406 return;
7407 }
7408 this._mountedFor = userId;
7409 const formHost = this.querySelector(
7410 "[data-wpd-user-profile-form]"
7411 );
7412 const asideHost = this.querySelector(
7413 "[data-wpd-user-profile-aside]"
7414 );
7415 const activityHost = this.querySelector(
7416 "[data-wpd-user-profile-activity]"
7417 );
7418 if (!formHost || !asideHost || !activityHost) {
7419 return;
7420 }
7421 const mounts = await loadMounts();
7422 void mounts.mountProfileFormAt(formHost, userId);
7423 void mounts.mountProfileAsideAt(asideHost, userId, false);
7424 void mounts.mountProfileActivityAt(activityHost, userId, false);
7425 }
7426 }
7427 if (typeof customElements !== "undefined" && !customElements.get("wpd-user-profile")) {
7428 customElements.define("wpd-user-profile", WpdUserProfile);
7429 }
7430 const FALLBACK_BASE = "http://localhost/";
7431 function joinRestUrl(restRoot, path) {
7432 const base = typeof window !== "undefined" && window.location ? window.location.href : FALLBACK_BASE;
7433 const url = new URL(restRoot, base);
7434 const trimmed = path.replace(/^\/+/, "");
7435 const queryAt = trimmed.indexOf("?");
7436 const route = queryAt === -1 ? trimmed : trimmed.slice(0, queryAt);
7437 const extraQuery = queryAt === -1 ? "" : trimmed.slice(queryAt + 1);
7438 if (url.searchParams.has("rest_route")) {
7439 const existing = url.searchParams.get("rest_route") ?? "/";
7440 const prefix = existing.endsWith("/") ? existing : existing + "/";
7441 url.searchParams.set("rest_route", prefix + route);
7442 } else {
7443 const pathname = url.pathname.endsWith("/") ? url.pathname : url.pathname + "/";
7444 url.pathname = pathname + route;
7445 }
7446 if (extraQuery) {
7447 const extras = new URLSearchParams(extraQuery);
7448 extras.forEach((value, key) => {
7449 url.searchParams.append(key, value);
7450 });
7451 }
7452 return url.toString();
7453 }
7454 function broadcastTermChange(taxonomy, action, id) {
7455 const api = window.wp?.desktop;
7456 if (api && typeof api.broadcast === "function") {
7457 api.broadcast("desktop-mode.term.changed", {
7458 source: "posts-window",
7459 taxonomy,
7460 action,
7461 id
7462 });
7463 }
7464 }
7465 function createPostsWindowClient(windowId) {
7466 const getConfig = () => {
7467 const store = window.desktopModeWindowConfig;
7468 const cfg = store ? store[windowId] : void 0;
7469 if (!cfg) {
7470 throw new Error(
7471 `[${windowId}] config blob is missing — was the window opened without registration? See the matching \`desktop_mode_register_window()\` call in \`includes/{posts,pages}-window/window.php\`.`
7472 );
7473 }
7474 return cfg;
7475 };
7476 const shellFetch = (input, init) => {
7477 return trackedFetch(input, init, { windowId });
7478 };
7479 const request = async (url, init = {}) => {
7480 const cfg = getConfig();
7481 const response = await shellFetch(url, {
7482 ...init,
7483 credentials: "same-origin",
7484 headers: {
7485 "X-WP-Nonce": cfg.restNonce,
7486 Accept: "application/json",
7487 ...init.body ? { "Content-Type": "application/json" } : {},
7488 ...init.headers ?? {}
7489 }
7490 });
7491 if (!response.ok) {
7492 let message = `${response.status} ${response.statusText}`;
7493 try {
7494 const json = await response.json();
7495 if (json && typeof json.message === "string") {
7496 message = json.message;
7497 }
7498 } catch {
7499 }
7500 throw new Error(message);
7501 }
7502 const data = init.expectJson === false ? null : await response.json();
7503 return { data, headers: response.headers };
7504 };
7505 const fetchPosts = async (params = {}) => {
7506 const cfg = getConfig();
7507 const url = new URL(cfg.postsUrl);
7508 for (const [key, value] of Object.entries(cfg.queryArgs ?? {})) {
7509 if (typeof value === "string" && value !== "") {
7510 url.searchParams.set(key, value);
7511 }
7512 }
7513 if (params.page) {
7514 url.searchParams.set("page", String(params.page));
7515 }
7516 if (params.perPage) {
7517 url.searchParams.set("per_page", String(params.perPage));
7518 }
7519 if (params.search) {
7520 url.searchParams.set("search", params.search);
7521 }
7522 if (params.status) {
7523 url.searchParams.set("status", params.status);
7524 } else {
7525 url.searchParams.set("status", "any");
7526 }
7527 if (params.orderby) {
7528 url.searchParams.set("orderby", params.orderby);
7529 }
7530 if (params.order) {
7531 url.searchParams.set("order", params.order);
7532 }
7533 const appendIds = (key, v) => {
7534 const list = Array.isArray(v) ? v : [v];
7535 for (const id of list) {
7536 if (Number.isFinite(id) && id > 0) {
7537 url.searchParams.append(`${key}[]`, String(id));
7538 }
7539 }
7540 };
7541 if (params.author) {
7542 appendIds("author", params.author);
7543 }
7544 if (params.tag) {
7545 appendIds("tags", params.tag);
7546 }
7547 const { data, headers } = await request(
7548 url.toString(),
7549 { method: "GET" }
7550 );
7551 return {
7552 items: Array.isArray(data) ? data : [],
7553 total: parseInt(headers.get("X-WP-Total") ?? "0", 10) || 0,
7554 totalPages: parseInt(headers.get("X-WP-TotalPages") ?? "0", 10) || 0
7555 };
7556 };
7557 const trashPost = async (id) => {
7558 const cfg = getConfig();
7559 try {
7560 await request(`${cfg.postsUrl}/${id}`, {
7561 method: "DELETE"
7562 });
7563 return { id, ok: true };
7564 } catch (err) {
7565 return {
7566 id,
7567 ok: false,
7568 error: err instanceof Error ? err.message : String(err)
7569 };
7570 }
7571 };
7572 const buildEditPostUrl = (id) => {
7573 const cfg = getConfig();
7574 const sep = cfg.editPostUrlBase.includes("?") ? "&" : "?";
7575 return `${cfg.editPostUrlBase}${sep}post=${id}&action=edit`;
7576 };
7577 const searchTags = async (query, signal) => {
7578 const cfg = getConfig();
7579 const url = new URL(joinRestUrl(cfg.restRoot, "wp/v2/tags"));
7580 url.searchParams.set("per_page", "20");
7581 url.searchParams.set("_fields", "id,name,slug,count");
7582 url.searchParams.set("orderby", "count");
7583 url.searchParams.set("order", "desc");
7584 if (query) {
7585 url.searchParams.set("search", query);
7586 url.searchParams.set("orderby", "name");
7587 url.searchParams.set("order", "asc");
7588 }
7589 const { data } = await request(url.toString(), {
7590 method: "GET",
7591 signal
7592 });
7593 return Array.isArray(data) ? data : [];
7594 };
7595 const createTag = async (name) => {
7596 const cfg = getConfig();
7597 const url = joinRestUrl(cfg.restRoot, "wp/v2/tags");
7598 try {
7599 const { data } = await request(url, {
7600 method: "POST",
7601 body: JSON.stringify({ name })
7602 });
7603 broadcastTermChange("post_tag", "created", data.id);
7604 return data;
7605 } catch (err) {
7606 const message = err instanceof Error ? err.message : String(err);
7607 if (/term[\s_]?exists/i.test(message)) {
7608 const matches = await searchTags(name);
7609 const exact = matches.find(
7610 (t) => t.name.toLowerCase() === name.toLowerCase()
7611 );
7612 if (exact) {
7613 return exact;
7614 }
7615 }
7616 throw err;
7617 }
7618 };
7619 const updatePostTags = async (postId, tagIds) => {
7620 const cfg = getConfig();
7621 const url = `${cfg.postsUrl}/${postId}`;
7622 const { data } = await request(url, {
7623 method: "POST",
7624 body: JSON.stringify({ tags: tagIds })
7625 });
7626 return data;
7627 };
7628 const fetchAllCategories = async (signal) => {
7629 const cfg = getConfig();
7630 const url = new URL(joinRestUrl(cfg.restRoot, "wp/v2/categories"));
7631 url.searchParams.set("per_page", "100");
7632 url.searchParams.set("_fields", "id,name,slug,parent");
7633 url.searchParams.set("orderby", "name");
7634 url.searchParams.set("order", "asc");
7635 const { data } = await request(url.toString(), {
7636 method: "GET",
7637 signal
7638 });
7639 return Array.isArray(data) ? data : [];
7640 };
7641 const fetchAuthorOptions = async (signal) => {
7642 const cfg = getConfig();
7643 const url = new URL(joinRestUrl(cfg.restRoot, "wp/v2/users"));
7644 url.searchParams.set("per_page", "100");
7645 url.searchParams.set("who", "authors");
7646 url.searchParams.set("_fields", "id,name");
7647 url.searchParams.set("orderby", "name");
7648 url.searchParams.set("order", "asc");
7649 try {
7650 const { data } = await request(url.toString(), {
7651 method: "GET",
7652 signal
7653 });
7654 return Array.isArray(data) ? data : [];
7655 } catch {
7656 return [];
7657 }
7658 };
7659 const fetchTagOptions = async (page = 1, perPage = 50, signal) => {
7660 const cfg = getConfig();
7661 const url = new URL(joinRestUrl(cfg.restRoot, "wp/v2/tags"));
7662 url.searchParams.set("per_page", String(Math.max(1, perPage)));
7663 url.searchParams.set("page", String(Math.max(1, page)));
7664 url.searchParams.set("_fields", "id,name,count");
7665 url.searchParams.set("orderby", "count");
7666 url.searchParams.set("order", "desc");
7667 try {
7668 const { data, headers } = await request(
7669 url.toString(),
7670 { method: "GET", signal }
7671 );
7672 return {
7673 items: Array.isArray(data) ? data : [],
7674 totalPages: parseInt(headers.get("X-WP-TotalPages") ?? "0", 10) || 0
7675 };
7676 } catch {
7677 return { items: [], totalPages: 0 };
7678 }
7679 };
7680 const createCategory = async (name, parent = 0, opts = {}) => {
7681 const cfg = getConfig();
7682 const url = joinRestUrl(cfg.restRoot, "wp/v2/categories");
7683 const body = { name, parent };
7684 if (opts.slug) {
7685 body.slug = opts.slug;
7686 }
7687 if (opts.description) {
7688 body.description = opts.description;
7689 }
7690 try {
7691 const { data } = await request(url, {
7692 method: "POST",
7693 body: JSON.stringify(body)
7694 });
7695 broadcastTermChange("category", "created", data.id);
7696 return data;
7697 } catch (err) {
7698 const message = err instanceof Error ? err.message : String(err);
7699 if (/term[\s_]?exists/i.test(message)) {
7700 const matches = await fetchAllCategories();
7701 const exact = matches.find(
7702 (t) => t.name.toLowerCase() === name.toLowerCase() && t.parent === parent
7703 );
7704 if (exact) {
7705 return exact;
7706 }
7707 }
7708 throw err;
7709 }
7710 };
7711 const updatePostCategories = async (postId, categoryIds) => {
7712 const cfg = getConfig();
7713 const url = `${cfg.postsUrl}/${postId}`;
7714 const { data } = await request(
7715 url,
7716 {
7717 method: "POST",
7718 body: JSON.stringify({ categories: categoryIds })
7719 }
7720 );
7721 return data;
7722 };
7723 const fetchTerms = async (taxonomy, params = {}) => {
7724 const cfg = getConfig();
7725 const url = new URL(joinRestUrl(cfg.restRoot, `wp/v2/${taxonomy}`));
7726 url.searchParams.set("per_page", String(params.perPage ?? 50));
7727 url.searchParams.set("page", String(params.page ?? 1));
7728 url.searchParams.set(
7729 "_fields",
7730 "id,name,slug,parent,count,description,desktop_mode_count,desktop_mode_is_default"
7731 );
7732 url.searchParams.set("orderby", params.orderby ?? "name");
7733 url.searchParams.set("order", params.order ?? "asc");
7734 if (params.search) {
7735 url.searchParams.set("search", params.search);
7736 }
7737 if (typeof params.parent === "number" && params.parent >= 0) {
7738 url.searchParams.set("parent", String(params.parent));
7739 }
7740 const { data, headers } = await request(
7741 url.toString(),
7742 { method: "GET" }
7743 );
7744 const items = Array.isArray(data) ? data.map((t) => {
7745 const anyCount = t.desktop_mode_count;
7746 const isDefault = t.desktop_mode_is_default === true;
7747 return {
7748 id: t.id ?? 0,
7749 name: t.name ?? "",
7750 slug: t.slug ?? "",
7751 parent: t.parent ?? 0,
7752 count: typeof anyCount === "number" ? anyCount : t.count ?? 0,
7753 description: t.description ?? "",
7754 isDefault
7755 };
7756 }) : [];
7757 return {
7758 items,
7759 total: parseInt(headers.get("X-WP-Total") ?? "0", 10) || 0,
7760 totalPages: parseInt(headers.get("X-WP-TotalPages") ?? "0", 10) || 0
7761 };
7762 };
7763 const fetchTagCooccurrence = async (taxonomy = "tags", limit = 8) => {
7764 const cfg = getConfig();
7765 const url = new URL(
7766 joinRestUrl(
7767 cfg.restRoot,
7768 "desktop-mode/v1/tag-cooccurrence"
7769 )
7770 );
7771 url.searchParams.set(
7772 "taxonomy",
7773 taxonomy === "tags" ? "post_tag" : "category"
7774 );
7775 url.searchParams.set("limit", String(limit));
7776 const { data } = await request(url.toString(), { method: "GET" });
7777 const out = /* @__PURE__ */ new Map();
7778 const pairs = data && typeof data === "object" && !Array.isArray(data) ? data.pairs : void 0;
7779 if (!pairs) {
7780 return out;
7781 }
7782 for (const [key, neighbors] of Object.entries(pairs)) {
7783 const id = parseInt(key, 10);
7784 if (!Number.isFinite(id) || id <= 0) {
7785 continue;
7786 }
7787 const clean = [];
7788 for (const raw of neighbors) {
7789 const nid = Number(raw?.id);
7790 const sh = Number(raw?.shared);
7791 if (Number.isFinite(nid) && nid > 0 && Number.isFinite(sh) && sh > 0) {
7792 clean.push({ id: nid, shared: sh });
7793 }
7794 }
7795 if (clean.length > 0) {
7796 out.set(id, clean);
7797 }
7798 }
7799 return out;
7800 };
7801 const updateTerm = async (taxonomy, id, patch) => {
7802 const cfg = getConfig();
7803 const url = joinRestUrl(cfg.restRoot, `wp/v2/${taxonomy}/${id}`);
7804 const { data } = await request(url, {
7805 method: "POST",
7806 body: JSON.stringify(patch)
7807 });
7808 broadcastTermChange(
7809 taxonomy === "categories" ? "category" : "post_tag",
7810 "updated",
7811 id
7812 );
7813 return {
7814 id: data.id ?? id,
7815 name: data.name ?? "",
7816 slug: data.slug ?? "",
7817 parent: data.parent ?? 0,
7818 count: data.count ?? 0,
7819 description: data.description ?? "",
7820 isDefault: data.isDefault ?? false
7821 };
7822 };
7823 const deleteTerm = async (taxonomy, id) => {
7824 const cfg = getConfig();
7825 const url = new URL(
7826 joinRestUrl(cfg.restRoot, `wp/v2/${taxonomy}/${id}`)
7827 );
7828 url.searchParams.set("force", "true");
7829 await request(url.toString(), { method: "DELETE" });
7830 broadcastTermChange(
7831 taxonomy === "categories" ? "category" : "post_tag",
7832 "deleted",
7833 id
7834 );
7835 };
7836 return {
7837 windowId,
7838 getConfig,
7839 fetchPosts,
7840 trashPost,
7841 buildEditPostUrl,
7842 searchTags,
7843 createTag,
7844 updatePostTags,
7845 fetchAllCategories,
7846 fetchAuthorOptions,
7847 fetchTagOptions,
7848 createCategory,
7849 updatePostCategories,
7850 fetchTerms,
7851 fetchTagCooccurrence,
7852 updateTerm,
7853 deleteTerm
7854 };
7855 }
7856 function createUsersWindowClient(windowId = "desktop-mode-users") {
7857 const getConfig = () => {
7858 const store = window.desktopModeWindowConfig;
7859 const cfg = store?.[windowId];
7860 if (!cfg) {
7861 throw new Error(
7862 `[${windowId}] config blob is missing — was the window opened without registration? See \`includes/users-window/window.php\`.`
7863 );
7864 }
7865 return cfg;
7866 };
7867 const shellFetch = (input, init, options) => {
7868 return trackedFetch(input, init, {
7869 windowId,
7870 source: options?.source ?? "users-window/rest",
7871 silent: options?.silent
7872 });
7873 };
7874 const fetchUsers = async (params) => {
7875 const cfg = getConfig();
7876 const baseUrl = cfg.usersUrl || cfg.postsUrl;
7877 const url = new URL(baseUrl);
7878 for (const [key, value] of Object.entries(cfg.queryArgs ?? {})) {
7879 if (typeof value === "string" && value !== "") {
7880 url.searchParams.set(key, value);
7881 }
7882 }
7883 url.searchParams.set("page", String(Math.max(1, params.page)));
7884 url.searchParams.set(
7885 "per_page",
7886 String(Math.max(1, params.perPage))
7887 );
7888 if (params.search) {
7889 url.searchParams.set("search", params.search);
7890 }
7891 if (params.roles && params.roles.length > 0) {
7892 for (const r of params.roles) {
7893 url.searchParams.append("roles", r);
7894 }
7895 }
7896 if (params.orderby) {
7897 url.searchParams.set("orderby", params.orderby);
7898 }
7899 if (params.order) {
7900 url.searchParams.set("order", params.order);
7901 }
7902 const res = await shellFetch(
7903 url.toString(),
7904 {
7905 method: "GET",
7906 credentials: "same-origin",
7907 headers: {
7908 Accept: "application/json",
7909 "X-WP-Nonce": cfg.restNonce
7910 }
7911 },
7912 { source: "users-window/list" }
7913 );
7914 if (!res.ok) {
7915 throw new Error(
7916 `[users-window] list fetch failed: ${res.status}`
7917 );
7918 }
7919 const items = await res.json();
7920 const total = parseInt(res.headers.get("X-WP-Total") ?? "0", 10);
7921 const totalPages = parseInt(
7922 res.headers.get("X-WP-TotalPages") ?? "0",
7923 10
7924 );
7925 return { items, total, totalPages };
7926 };
7927 const fetchOneUser = async (id) => {
7928 const cfg = getConfig();
7929 const baseUrl = cfg.usersUrl || cfg.postsUrl;
7930 const url = new URL(`${baseUrl.replace(/\/$/, "")}/${id}`);
7931 for (const [key, value] of Object.entries(cfg.queryArgs ?? {})) {
7932 if (typeof value === "string" && value !== "") {
7933 url.searchParams.set(key, value);
7934 }
7935 }
7936 const res = await shellFetch(
7937 url.toString(),
7938 {
7939 method: "GET",
7940 credentials: "same-origin",
7941 headers: {
7942 Accept: "application/json",
7943 "X-WP-Nonce": cfg.restNonce
7944 }
7945 },
7946 { source: "users-window/one", silent: true }
7947 );
7948 if (res.status === 404) {
7949 return null;
7950 }
7951 if (!res.ok) {
7952 throw new Error(
7953 `[users-window] one fetch failed: ${res.status}`
7954 );
7955 }
7956 return await res.json();
7957 };
7958 const bulkSetRole = async (ids, role) => {
7959 const cfg = getConfig();
7960 const url = cfg.bulkRoleUrl ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/bulk-role");
7961 const res = await shellFetch(
7962 url,
7963 {
7964 method: "POST",
7965 credentials: "same-origin",
7966 headers: {
7967 "Content-Type": "application/json",
7968 "X-WP-Nonce": cfg.restNonce
7969 },
7970 body: JSON.stringify({ ids, role })
7971 },
7972 { source: "users-window/bulk-role" }
7973 );
7974 if (!res.ok) {
7975 throw new Error(
7976 `[users-window] bulk-role failed: ${res.status}`
7977 );
7978 }
7979 return await res.json();
7980 };
7981 const sendPasswordReset = async (id) => {
7982 const cfg = getConfig();
7983 const base = cfg.sendResetUrlBase ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/");
7984 const res = await shellFetch(
7985 joinRestUrl(base, `${id}/send-password-reset`),
7986 {
7987 method: "POST",
7988 credentials: "same-origin",
7989 headers: {
7990 "Content-Type": "application/json",
7991 "X-WP-Nonce": cfg.restNonce
7992 }
7993 },
7994 { source: "users-window/send-password-reset" }
7995 );
7996 if (!res.ok) {
7997 const body = await res.json().catch(() => ({}));
7998 return {
7999 ok: false,
8000 error: typeof body.code === "string" ? body.code : `http_${res.status}`
8001 };
8002 }
8003 const data = await res.json();
8004 return { ok: data.ok === true, email: data.email };
8005 };
8006 const resendWelcome = async (id) => {
8007 const cfg = getConfig();
8008 const base = cfg.sendResetUrlBase ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/");
8009 const res = await shellFetch(
8010 joinRestUrl(base, `${id}/resend-welcome`),
8011 {
8012 method: "POST",
8013 credentials: "same-origin",
8014 headers: {
8015 "Content-Type": "application/json",
8016 "X-WP-Nonce": cfg.restNonce
8017 }
8018 },
8019 { source: "users-window/resend-welcome" }
8020 );
8021 if (!res.ok) {
8022 const body = await res.json().catch(() => ({}));
8023 return {
8024 ok: false,
8025 error: typeof body.code === "string" ? body.code : `http_${res.status}`
8026 };
8027 }
8028 const data = await res.json();
8029 return { ok: data.ok === true, email: data.email };
8030 };
8031 const createUser = async (body) => {
8032 const cfg = getConfig();
8033 const url = cfg.createUserUrl ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users");
8034 const res = await shellFetch(
8035 url,
8036 {
8037 method: "POST",
8038 credentials: "same-origin",
8039 headers: {
8040 "Content-Type": "application/json",
8041 "X-WP-Nonce": cfg.restNonce
8042 },
8043 body: JSON.stringify(body)
8044 },
8045 { source: "users-window/create" }
8046 );
8047 if (!res.ok) {
8048 const data2 = await res.json().catch(() => ({}));
8049 const code = data2.code;
8050 const message = data2.message;
8051 return {
8052 ok: false,
8053 error: typeof code === "string" ? code : `http_${res.status}`,
8054 message: typeof message === "string" ? message : void 0
8055 };
8056 }
8057 const data = await res.json();
8058 return {
8059 ok: data.ok === true,
8060 user_id: data.user_id,
8061 email: data.email
8062 };
8063 };
8064 const bulkDeleteUsers = async (ids, reassign) => {
8065 const cfg = getConfig();
8066 const url = cfg.bulkDeleteUrl ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/bulk-delete");
8067 const body = { ids };
8068 if (typeof reassign === "number" && reassign > 0) {
8069 body.reassign = reassign;
8070 }
8071 const res = await shellFetch(
8072 url,
8073 {
8074 method: "POST",
8075 credentials: "same-origin",
8076 headers: {
8077 "Content-Type": "application/json",
8078 "X-WP-Nonce": cfg.restNonce
8079 },
8080 body: JSON.stringify(body)
8081 },
8082 { source: "users-window/bulk-delete" }
8083 );
8084 if (!res.ok) {
8085 throw new Error(
8086 `[users-window] bulk-delete failed: ${res.status}`
8087 );
8088 }
8089 return await res.json();
8090 };
8091 return {
8092 windowId,
8093 getConfig,
8094 fetchUsers,
8095 fetchOneUser,
8096 bulkSetRole,
8097 sendPasswordReset,
8098 resendWelcome,
8099 createUser,
8100 bulkDeleteUsers
8101 };
8102 }
8103 const styles$2 = 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 )}}`;
8104 const _WpdButton = class _WpdButton extends Component {
8105 render() {
8106 const disabled = this.disabled !== null;
8107 const busy = this.busy !== null;
8108 const type = this.type || "button";
8109 return html`
8110 <button
8111 part="button"
8112 type=${type}
8113 ?disabled=${disabled || busy}
8114 aria-busy=${busy ? "true" : "false"}
8115 >
8116 ${busy ? html`<span class="wpd-button__spinner" aria-hidden="true"></span>` : ""}
8117 <slot></slot>
8118 </button>
8119 `;
8120 }
8121 };
8122 _WpdButton.props = ["variant", "disabled", "type", "busy", "fill-cell"];
8123 _WpdButton.styles = [styles$2];
8124 _WpdButton.help = {
8125 title: "Button",
8126 summary: "Thin wrapper around <button> with consistent variant styling and a slot for the label.",
8127 status: "stable",
8128 since: "0.9.0",
8129 props: [
8130 {
8131 name: "variant",
8132 type: "'primary' | 'secondary' | 'ghost' | 'danger' | 'link'",
8133 default: "ghost",
8134 description: "Visual weight of the button. Use primary for the single attention-grabbing action per surface."
8135 },
8136 {
8137 name: "disabled",
8138 type: "boolean attribute",
8139 description: "Disable pointer + keyboard interaction and dim the chrome."
8140 },
8141 {
8142 name: "type",
8143 type: "'button' | 'submit' | 'reset'",
8144 default: "button",
8145 description: "Forwarded to the underlying native <button>."
8146 },
8147 {
8148 name: "busy",
8149 type: "boolean attribute",
8150 description: "Marks the button as in-progress (e.g., awaiting a fetch)."
8151 },
8152 {
8153 name: "fill-cell",
8154 type: "boolean attribute",
8155 description: "Grow to fill the parent flex/grid cell. Useful for tiled keypads."
8156 }
8157 ],
8158 slots: [{ name: "(default)", description: "Button label." }],
8159 parts: [{ name: "button", description: "Underlying <button> element." }],
8160 cssProps: [
8161 { name: "--wpd-button-bg", description: "Background color." },
8162 {
8163 name: "--wpd-button-bg-hover",
8164 description: "Hover wash (ghost + secondary variants)."
8165 },
8166 { name: "--wpd-button-fg", description: "Text color." },
8167 { name: "--wpd-button-border", description: "Border shorthand." },
8168 { name: "--wpd-button-border-radius", default: "6px" },
8169 { name: "--wpd-button-padding", default: "6px 12px" },
8170 {
8171 name: "--wpd-button-min-height",
8172 description: "Minimum height when fill-cell is set."
8173 }
8174 ],
8175 example: html`
8176 <wpd-cluster gap="8">
8177 <wpd-button variant="primary">Primary</wpd-button>
8178 <wpd-button variant="secondary">Secondary</wpd-button>
8179 <wpd-button variant="ghost">Ghost</wpd-button>
8180 <wpd-button variant="danger">Danger</wpd-button>
8181 <wpd-button variant="link">Link</wpd-button>
8182 </wpd-cluster>
8183 `
8184 };
8185 let WpdButton = _WpdButton;
8186 defineComponent("wpd-button", WpdButton);
8187 const segmentedStyles = css`:host{display:inline-flex;padding:3px;background:var( --wpd-segmented-bg,rgba( 0,0,0,0.05 ) );border-radius:7px;gap:2px}`;
8188 const segmentStyles = css`:host{flex:1 1 auto;min-width:0}button{appearance:none;display:block;width:100%;padding:8px 12px;background:transparent;border:0;font:inherit;font-size:13px;color:var( --desktop-mode-muted,#646970 );cursor:pointer;border-radius:5px;transition:background-color 0.12s ease,color 0.12s ease;white-space:nowrap}:host( [ aria-checked='true' ] ) button{background:var( --desktop-mode-window-bg,#fff );color:var( --desktop-mode-text,#1d2327 );box-shadow:0 1px 3px rgba( 0,0,0,0.12 );font-weight:500}`;
8189 const _WpdSegment = class _WpdSegment extends Component {
8190 render() {
8191 this.setAttribute("role", "radio");
8192 return html`
8193 <button type="button" @click=${() => this._onPick()}>
8194 <slot></slot>
8195 </button>
8196 `;
8197 }
8198 _onPick() {
8199 this.emit("wpd-segment-pick", {
8200 value: this.value
8201 });
8202 }
8203 };
8204 _WpdSegment.props = ["value"];
8205 _WpdSegment.styles = [segmentStyles];
8206 _WpdSegment.help = {
8207 title: "Segment",
8208 summary: "Single pill inside a <wpd-segmented> group. Value identifies it for selection; aria-checked is mirrored by the parent.",
8209 status: "stable",
8210 since: "0.9.0",
8211 props: [
8212 {
8213 name: "value",
8214 type: "string",
8215 description: "Identifier this segment contributes to the parent group selection."
8216 }
8217 ],
8218 slots: [
8219 { name: "(default)", description: "Visible segment label." }
8220 ],
8221 events: [
8222 {
8223 name: "wpd-segment-pick",
8224 description: "Internal event bubbled to the parent <wpd-segmented>. Consumers should listen for wpd-pick on the group instead.",
8225 detail: "{ value: string }"
8226 }
8227 ]
8228 };
8229 let WpdSegment = _WpdSegment;
8230 defineComponent("wpd-segment", WpdSegment);
8231 const _WpdSegmented = class _WpdSegmented extends Component {
8232 connectedCallback() {
8233 super.connectedCallback();
8234 this.addEventListener("wpd-segment-pick", (e) => {
8235 const detail = e.detail;
8236 e.stopPropagation();
8237 this.value = detail.value;
8238 this.emit("wpd-pick", { value: detail.value });
8239 });
8240 }
8241 /**
8242 * Declarative item-list setter. Replaces the existing
8243 * `<wpd-segment>` children with a fresh set built from a
8244 * `{ value, label }` array; preserves the current selection
8245 * when the value still matches an entry, otherwise falls back
8246 * to the first item.
8247 *
8248 * Collapses the pre-0.11 imperative dance (clear children,
8249 * `createElement`, set `textContent`, `appendChild`, then
8250 * `setAttribute('value', …)` on the group — order matters) to
8251 * a single assignment:
8252 *
8253 * ```js
8254 * segmented.items = [
8255 * { value: 'm', label: 'm' },
8256 * { value: 'km', label: 'km' },
8257 * ];
8258 * ```
8259 *
8260 * @since 0.5.0
8261 */
8262 set items(list) {
8263 const existing = this.querySelectorAll(":scope > wpd-segment");
8264 for (const el of Array.from(existing)) {
8265 el.remove();
8266 }
8267 for (const item of list) {
8268 const seg = document.createElement("wpd-segment");
8269 seg.setAttribute("value", item.value);
8270 seg.textContent = item.label;
8271 this.appendChild(seg);
8272 }
8273 const current = this.value;
8274 const stillValid = current !== null && list.some((i) => i.value === current);
8275 if (!stillValid && list.length > 0) {
8276 this.value = list[0].value;
8277 } else {
8278 this.requestUpdate();
8279 }
8280 }
8281 render() {
8282 const label = this.label || "";
8283 if (label) {
8284 this.setAttribute("aria-label", label);
8285 }
8286 this.setAttribute("role", "radiogroup");
8287 const current = this.value;
8288 queueMicrotask(() => {
8289 const segs = this.querySelectorAll("wpd-segment");
8290 for (const seg of Array.from(segs)) {
8291 const v = seg.getAttribute("value");
8292 seg.setAttribute(
8293 "aria-checked",
8294 v === current ? "true" : "false"
8295 );
8296 }
8297 });
8298 return html`<slot></slot>`;
8299 }
8300 };
8301 _WpdSegmented.props = ["value", "label"];
8302 _WpdSegmented.styles = [segmentedStyles];
8303 _WpdSegmented.help = {
8304 title: "Segmented",
8305 summary: "iOS-style segmented radio group. Pill-shaped bar of equal-width <wpd-segment> children where exactly one is active.",
8306 status: "stable",
8307 since: "0.9.0",
8308 props: [
8309 {
8310 name: "value",
8311 type: "string",
8312 description: "Currently selected segment value. Mirrored onto child aria-checked."
8313 },
8314 {
8315 name: "label",
8316 type: "string",
8317 description: "aria-label for the radiogroup."
8318 }
8319 ],
8320 slots: [
8321 { name: "(default)", description: '<wpd-segment value="…"> children.' }
8322 ],
8323 events: [
8324 {
8325 name: "wpd-pick",
8326 description: "Fires when the selected segment changes.",
8327 detail: "{ value: string }"
8328 }
8329 ],
8330 cssProps: [
8331 { name: "--desktop-mode-window-bg", description: "Pill background." },
8332 { name: "--desktop-mode-text", description: "Active label colour." },
8333 { name: "--desktop-mode-muted", description: "Inactive label colour." }
8334 ],
8335 example: html`
8336 <wpd-segmented value="md" label="Dock size">
8337 <wpd-segment value="sm">Small</wpd-segment>
8338 <wpd-segment value="md">Medium</wpd-segment>
8339 <wpd-segment value="lg">Large</wpd-segment>
8340 </wpd-segmented>
8341 `
8342 };
8343 let WpdSegmented = _WpdSegmented;
8344 defineComponent("wpd-segmented", WpdSegmented);
8345 const menuStyles = css`:host{display:block;min-width:220px;padding:4px;background:var( --desktop-mode-window-bg,#fff );color:var( --desktop-mode-text,#1d2327 );border:1px solid var( --desktop-mode-window-border,#c3c4c7 );border-radius:8px;box-shadow:0 8px 24px rgba( 0,0,0,0.18 ),0 2px 6px rgba( 0,0,0,0.08 )}:host( [ hidden ] ){display:none}`;
8346 const menuItemStyles = css`:host{display:block}button{display:flex;align-items:center;gap:10px;width:100%;min-height:32px;padding:6px 10px;border:none;border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:1.3;text-align:start;cursor:pointer;transition:background-color 0.12s ease,color 0.12s ease}button:hover,button:focus-visible{background:rgba( 0,0,0,0.06 );color:#000;outline:none}button:focus-visible{outline:2px solid var( --wp-admin-theme-color,#2271b1 );outline-offset:-2px}.wpd-menu-item__icon{flex-shrink:0;width:18px;height:18px;font-size:18px;line-height:1;color:var( --wp-admin-theme-color,#2271b1 )}.wpd-menu-item__icon[ hidden ]{display:none}.wpd-menu-item__label{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wpd-menu-item__check{flex-shrink:0;width:16px;height:16px;border-radius:3px;border:1.5px solid rgba( 0,0,0,0.25 );position:relative;background:transparent;transition:background-color 0.12s ease,border-color 0.12s ease}.wpd-menu-item__check[ hidden ]{display:none}:host( [ checked ] ) .wpd-menu-item__check{background:var( --wp-admin-theme-color,#2271b1 );border-color:var( --wp-admin-theme-color,#2271b1 )}:host( [ checked ] ) .wpd-menu-item__check::after{content:'';position:absolute;top:1px;left:4px;width:4px;height:8px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate( 45deg )}`;
8347 const _WpdMenu = class _WpdMenu extends Component {
8348 connectedCallback() {
8349 super.connectedCallback();
8350 this.setAttribute("role", "menu");
8351 }
8352 render() {
8353 return html`<slot></slot>`;
8354 }
8355 };
8356 _WpdMenu.styles = [menuStyles];
8357 _WpdMenu.help = {
8358 title: "Menu",
8359 summary: "Popover menu used in window title bars and other overflow triggers. Presentation-only: the consumer owns open/close state via the `hidden` attribute and any outside-click dismissal.",
8360 status: "stable",
8361 since: "0.9.0",
8362 slots: [
8363 { name: "(default)", description: "<wpd-menu-item> children." }
8364 ],
8365 cssProps: [
8366 { name: "--desktop-mode-window-bg", description: "Menu background." },
8367 { name: "--desktop-mode-window-border", description: "Menu border." },
8368 { name: "--desktop-mode-text", description: "Item text colour." }
8369 ],
8370 example: html`
8371 <wpd-menu>
8372 <wpd-menu-item value="new" icon="dashicons-plus">Open another window</wpd-menu-item>
8373 <wpd-menu-item value="startup" role="menuitemcheckbox" checked>Open on startup</wpd-menu-item>
8374 <wpd-menu-item value="close">Close window</wpd-menu-item>
8375 </wpd-menu>
8376 `
8377 };
8378 let WpdMenu = _WpdMenu;
8379 defineComponent("wpd-menu", WpdMenu);
8380 const _WpdMenuItem = class _WpdMenuItem extends Component {
8381 connectedCallback() {
8382 super.connectedCallback();
8383 if (!this.hasAttribute("role")) {
8384 this.setAttribute("role", "menuitem");
8385 }
8386 }
8387 render() {
8388 const icon = this.icon || "";
8389 const isCheckbox = this.getAttribute("role") === "menuitemcheckbox";
8390 const checked = this.checked !== null;
8391 if (isCheckbox) {
8392 this.setAttribute("aria-checked", checked ? "true" : "false");
8393 }
8394 return html`
8395 <button type="button" @click=${(e) => this._onPick(e)}>
8396 <span
8397 class="wpd-menu-item__check"
8398 ?hidden=${!isCheckbox}
8399 ></span>
8400 <span
8401 class="wpd-menu-item__icon dashicons ${icon}"
8402 aria-hidden="true"
8403 ?hidden=${isCheckbox || !icon}
8404 ></span>
8405 <span class="wpd-menu-item__label">
8406 <slot></slot>
8407 </span>
8408 </button>
8409 `;
8410 }
8411 _onPick(e) {
8412 e.preventDefault();
8413 this.emit("wpd-menu-item-click", {
8414 value: this.value
8415 });
8416 }
8417 };
8418 _WpdMenuItem.props = ["icon", "value", "checked"];
8419 _WpdMenuItem.styles = [menuItemStyles];
8420 _WpdMenuItem.help = {
8421 title: "Menu item",
8422 summary: 'Single row inside a <wpd-menu>. Supports three looks: plain label, left-aligned dashicon (icon="dashicons-…"), or a checkbox indicator (role="menuitemcheckbox" + checked).',
8423 status: "stable",
8424 since: "0.9.0",
8425 props: [
8426 {
8427 name: "icon",
8428 type: "string (dashicons class)",
8429 description: 'Dashicons class rendered on the left. Ignored when role="menuitemcheckbox".'
8430 },
8431 {
8432 name: "value",
8433 type: "string",
8434 description: "Identifier emitted in wpd-menu-item-click.detail.value."
8435 },
8436 {
8437 name: "checked",
8438 type: "boolean attribute",
8439 description: 'Visible check indicator. Only honoured when role="menuitemcheckbox".'
8440 }
8441 ],
8442 slots: [
8443 { name: "(default)", description: "Menu item label." }
8444 ],
8445 events: [
8446 {
8447 name: "wpd-menu-item-click",
8448 description: "Fires when the item is clicked; bubbles so the <wpd-menu> parent can delegate.",
8449 detail: "{ value: string | null }"
8450 }
8451 ]
8452 };
8453 let WpdMenuItem = _WpdMenuItem;
8454 defineComponent("wpd-menu-item", WpdMenuItem);
8455 function wpdConfirmGlobal$1(options) {
8456 const fn = window.wp?.desktop?.confirm;
8457 if (typeof fn !== "function") {
8458 return Promise.reject(
8459 new Error(
8460 "[desktop-mode] wp.desktop.confirm is missing — the main desktop bundle must load before the posts-window script."
8461 )
8462 );
8463 }
8464 return fn(options);
8465 }
8466 const _introShown = /* @__PURE__ */ Object.create(null);
8467 document.addEventListener("desktop-mode-intros-reset", () => {
8468 for (const slug of Object.keys(_introShown)) {
8469 _introShown[slug] = false;
8470 }
8471 });
8472 function maybeShowIntro(client) {
8473 let cfg;
8474 try {
8475 cfg = client.getConfig();
8476 } catch {
8477 return;
8478 }
8479 const slug = cfg.introSlug || cfg.mode || "posts";
8480 if (_introShown[slug]) {
8481 return;
8482 }
8483 if (cfg.introSeen) {
8484 return;
8485 }
8486 _introShown[slug] = true;
8487 const dialogPromise = slug === "pages" ? Promise.resolve().then(() => pagesIntroDialog).then(
8488 (m) => m.showPagesIntroDialog()
8489 ) : showPostsIntroDialog();
8490 void dialogPromise.then((result) => {
8491 if (result === "cancel") {
8492 _introShown[slug] = false;
8493 return;
8494 }
8495 void markIntroSeen(cfg, slug, client);
8496 if (result === "settings") {
8497 openOsSettingsFeatures();
8498 }
8499 }).catch(() => {
8500 _introShown[slug] = false;
8501 });
8502 }
8503 async function markIntroSeen(cfg, slug, client) {
8504 if (!cfg.introUrl) {
8505 return;
8506 }
8507 try {
8508 await trackedFetch(
8509 cfg.introUrl,
8510 {
8511 method: "POST",
8512 credentials: "same-origin",
8513 headers: {
8514 "Content-Type": "application/json",
8515 "X-WP-Nonce": cfg.restNonce
8516 },
8517 body: JSON.stringify({ slug })
8518 },
8519 {
8520 windowId: client.windowId,
8521 source: `${slug}-window/intro`
8522 }
8523 );
8524 cfg.introSeen = true;
8525 } catch {
8526 }
8527 }
8528 function openOsSettingsFeatures() {
8529 const api = window.wp?.desktop;
8530 api?.openOsSettings?.();
8531 }
8532 const ROOT$1 = "[data-desktop-mode-posts-root]";
8533 const STATUS$1 = "[data-desktop-mode-posts-status]";
8534 const SEARCH$1 = "[data-desktop-mode-posts-search]";
8535 const REFRESH$1 = "[data-desktop-mode-posts-refresh]";
8536 const NEW_BTN$1 = "[data-desktop-mode-posts-new]";
8537 const TABLE$1 = "[data-desktop-mode-posts-table]";
8538 const BULK$1 = "[data-desktop-mode-posts-bulk]";
8539 const COUNT$1 = "[data-desktop-mode-posts-count]";
8540 const PAGE_INDICATOR$1 = "[data-desktop-mode-posts-page-indicator]";
8541 const PREV$1 = "[data-desktop-mode-posts-prev]";
8542 const NEXT$1 = "[data-desktop-mode-posts-next]";
8543 const PER_PAGE$1 = "[data-desktop-mode-posts-per-page]";
8544 const TOOLBAR_TRAILING_EXTRAS = "[data-desktop-mode-posts-toolbar-extras]";
8545 const BULK_ACTIONS_HOST$1 = "[data-desktop-mode-posts-bulk-actions]";
8546 const HOOK_FILTER_COLUMNS = "desktop_mode.postsWindow.columns";
8547 const HOOK_FILTER_STATUS_SEGMENTS = "desktop_mode.postsWindow.statusSegments";
8548 const HOOK_FILTER_BULK_ACTIONS = "desktop_mode.postsWindow.bulkActions";
8549 const HOOK_FILTER_TOOLBAR_TRAILING = "desktop_mode.postsWindow.toolbarTrailing";
8550 const HOOK_ACTION_OPENED = "desktop_mode.postsWindow.opened";
8551 const HOOK_ACTION_DATA_LOADED = "desktop_mode.postsWindow.dataLoaded";
8552 const SEARCH_DEBOUNCE_MS$1 = 250;
8553 const STATUS_LABELS = {
8554 publish: __("Published"),
8555 future: __("Scheduled"),
8556 draft: __("Draft"),
8557 pending: __("Pending"),
8558 private: __("Private"),
8559 trash: __("Trash")
8560 };
8561 function statusBadgeColor(status) {
8562 switch (status) {
8563 case "publish":
8564 return { bg: "#e6f4ea", fg: "#1d6f42" };
8565 case "draft":
8566 return { bg: "#fdecea", fg: "#a02622" };
8567 case "pending":
8568 return { bg: "#fef7e0", fg: "#8a6d00" };
8569 case "private":
8570 return { bg: "#e8f0fe", fg: "#1a52a8" };
8571 case "future":
8572 return { bg: "#ede7f6", fg: "#5b3aa0" };
8573 case "trash":
8574 return { bg: "#f1f1f2", fg: "#50575e" };
8575 default:
8576 return { bg: "#f1f1f2", fg: "#50575e" };
8577 }
8578 }
8579 function decodeTitle(raw) {
8580 const ta = document.createElement("textarea");
8581 ta.innerHTML = raw;
8582 return ta.value;
8583 }
8584 function authorOf(row) {
8585 const embedded = row._embedded?.author?.[0];
8586 if (embedded) {
8587 const avatars = embedded.avatar_urls ?? {};
8588 return {
8589 id: embedded.id,
8590 name: embedded.name,
8591 avatar: avatars["48"] ?? avatars["96"] ?? avatars["24"]
8592 };
8593 }
8594 return { id: row.author, name: __("Unknown") };
8595 }
8596 function termRecordsOf(row, taxonomy) {
8597 const groups = row._embedded?.["wp:term"] ?? [];
8598 for (const group of groups) {
8599 if (group.length === 0) {
8600 continue;
8601 }
8602 if (group[0].taxonomy === taxonomy) {
8603 return group.map((t) => ({ id: t.id, name: t.name }));
8604 }
8605 }
8606 return [];
8607 }
8608 function featuredMediaOf(row) {
8609 const media = row._embedded?.["wp:featuredmedia"]?.[0];
8610 if (!media) {
8611 return null;
8612 }
8613 const sizes = media.media_details?.sizes ?? {};
8614 const small = sizes.thumbnail?.source_url ?? sizes.medium?.source_url ?? media.source_url;
8615 return { url: small, alt: media.alt_text ?? "" };
8616 }
8617 function cacheKey(rowId, columnKey) {
8618 return `${rowId}|${columnKey}`;
8619 }
8620 function memoCell(cache, rowId, columnKey, build) {
8621 const key = cacheKey(rowId, columnKey);
8622 const cached = cache.get(key);
8623 if (cached) {
8624 return cached;
8625 }
8626 const built = build();
8627 cache.set(key, built);
8628 return built;
8629 }
8630 const REQUIRED_COLUMN_KEYS = /* @__PURE__ */ new Set(["title"]);
8631 function getHiddenColumns() {
8632 try {
8633 const api = window.wp?.desktop;
8634 if (api && typeof api.getOsSettings === "function") {
8635 const snap = api.getOsSettings();
8636 if (Array.isArray(snap.nativePostsHiddenColumns)) {
8637 return new Set(snap.nativePostsHiddenColumns);
8638 }
8639 }
8640 } catch {
8641 }
8642 return /* @__PURE__ */ new Set();
8643 }
8644 const EMPTY_FILTER_DATA = { authors: [], tags: [] };
8645 function buildAllColumns(cache, client, filterData = EMPTY_FILTER_DATA) {
8646 const cols = _buildBaseColumns(cache, filterData, client);
8647 const hooks = window.wp?.hooks;
8648 return hooks && typeof hooks.applyFilters === "function" ? hooks.applyFilters(
8649 HOOK_FILTER_COLUMNS,
8650 cols
8651 ) : cols;
8652 }
8653 function buildColumns$1(cache, client, filterData = EMPTY_FILTER_DATA) {
8654 const all = buildAllColumns(cache, client, filterData);
8655 const hidden = getHiddenColumns();
8656 if (hidden.size === 0) {
8657 return all;
8658 }
8659 return all.filter(
8660 (col) => REQUIRED_COLUMN_KEYS.has(col.key) || !hidden.has(col.key)
8661 );
8662 }
8663 function _buildBaseColumns(cache, filterData, client) {
8664 let mode = "posts";
8665 try {
8666 const cfg = client.getConfig();
8667 if (cfg.mode === "pages") {
8668 mode = "pages";
8669 }
8670 } catch {
8671 }
8672 const titleCol = {
8673 key: "title",
8674 label: __("Title"),
8675 sortable: true,
8676 sticky: true,
8677 render: (_v, row) => memoCell(cache, row.id, "title", () => buildTitleCell(row, client))
8678 };
8679 const authorCol = {
8680 key: "author",
8681 label: __("Author"),
8682 sortable: true,
8683 width: "180px",
8684 filterRender: (host, ctx) => renderMultiSelectFilter(host, ctx, filterData.authors, {
8685 label: __("All authors"),
8686 ariaLabel: __("Filter by author")
8687 }),
8688 render: (_v, row) => memoCell(cache, row.id, "author", () => buildAuthorCell(row))
8689 };
8690 const dateCol = {
8691 key: "date",
8692 label: __("Date"),
8693 sortable: true,
8694 width: "170px",
8695 sortValue: (row) => Date.parse(row.date_gmt + "Z") || 0,
8696 render: (_v, row) => memoCell(cache, row.id, "date", () => buildDateCell(row))
8697 };
8698 if (mode === "pages") {
8699 const parentCol = {
8700 key: "parent",
8701 label: __("Parent"),
8702 width: "200px",
8703 render: (_v, row) => memoCell(cache, row.id, "parent", () => buildParentCell(row))
8704 };
8705 const templateCol = {
8706 key: "template",
8707 label: __("Template"),
8708 width: "180px",
8709 render: (_v, row) => memoCell(cache, row.id, "template", () => buildTemplateCell(row, client))
8710 };
8711 const slugCol = {
8712 key: "slug",
8713 label: __("Slug"),
8714 width: "200px",
8715 render: (_v, row) => memoCell(cache, row.id, "slug", () => buildSlugCell(row))
8716 };
8717 const commentsCol = {
8718 key: "comments",
8719 label: __("Comments"),
8720 width: "110px",
8721 sortValue: (row) => typeof row.desktop_mode_comment_count === "number" ? row.desktop_mode_comment_count : 0,
8722 render: (_v, row) => memoCell(
8723 cache,
8724 row.id,
8725 "comments",
8726 () => buildCommentsCell(row)
8727 )
8728 };
8729 return [
8730 titleCol,
8731 authorCol,
8732 parentCol,
8733 templateCol,
8734 slugCol,
8735 commentsCol,
8736 dateCol
8737 ];
8738 }
8739 return [
8740 titleCol,
8741 authorCol,
8742 {
8743 key: "categories",
8744 label: __("Categories"),
8745 width: "260px",
8746 render: (_v, row) => memoCell(
8747 cache,
8748 row.id,
8749 "categories",
8750 () => buildCategoriesCell(row, client)
8751 )
8752 },
8753 {
8754 key: "tags",
8755 // Drop the fixed width so the column flexes with the
8756 // available space; pin a minimum that comfortably holds
8757 // ~4 chips on one line so the cell doesn't collapse the
8758 // tags into a vertical stack on narrow tables.
8759 label: __("Tags"),
8760 minWidth: "360px",
8761 filterRender: (host, ctx) => renderMultiSelectFilter(
8762 host,
8763 ctx,
8764 filterData.tags.map((t) => ({ id: t.id, name: t.name })),
8765 {
8766 label: __("All tags"),
8767 ariaLabel: __("Filter by tag"),
8768 dataKey: "tags",
8769 hasMore: !!filterData.tagsHasMore,
8770 onLoadMore: filterData.loadMoreTags
8771 }
8772 ),
8773 render: (_v, row) => memoCell(cache, row.id, "tags", () => buildTagsCell(row, client))
8774 },
8775 dateCol
8776 ];
8777 }
8778 const _parentTitleByPageRoster = /* @__PURE__ */ new Map();
8779 function buildParentCell(row) {
8780 const cell = document.createElement("span");
8781 cell.className = "desktop-mode-posts__parent";
8782 const pid = typeof row.parent === "number" ? row.parent : 0;
8783 if (pid === 0) {
8784 cell.classList.add("desktop-mode-posts__parent--top");
8785 cell.textContent = "—";
8786 cell.setAttribute("aria-label", __("Top-level page"));
8787 return cell;
8788 }
8789 cell.classList.add("desktop-mode-posts__parent--child");
8790 const titleFromRoster = _parentTitleByPageRoster.get(pid);
8791 if (titleFromRoster) {
8792 cell.textContent = `↳ ${titleFromRoster}`;
8793 } else {
8794 cell.textContent = sprintf(__("↳ #%d"), pid);
8795 }
8796 return cell;
8797 }
8798 function refreshParentTitleRoster(rows) {
8799 _parentTitleByPageRoster.clear();
8800 for (const row of rows) {
8801 _parentTitleByPageRoster.set(row.id, decodeTitle(row.title.rendered));
8802 }
8803 }
8804 function buildTemplateCell(row, client) {
8805 const cell = document.createElement("span");
8806 cell.className = "desktop-mode-posts__template";
8807 const slug = typeof row.template === "string" ? row.template : "";
8808 let label = slug;
8809 try {
8810 const cfg = client.getConfig();
8811 const map = cfg.pageTemplates ?? {};
8812 label = map[slug] ?? (slug === "" ? __("Default template") : slug);
8813 } catch {
8814 label = slug === "" ? __("Default template") : slug;
8815 }
8816 cell.textContent = label;
8817 if (slug !== "") {
8818 cell.title = slug;
8819 }
8820 return cell;
8821 }
8822 function buildSlugCell(row) {
8823 const cell = document.createElement("button");
8824 cell.type = "button";
8825 cell.className = "desktop-mode-posts__slug";
8826 const slug = typeof row.slug === "string" ? row.slug : "";
8827 cell.textContent = slug || "—";
8828 cell.disabled = slug === "";
8829 cell.title = slug ? __("Click to copy slug") : "";
8830 Object.assign(cell.style, {
8831 appearance: "none",
8832 background: "transparent",
8833 border: "none",
8834 padding: "2px 6px",
8835 font: "inherit",
8836 color: "inherit",
8837 cursor: slug ? "copy" : "default",
8838 textAlign: "left",
8839 fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
8840 fontSize: "12px",
8841 borderRadius: "4px",
8842 maxWidth: "100%",
8843 overflow: "hidden",
8844 textOverflow: "ellipsis",
8845 whiteSpace: "nowrap"
8846 });
8847 cell.addEventListener("click", (e) => {
8848 e.stopPropagation();
8849 if (!slug) {
8850 return;
8851 }
8852 void navigator.clipboard?.writeText(slug).then(() => {
8853 cell.textContent = __("Copied!");
8854 cell.style.color = "var(--wp-admin-theme-color, #2271b1)";
8855 setTimeout(() => {
8856 cell.textContent = slug;
8857 cell.style.color = "";
8858 }, 1200);
8859 }).catch(() => {
8860 });
8861 });
8862 return cell;
8863 }
8864 function buildCommentsCell(row) {
8865 const cell = document.createElement("span");
8866 cell.className = "desktop-mode-posts__comments";
8867 Object.assign(cell.style, {
8868 display: "inline-flex",
8869 alignItems: "center",
8870 gap: "6px",
8871 fontVariantNumeric: "tabular-nums"
8872 });
8873 const count = typeof row.desktop_mode_comment_count === "number" ? row.desktop_mode_comment_count : null;
8874 if (count === null) {
8875 cell.textContent = "—";
8876 cell.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
8877 return cell;
8878 }
8879 const icon = document.createElement("span");
8880 icon.className = "dashicons dashicons-admin-comments";
8881 icon.setAttribute("aria-hidden", "true");
8882 Object.assign(icon.style, {
8883 fontSize: "16px",
8884 width: "16px",
8885 height: "16px",
8886 color: count > 0 ? "var(--wp-admin-theme-color, #2271b1)" : "var(--wp-admin-theme-fg-muted, #8c8f94)"
8887 });
8888 const label = document.createElement("span");
8889 label.textContent = String(count);
8890 if (count === 0) {
8891 label.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
8892 }
8893 cell.appendChild(icon);
8894 cell.appendChild(label);
8895 cell.setAttribute(
8896 "aria-label",
8897 // translators: %d is the comment count for a row.
8898 `${sprintf(_n("%d comment", "%d comments", count), count)}`
8899 );
8900 return cell;
8901 }
8902 function renderMultiSelectFilter(host, ctx, all, opts) {
8903 const HOST_KEY = "wpdPostsFilterMounted";
8904 const tagged = host;
8905 const optionsForPicker = all.map((o) => ({
8906 value: String(o.id),
8907 label: o.name
8908 }));
8909 const nextSig = optionsForPicker.map((o) => `${o.value}:${o.label}`).join("|");
8910 if (tagged[HOST_KEY]) {
8911 const state = tagged[HOST_KEY];
8912 if (state.listSig !== nextSig) {
8913 state.picker.items = optionsForPicker;
8914 state.listSig = nextSig;
8915 }
8916 if (state.picker.getAttribute("value") !== ctx.value) {
8917 state.picker.setAttribute("value", ctx.value);
8918 }
8919 state.picker.hasMore = !!opts.hasMore;
8920 return;
8921 }
8922 const picker = document.createElement("wpd-multiselect");
8923 picker.setAttribute("placeholder", opts.label);
8924 picker.setAttribute("aria-label", opts.ariaLabel);
8925 picker.setAttribute("data-noclick", "");
8926 picker.setAttribute("value", ctx.value);
8927 if (opts.dataKey) {
8928 picker.setAttribute("data-key", opts.dataKey);
8929 }
8930 host.appendChild(picker);
8931 picker.items = optionsForPicker;
8932 picker.hasMore = !!opts.hasMore;
8933 picker.addEventListener("wpd-pick", (e) => {
8934 const detail = e.detail;
8935 const next = detail?.value ?? "";
8936 ctx.value = next;
8937 ctx.setValue(next);
8938 });
8939 if (opts.onLoadMore) {
8940 const onLoadMore = opts.onLoadMore;
8941 picker.addEventListener("wpd-multiselect-load-more", () => {
8942 picker.loadingMore = true;
8943 onLoadMore();
8944 });
8945 }
8946 tagged[HOST_KEY] = { picker, listSig: nextSig };
8947 }
8948 function mountKebabColumnToggles(body, cache, repaintColumns, client) {
8949 const winEl = body.closest(".desktop-mode-window");
8950 const panel = winEl?.querySelector(
8951 ".desktop-mode-window__menu-panel"
8952 );
8953 if (!panel) {
8954 return null;
8955 }
8956 const SECTION_CLASS = "desktop-mode-posts-window__menu-columns";
8957 const ITEM_CLASS = "desktop-mode-posts-window__menu-column-item";
8958 const VALUE_PREFIX = "desktop-mode-posts-column:";
8959 panel.querySelectorAll(`.${SECTION_CLASS}, .${ITEM_CLASS}`).forEach((n) => n.remove());
8960 const allCols = buildAllColumns(cache, client);
8961 const togglable = allCols.filter(
8962 (c) => !REQUIRED_COLUMN_KEYS.has(c.key)
8963 );
8964 if (togglable.length === 0) {
8965 return null;
8966 }
8967 const sectionLabel = document.createElement("div");
8968 sectionLabel.className = SECTION_CLASS;
8969 sectionLabel.setAttribute("role", "presentation");
8970 sectionLabel.textContent = __("Show columns");
8971 panel.appendChild(sectionLabel);
8972 const itemEls = /* @__PURE__ */ new Map();
8973 for (const col of togglable) {
8974 const item = document.createElement("wpd-menu-item");
8975 item.setAttribute("role", "menuitemcheckbox");
8976 item.setAttribute("value", VALUE_PREFIX + col.key);
8977 item.classList.add("desktop-mode-window__menu-item");
8978 item.classList.add(ITEM_CLASS);
8979 item.textContent = col.label || col.key;
8980 panel.appendChild(item);
8981 itemEls.set(col.key, item);
8982 }
8983 const paintChecked = () => {
8984 const hidden = getHiddenColumns();
8985 for (const [key, el] of itemEls) {
8986 if (hidden.has(key)) {
8987 el.removeAttribute("checked");
8988 } else {
8989 el.setAttribute("checked", "");
8990 }
8991 }
8992 };
8993 paintChecked();
8994 const onClick = (e) => {
8995 const detail = e.detail;
8996 const value = detail?.value;
8997 if (typeof value !== "string" || !value.startsWith(VALUE_PREFIX)) {
8998 return;
8999 }
9000 const key = value.slice(VALUE_PREFIX.length);
9001 if (!itemEls.has(key) || REQUIRED_COLUMN_KEYS.has(key)) {
9002 return;
9003 }
9004 const hidden = getHiddenColumns();
9005 if (hidden.has(key)) {
9006 hidden.delete(key);
9007 } else {
9008 hidden.add(key);
9009 }
9010 const next = Array.from(hidden).sort();
9011 const api = window.wp?.desktop;
9012 if (api && typeof api.updateOsSettings === "function") {
9013 api.updateOsSettings(
9014 { nativePostsHiddenColumns: next },
9015 { windowId: "desktop-mode-posts" }
9016 );
9017 }
9018 paintChecked();
9019 repaintColumns();
9020 };
9021 panel.addEventListener("wpd-menu-item-click", onClick);
9022 return {
9023 refresh: paintChecked,
9024 dispose: () => {
9025 panel.removeEventListener("wpd-menu-item-click", onClick);
9026 sectionLabel.remove();
9027 for (const el of itemEls.values()) {
9028 el.remove();
9029 }
9030 itemEls.clear();
9031 }
9032 };
9033 }
9034 function defaultStatusSegments$1() {
9035 return [
9036 { value: "", label: __("All") },
9037 { value: "publish", label: __("Published") },
9038 { value: "draft", label: __("Drafts") },
9039 { value: "pending", label: __("Pending") },
9040 { value: "future", label: __("Scheduled") },
9041 { value: "trash", label: __("Trash") }
9042 ];
9043 }
9044 function defaultBulkActions(client) {
9045 return [
9046 {
9047 id: "trash",
9048 label: __("Move to trash"),
9049 icon: "dashicons-trash",
9050 variant: "danger",
9051 /* translators: %d: row count. */
9052 confirm: __("Move %d post(s) to the trash?"),
9053 run: async (ids, ctx) => {
9054 const data = ctx.table.data ?? [];
9055 const trashable = ids.filter((id) => {
9056 const row = data.find((r) => r.id === id);
9057 return row && row.status !== "trash";
9058 });
9059 if (trashable.length === 0) {
9060 return;
9061 }
9062 const results = await Promise.all(
9063 trashable.map((id) => client.trashPost(id))
9064 );
9065 const errors = results.filter((r) => !r.ok);
9066 if (errors.length > 0) {
9067 console.error("[posts-window] some trashes failed", errors);
9068 }
9069 const okIds = results.filter((r) => r.ok).map((r) => r.id);
9070 const api = window.wp?.desktop;
9071 if (api && typeof api.broadcast === "function") {
9072 api.broadcast("desktop-mode.post.changed", {
9073 source: "posts-window",
9074 action: "trashed",
9075 ids: okIds
9076 });
9077 }
9078 }
9079 }
9080 ];
9081 }
9082 function resolveBulkActions(client) {
9083 const hooks = window.wp?.hooks;
9084 const defaults = defaultBulkActions(client);
9085 if (!hooks || typeof hooks.applyFilters !== "function") {
9086 return defaults;
9087 }
9088 try {
9089 const out = hooks.applyFilters(HOOK_FILTER_BULK_ACTIONS, defaults);
9090 return Array.isArray(out) ? out : defaults;
9091 } catch (err) {
9092 console.error(
9093 "[posts-window] bulk-actions filter threw; falling back to defaults:",
9094 err
9095 );
9096 return defaults;
9097 }
9098 }
9099 function resolveStatusSegments() {
9100 const hooks = window.wp?.hooks;
9101 const defaults = defaultStatusSegments$1();
9102 if (!hooks || typeof hooks.applyFilters !== "function") {
9103 return defaults;
9104 }
9105 try {
9106 const out = hooks.applyFilters(HOOK_FILTER_STATUS_SEGMENTS, defaults);
9107 return Array.isArray(out) && out.length > 0 ? out : defaults;
9108 } catch (err) {
9109 console.error(
9110 "[posts-window] status-segments filter threw; falling back to defaults:",
9111 err
9112 );
9113 return defaults;
9114 }
9115 }
9116 function resolveToolbarTrailing(ctx) {
9117 const hooks = window.wp?.hooks;
9118 if (!hooks || typeof hooks.applyFilters !== "function") {
9119 return [];
9120 }
9121 try {
9122 const out = hooks.applyFilters(HOOK_FILTER_TOOLBAR_TRAILING, [], ctx);
9123 if (!Array.isArray(out)) {
9124 return [];
9125 }
9126 return out.filter((el) => el instanceof HTMLElement);
9127 } catch (err) {
9128 console.error(
9129 "[posts-window] toolbar-trailing filter threw; ignoring:",
9130 err
9131 );
9132 return [];
9133 }
9134 }
9135 function buildTitleCell(row, client) {
9136 const cell = document.createElement("span");
9137 cell.style.cssText = "display:flex;flex-direction:column;gap:4px;min-width:0;";
9138 const titleRow = document.createElement("span");
9139 titleRow.style.cssText = "display:flex;align-items:center;gap:8px;min-width:0;";
9140 const link = document.createElement("a");
9141 link.href = client.buildEditPostUrl(row.id);
9142 link.setAttribute("data-noclick", "");
9143 const title = decodeTitle(row.title.rendered) || __("(no title)");
9144 link.textContent = title;
9145 link.title = title;
9146 link.style.cssText = "font-weight:600;color:inherit;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:340px;";
9147 link.addEventListener("mouseenter", () => {
9148 link.style.textDecoration = "underline";
9149 });
9150 link.addEventListener("mouseleave", () => {
9151 link.style.textDecoration = "none";
9152 });
9153 link.addEventListener("click", (e) => {
9154 e.preventDefault();
9155 e.stopPropagation();
9156 openAdminUrl(link.href, {
9157 title,
9158 icon: "dashicons-admin-post"
9159 });
9160 });
9161 titleRow.appendChild(link);
9162 const lock = row.desktop_mode_lock ?? null;
9163 if (lock) {
9164 const lockBadge = document.createElement("span");
9165 lockBadge.style.cssText = [
9166 "display:inline-flex",
9167 "align-items:center",
9168 "gap:4px",
9169 "padding:2px 8px",
9170 "border-radius:10px",
9171 "font-size:11px",
9172 "font-weight:600",
9173 "background:rgba(179, 45, 46, 0.1)",
9174 "color:#b32d2e",
9175 "white-space:nowrap",
9176 "flex-shrink:0"
9177 ].join(";");
9178 const lockIcon = document.createElement("span");
9179 lockIcon.setAttribute("aria-hidden", "true");
9180 lockIcon.style.cssText = [
9181 "font-family:dashicons",
9182 "font-size:14px",
9183 "line-height:1",
9184 "display:inline-block",
9185 "speak:none",
9186 "-webkit-font-smoothing:antialiased"
9187 ].join(";");
9188 lockIcon.textContent = "";
9189 lockBadge.appendChild(lockIcon);
9190 const lockText = document.createElement("span");
9191 lockText.textContent = lock.userName;
9192 lockBadge.appendChild(lockText);
9193 const tipFmt = __("%s is currently editing", "desktop-mode");
9194 lockBadge.title = sprintf(tipFmt, lock.userName);
9195 titleRow.appendChild(lockBadge);
9196 }
9197 let cfgForBadges = null;
9198 try {
9199 cfgForBadges = client.getConfig();
9200 } catch {
9201 cfgForBadges = null;
9202 }
9203 if (cfgForBadges && cfgForBadges.mode === "pages") {
9204 if (typeof cfgForBadges.frontPageId === "number" && cfgForBadges.frontPageId === row.id) {
9205 titleRow.appendChild(
9206 buildAssignmentBadge(
9207 __("Front page"),
9208 "dashicons-admin-home",
9209 "#0a4b78",
9210 "rgba(34,113,177,0.12)"
9211 )
9212 );
9213 }
9214 if (typeof cfgForBadges.postsPageId === "number" && cfgForBadges.postsPageId === row.id) {
9215 titleRow.appendChild(
9216 buildAssignmentBadge(
9217 __("Posts page"),
9218 "dashicons-admin-post",
9219 "#5b3aa0",
9220 "rgba(91,58,160,0.12)"
9221 )
9222 );
9223 }
9224 }
9225 if (row.status && row.status !== "publish") {
9226 const badge = document.createElement("span");
9227 const colors = statusBadgeColor(row.status);
9228 badge.textContent = STATUS_LABELS[row.status] ?? row.status;
9229 badge.style.cssText = [
9230 "display:inline-flex",
9231 "align-items:center",
9232 "padding:2px 8px",
9233 "border-radius:10px",
9234 "font-size:11px",
9235 "font-weight:600",
9236 "text-transform:uppercase",
9237 "letter-spacing:0.04em",
9238 `background:${colors.bg}`,
9239 `color:${colors.fg}`,
9240 "white-space:nowrap",
9241 "flex-shrink:0"
9242 ].join(";");
9243 titleRow.appendChild(badge);
9244 }
9245 if (cfgForBadges?.mode === "pages" && typeof row.link === "string" && row.link && row.status === "publish") {
9246 const view = document.createElement("a");
9247 view.href = row.link;
9248 view.target = "_blank";
9249 view.rel = "noreferrer noopener";
9250 view.textContent = __("View");
9251 view.title = row.link;
9252 view.setAttribute("data-noclick", "");
9253 view.style.cssText = [
9254 "font-size:11px",
9255 "color:var(--wp-admin-theme-color, #2271b1)",
9256 "text-decoration:none",
9257 "flex-shrink:0"
9258 ].join(";");
9259 view.addEventListener("click", (e) => e.stopPropagation());
9260 view.addEventListener("mouseenter", () => {
9261 view.style.textDecoration = "underline";
9262 });
9263 view.addEventListener("mouseleave", () => {
9264 view.style.textDecoration = "none";
9265 });
9266 titleRow.appendChild(view);
9267 }
9268 cell.appendChild(titleRow);
9269 return cell;
9270 }
9271 function buildAssignmentBadge(label, dashicon, fg, bg) {
9272 const badge = document.createElement("span");
9273 badge.style.cssText = [
9274 "display:inline-flex",
9275 "align-items:center",
9276 "gap:4px",
9277 "padding:2px 8px",
9278 "border-radius:10px",
9279 "font-size:11px",
9280 "font-weight:600",
9281 `background:${bg}`,
9282 `color:${fg}`,
9283 "white-space:nowrap",
9284 "flex-shrink:0"
9285 ].join(";");
9286 const icon = document.createElement("span");
9287 icon.className = `dashicons ${dashicon}`;
9288 icon.setAttribute("aria-hidden", "true");
9289 icon.style.cssText = "font-size:13px;width:13px;height:13px;line-height:1;";
9290 const text = document.createElement("span");
9291 text.textContent = label;
9292 badge.appendChild(icon);
9293 badge.appendChild(text);
9294 return badge;
9295 }
9296 function buildAuthorCell(row) {
9297 const a = authorOf(row);
9298 const wrap = document.createElement("span");
9299 wrap.style.cssText = "display:inline-flex;align-items:center;gap:8px;min-width:0;";
9300 const avatar = document.createElement("wpd-avatar");
9301 avatar.setAttribute("size", "24");
9302 if (a.name) {
9303 avatar.setAttribute("name", a.name);
9304 }
9305 if (a.id > 0) {
9306 avatar.setAttribute("user-id", String(a.id));
9307 }
9308 if (a.avatar) {
9309 applyAvatarSrc(avatar, a.avatar);
9310 }
9311 wrap.appendChild(avatar);
9312 const name = document.createElement("span");
9313 name.textContent = a.name;
9314 name.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
9315 wrap.appendChild(name);
9316 return wrap;
9317 }
9318 function buildTagsCell(row, client) {
9319 const wrap = document.createElement("span");
9320 wrap.style.cssText = "display:inline-flex;align-items:center;width:100%;min-width:0;";
9321 const picker = document.createElement("wpd-tag-input");
9322 picker.setAttribute("creatable", "");
9323 picker.setAttribute("removable", "");
9324 picker.setAttribute("min-query", "0");
9325 picker.setAttribute("placeholder", __("Add tag…"));
9326 picker.setAttribute("add-label", __("Tag"));
9327 picker.setAttribute("data-noclick", "");
9328 const seed = termRecordsOf(row, "post_tag").map((t) => ({
9329 id: t.id,
9330 label: t.name
9331 }));
9332 picker.value = seed;
9333 const cellState = {
9334 // Mirror of `picker.value` we mutate optimistically. Keeping
9335 // it here (rather than reading back from the picker) avoids
9336 // double-source-of-truth bugs when two events fire in the
9337 // same tick.
9338 tags: seed.slice(),
9339 // AbortController for the in-flight suggest fetch.
9340 suggestAbort: null,
9341 suggestDebounce: null,
9342 // Last query the user typed — used to drop stale responses
9343 // even after AbortController has fired.
9344 lastQuery: ""
9345 };
9346 const setValue = (next) => {
9347 cellState.tags = next.slice();
9348 picker.value = next;
9349 };
9350 picker.addEventListener("wpd-tag-suggest", (e) => {
9351 const detail = e.detail;
9352 const query = detail?.query ?? "";
9353 cellState.lastQuery = query;
9354 if (cellState.suggestDebounce !== null) {
9355 window.clearTimeout(cellState.suggestDebounce);
9356 cellState.suggestDebounce = null;
9357 }
9358 cellState.suggestDebounce = window.setTimeout(async () => {
9359 cellState.suggestDebounce = null;
9360 if (cellState.suggestAbort) {
9361 cellState.suggestAbort.abort();
9362 }
9363 const ac = new AbortController();
9364 cellState.suggestAbort = ac;
9365 try {
9366 const matches = await client.searchTags(query, ac.signal);
9367 if (cellState.lastQuery !== query) {
9368 return;
9369 }
9370 const existingIds = new Set(cellState.tags.map((t) => t.id));
9371 picker.suggestions = matches.filter((m) => !existingIds.has(m.id)).map((m) => ({ id: m.id, label: m.name }));
9372 } catch (err) {
9373 if (err?.name === "AbortError") {
9374 return;
9375 }
9376 picker.suggestions = [];
9377 console.warn(
9378 "[posts-window] tag search failed",
9379 err
9380 );
9381 } finally {
9382 picker.suggestionsLoading = false;
9383 }
9384 }, 200);
9385 });
9386 picker.addEventListener("wpd-tag-add", async (e) => {
9387 const detail = e.detail;
9388 if (!detail?.tag) {
9389 return;
9390 }
9391 const optimistic = {
9392 id: detail.tag.id,
9393 label: detail.tag.label,
9394 pending: true
9395 };
9396 const next = [...cellState.tags, optimistic];
9397 setValue(next);
9398 try {
9399 let resolvedTag = null;
9400 if (detail.isNew || typeof detail.tag.id !== "number") {
9401 resolvedTag = await client.createTag(detail.tag.label);
9402 } else {
9403 resolvedTag = {
9404 id: Number(detail.tag.id),
9405 name: detail.tag.label,
9406 slug: ""
9407 };
9408 }
9409 const desiredIds = [
9410 ...cellState.tags.filter((t) => !t.pending).map((t) => Number(t.id)),
9411 resolvedTag.id
9412 ];
9413 await client.updatePostTags(row.id, desiredIds);
9414 setValue(
9415 cellState.tags.map((t) => {
9416 if (t.label.toLowerCase() === detail.tag.label.toLowerCase()) {
9417 return {
9418 id: resolvedTag.id,
9419 label: resolvedTag.name
9420 };
9421 }
9422 return t;
9423 })
9424 );
9425 const api = window.wp?.desktop;
9426 if (api && typeof api.broadcast === "function") {
9427 api.broadcast("desktop-mode.post.changed", {
9428 source: "posts-window",
9429 action: "tagged",
9430 ids: [row.id]
9431 });
9432 }
9433 } catch (err) {
9434 setValue(
9435 cellState.tags.filter(
9436 (t) => t.label.toLowerCase() !== detail.tag.label.toLowerCase()
9437 )
9438 );
9439 showTagError(
9440 sprintf(
9441 /* translators: %s: tag label */
9442 __('Couldn’t add tag "%s".'),
9443 detail.tag.label
9444 ),
9445 err
9446 );
9447 }
9448 });
9449 picker.addEventListener("wpd-tag-remove", async (e) => {
9450 const detail = e.detail;
9451 if (!detail?.tag) {
9452 return;
9453 }
9454 const removed = detail.tag;
9455 const previous = cellState.tags.slice();
9456 setValue(
9457 cellState.tags.map(
9458 (t) => t.label === removed.label ? { ...t, pending: true } : t
9459 )
9460 );
9461 try {
9462 const desiredIds = previous.filter((t) => t.label !== removed.label).map((t) => Number(t.id)).filter((n) => Number.isFinite(n));
9463 await client.updatePostTags(row.id, desiredIds);
9464 setValue(
9465 previous.filter((t) => t.label !== removed.label)
9466 );
9467 const api = window.wp?.desktop;
9468 if (api && typeof api.broadcast === "function") {
9469 api.broadcast("desktop-mode.post.changed", {
9470 source: "posts-window",
9471 action: "untagged",
9472 ids: [row.id]
9473 });
9474 }
9475 } catch (err) {
9476 setValue(previous);
9477 showTagError(
9478 sprintf(
9479 /* translators: %s: tag label */
9480 __('Couldn’t remove tag "%s".'),
9481 removed.label
9482 ),
9483 err
9484 );
9485 }
9486 });
9487 wrap.appendChild(picker);
9488 return wrap;
9489 }
9490 function showTagError(title, err) {
9491 const reason = err instanceof Error ? err.message : String(err);
9492 const api = window.wp?.desktop;
9493 if (api && typeof api.showToast === "function") {
9494 api.showToast({
9495 message: `${title} ${reason}`.trim(),
9496 duration: 6e3
9497 });
9498 return;
9499 }
9500 console.error(title, err);
9501 }
9502 function buildCategoriesCell(row, client) {
9503 const wrap = document.createElement("span");
9504 wrap.className = "wpd-cat-cell-dropzone";
9505 wrap.style.cssText = "display:inline-flex;align-items:center;width:100%;min-width:0;border-radius:6px;transition:background-color 0.12s ease, box-shadow 0.12s ease;";
9506 const picker = document.createElement(
9507 "wpd-category-picker"
9508 );
9509 picker.setAttribute("placeholder", __("Search categories…"));
9510 picker.setAttribute("add-label", __("Categorize"));
9511 picker.setAttribute("data-noclick", "");
9512 _activePickers.add(picker);
9513 picker.value = row.categories ?? [];
9514 const seedItems = termRecordsOf(row, "category").map(
9515 (t) => ({ id: t.id, name: t.name, parent: 0 })
9516 );
9517 picker.items = seedItems;
9518 const cellState = {
9519 categoryIds: (row.categories ?? []).slice()
9520 };
9521 const setValue = (next) => {
9522 cellState.categoryIds = next.slice();
9523 picker.value = next;
9524 };
9525 void getCategoriesTree(client).then((tree) => {
9526 if (!picker.isConnected) {
9527 return;
9528 }
9529 picker.items = tree;
9530 }).catch((err) => {
9531 console.warn("[posts-window] category tree fetch failed", err);
9532 });
9533 picker.addEventListener("wpd-categories-open", () => {
9534 void primePickerFromCache(picker);
9535 });
9536 picker.addEventListener(
9537 "wpd-categories-create",
9538 async (e) => {
9539 const detail = e.detail;
9540 const parent = detail?.parent ?? 0;
9541 if (!detail || !detail.name) {
9542 picker.failCreating(parent);
9543 return;
9544 }
9545 try {
9546 const created = await client.createCategory(detail.name, parent);
9547 _categoryTreePromise = null;
9548 const nextItems = [
9549 ...picker.items,
9550 {
9551 id: created.id,
9552 name: created.name,
9553 parent: created.parent
9554 }
9555 ];
9556 picker.items = nextItems;
9557 const nextValue = [...cellState.categoryIds, created.id];
9558 setValue(nextValue);
9559 picker.endCreating(parent);
9560 try {
9561 await client.updatePostCategories(row.id, nextValue);
9562 const api = window.wp?.desktop;
9563 if (api && typeof api.broadcast === "function") {
9564 api.broadcast("desktop-mode.post.changed", {
9565 source: "posts-window",
9566 action: "categorized",
9567 ids: [row.id]
9568 });
9569 }
9570 } catch (err) {
9571 setValue(cellState.categoryIds.filter((id) => id !== created.id));
9572 showTagError(__("Couldn’t assign new category."), err);
9573 }
9574 } catch (err) {
9575 picker.failCreating(
9576 parent,
9577 err instanceof Error ? err.message : String(err)
9578 );
9579 showTagError(__("Couldn’t create category."), err);
9580 }
9581 }
9582 );
9583 picker.addEventListener("wpd-categories-change", async (e) => {
9584 const detail = e.detail;
9585 if (!detail || !Array.isArray(detail.value)) {
9586 return;
9587 }
9588 const previous = cellState.categoryIds.slice();
9589 const next = detail.value.slice();
9590 setValue(next);
9591 try {
9592 await client.updatePostCategories(row.id, next);
9593 const api = window.wp?.desktop;
9594 if (api && typeof api.broadcast === "function") {
9595 api.broadcast("desktop-mode.post.changed", {
9596 source: "posts-window",
9597 action: "categorized",
9598 ids: [row.id]
9599 });
9600 }
9601 } catch (err) {
9602 setValue(previous);
9603 showTagError(__("Couldn’t update categories."), err);
9604 }
9605 });
9606 picker.addEventListener("wpd-categories-delete", async (e) => {
9607 const detail = e.detail;
9608 if (!detail || typeof detail.id !== "number") {
9609 return;
9610 }
9611 const ok = await wpdConfirmGlobal$1({
9612 title: __("Delete category?"),
9613 message: sprintf(
9614 /* translators: %s: category name. */
9615 __(
9616 'Delete the category "%s"? Posts assigned only to it will fall back to Uncategorized.'
9617 ),
9618 detail.name
9619 ),
9620 confirmLabel: __("Delete"),
9621 danger: true
9622 });
9623 if (!ok) {
9624 return;
9625 }
9626 try {
9627 await client.deleteTerm("categories", detail.id);
9628 if (cellState.categoryIds.includes(detail.id)) {
9629 const next = cellState.categoryIds.filter(
9630 (id) => id !== detail.id
9631 );
9632 setValue(next);
9633 try {
9634 await client.updatePostCategories(row.id, next);
9635 } catch (err) {
9636 showTagError(
9637 __("Couldn’t update post categories after delete."),
9638 err
9639 );
9640 }
9641 }
9642 } catch (err) {
9643 showTagError(__("Couldn’t delete category."), err);
9644 }
9645 });
9646 picker.addEventListener("wpd-chain-segment-dragstart", (e) => {
9647 const detail = e.detail;
9648 if (!detail || !detail.dragEvent || !detail.dragEvent.dataTransfer) {
9649 return;
9650 }
9651 const ids = [];
9652 for (const seg of detail.segments) {
9653 if (typeof seg.id === "number") {
9654 ids.push(seg.id);
9655 }
9656 }
9657 if (ids.length === 0) {
9658 return;
9659 }
9660 const dt = detail.dragEvent.dataTransfer;
9661 dt.setData(
9662 "application/x-desktop-mode-categories",
9663 JSON.stringify({
9664 ids,
9665 source: "posts-window",
9666 sourcePostId: row.id
9667 })
9668 );
9669 dt.setData("text/plain", ids.join(","));
9670 dt.effectAllowed = "copy";
9671 });
9672 let dropEnterCount = 0;
9673 const setDropTargetActive = (on) => {
9674 if (on) {
9675 wrap.style.backgroundColor = "color-mix(in srgb, var(--wp-admin-theme-color, #2271b1) 12%, transparent)";
9676 wrap.style.boxShadow = "inset 0 0 0 2px var(--wp-admin-theme-color, #2271b1)";
9677 } else {
9678 wrap.style.backgroundColor = "";
9679 wrap.style.boxShadow = "";
9680 }
9681 };
9682 const acceptsCategoriesDrag = (e) => {
9683 const types = e.dataTransfer?.types;
9684 if (!types) {
9685 return false;
9686 }
9687 return Array.from(types).includes(
9688 "application/x-desktop-mode-categories"
9689 );
9690 };
9691 wrap.addEventListener("dragenter", (e) => {
9692 if (!acceptsCategoriesDrag(e)) {
9693 return;
9694 }
9695 e.preventDefault();
9696 dropEnterCount++;
9697 setDropTargetActive(true);
9698 });
9699 wrap.addEventListener("dragover", (e) => {
9700 if (!acceptsCategoriesDrag(e)) {
9701 return;
9702 }
9703 e.preventDefault();
9704 if (e.dataTransfer) {
9705 e.dataTransfer.dropEffect = "copy";
9706 }
9707 });
9708 wrap.addEventListener("dragleave", () => {
9709 if (dropEnterCount > 0) {
9710 dropEnterCount--;
9711 }
9712 if (dropEnterCount === 0) {
9713 setDropTargetActive(false);
9714 }
9715 });
9716 wrap.addEventListener("drop", async (e) => {
9717 dropEnterCount = 0;
9718 setDropTargetActive(false);
9719 if (!acceptsCategoriesDrag(e)) {
9720 return;
9721 }
9722 e.preventDefault();
9723 const json = e.dataTransfer?.getData(
9724 "application/x-desktop-mode-categories"
9725 );
9726 if (!json) {
9727 return;
9728 }
9729 let parsed;
9730 try {
9731 parsed = JSON.parse(json);
9732 } catch {
9733 return;
9734 }
9735 const payload = parsed;
9736 if (!payload || !Array.isArray(payload.ids)) {
9737 return;
9738 }
9739 const incoming = [];
9740 for (const v of payload.ids) {
9741 if (typeof v === "number" && Number.isFinite(v)) {
9742 incoming.push(v);
9743 }
9744 }
9745 if (incoming.length === 0) {
9746 return;
9747 }
9748 if (payload.sourcePostId === row.id && incoming.every((id) => cellState.categoryIds.includes(id))) {
9749 return;
9750 }
9751 const merged = Array.from(
9752 /* @__PURE__ */ new Set([...cellState.categoryIds, ...incoming])
9753 );
9754 if (merged.length === cellState.categoryIds.length) {
9755 return;
9756 }
9757 const previous = cellState.categoryIds.slice();
9758 setValue(merged);
9759 try {
9760 await client.updatePostCategories(row.id, merged);
9761 const api = window.wp?.desktop;
9762 if (api && typeof api.broadcast === "function") {
9763 api.broadcast("desktop-mode.post.changed", {
9764 source: "posts-window",
9765 action: "categorized",
9766 ids: [row.id]
9767 });
9768 }
9769 } catch (err) {
9770 setValue(previous);
9771 showTagError(__("Couldn’t add category."), err);
9772 }
9773 });
9774 wrap.appendChild(picker);
9775 return wrap;
9776 }
9777 let _categoryTreePromise = null;
9778 function getCategoriesTree(client) {
9779 if (!_categoryTreePromise) {
9780 _categoryTreePromise = client.fetchAllCategories().then(
9781 (terms) => terms.map((t) => ({
9782 id: t.id,
9783 name: t.name,
9784 parent: t.parent
9785 }))
9786 );
9787 }
9788 return _categoryTreePromise;
9789 }
9790 function clearCategoryTreeCache() {
9791 _categoryTreePromise = null;
9792 }
9793 const _activePickers = /* @__PURE__ */ new Set();
9794 function broadcastFreshCategoryTreeToPickers(client) {
9795 void getCategoriesTree(client).then((tree) => {
9796 for (const picker of _activePickers) {
9797 if (picker.isConnected) {
9798 picker.items = tree;
9799 } else {
9800 _activePickers.delete(picker);
9801 }
9802 }
9803 }).catch(() => {
9804 });
9805 }
9806 async function primePickerFromCache(picker) {
9807 if (!_categoryTreePromise) {
9808 return;
9809 }
9810 try {
9811 picker.items = await _categoryTreePromise;
9812 } catch {
9813 }
9814 }
9815 function buildDateCell(row) {
9816 const wrap = document.createElement("span");
9817 wrap.style.cssText = "display:flex;flex-direction:column;line-height:1.2;";
9818 const time = document.createElement("wpd-relative-time");
9819 time.setAttribute("datetime", row.date);
9820 wrap.appendChild(time);
9821 if (row.modified_gmt && row.modified_gmt !== row.date_gmt) {
9822 const meta = document.createElement("span");
9823 meta.textContent = __("modified");
9824 meta.style.cssText = "font-size:11px;color:#646970;";
9825 wrap.appendChild(meta);
9826 }
9827 return wrap;
9828 }
9829 function buildSubRow(row) {
9830 const wrap = document.createElement("div");
9831 wrap.style.cssText = "display:flex;gap:16px;padding:12px 16px;background:#fafafa;align-items:flex-start;";
9832 const featured = featuredMediaOf(row);
9833 if (featured) {
9834 const img = document.createElement("img");
9835 img.src = featured.url;
9836 img.alt = featured.alt;
9837 img.loading = "lazy";
9838 img.style.cssText = "width:96px;height:96px;border-radius:6px;object-fit:cover;flex-shrink:0;";
9839 wrap.appendChild(img);
9840 }
9841 const text = document.createElement("div");
9842 text.style.cssText = "flex:1;min-width:0;display:flex;flex-direction:column;gap:6px;";
9843 const heading = document.createElement("div");
9844 heading.style.cssText = "font-size:13px;color:#646970;text-transform:uppercase;letter-spacing:0.04em;";
9845 heading.textContent = __("Excerpt");
9846 text.appendChild(heading);
9847 const excerpt = document.createElement("div");
9848 excerpt.style.cssText = "color:#1d2327;line-height:1.5;";
9849 const raw = row.excerpt?.rendered ?? "";
9850 if (raw) {
9851 const stripped = raw.replace(/<[^>]+>/g, "").trim();
9852 excerpt.textContent = stripped || __("(no excerpt)");
9853 } else {
9854 excerpt.textContent = __("(no excerpt)");
9855 excerpt.style.color = "#a7aaad";
9856 }
9857 text.appendChild(excerpt);
9858 wrap.appendChild(text);
9859 return wrap;
9860 }
9861 async function renderPostsWindow(body, client) {
9862 const root = body.querySelector(ROOT$1);
9863 const table = body.querySelector(TABLE$1);
9864 if (!root || !table) {
9865 return;
9866 }
9867 maybeShowIntro(client);
9868 const catsHost = body.querySelector(
9869 "[data-desktop-mode-posts-cats-host]"
9870 );
9871 const tagsHost = body.querySelector(
9872 "[data-desktop-mode-posts-tags-host]"
9873 );
9874 let catsTeardown = null;
9875 let tagsTeardown = null;
9876 const tabsEl = body.querySelector(".desktop-mode-posts__tabs");
9877 if (tabsEl) {
9878 tabsEl.addEventListener("wpd-tab-change", (e) => {
9879 const detail = e.detail;
9880 const value = detail?.value;
9881 if (value === "categories" && catsHost && !catsTeardown) {
9882 void Promise.resolve().then(() => categoriesMindmap).then(
9883 async ({ mountCategoriesMindmap: mountCategoriesMindmap2 }) => {
9884 catsTeardown = await mountCategoriesMindmap2(catsHost, client);
9885 }
9886 );
9887 }
9888 if (value === "tags" && tagsHost && !tagsTeardown) {
9889 void Promise.resolve().then(() => tagsCloud).then(
9890 async ({ mountTagsCloud: mountTagsCloud2 }) => {
9891 tagsTeardown = await mountTagsCloud2(tagsHost, client);
9892 }
9893 );
9894 }
9895 });
9896 }
9897 const cfg = client.getConfig();
9898 const view = {
9899 page: 1,
9900 perPage: Math.max(1, cfg.defaultPerPage || 20),
9901 search: "",
9902 status: "",
9903 orderby: "date",
9904 order: "desc",
9905 author: [],
9906 tag: [],
9907 searchDebounce: null
9908 };
9909 const cellCache = /* @__PURE__ */ new Map();
9910 const filterData = { authors: [], tags: [] };
9911 table.columns = buildColumns$1(cellCache, client, filterData);
9912 table.getRowId = (row) => row.id;
9913 table.subTable = (row) => buildSubRow(row);
9914 table.sort = { key: "date", direction: "desc" };
9915 let totalPages = 0;
9916 let totalRows = 0;
9917 let refreshSeq = 0;
9918 const perPageEl = root.querySelector(PER_PAGE$1);
9919 if (perPageEl) {
9920 perPageEl.value = String(view.perPage);
9921 }
9922 const indicator = root.querySelector(PAGE_INDICATOR$1);
9923 const prevBtn = root.querySelector(PREV$1);
9924 const nextBtn = root.querySelector(NEXT$1);
9925 const bulkBar = root.querySelector(BULK$1);
9926 const countEl = root.querySelector(COUNT$1);
9927 const bulkActionsHost = root.querySelector(BULK_ACTIONS_HOST$1);
9928 const trailingExtras = root.querySelector(
9929 TOOLBAR_TRAILING_EXTRAS
9930 );
9931 const statusHost = root.querySelector(STATUS$1);
9932 const statusSegments = resolveStatusSegments();
9933 if (statusHost) {
9934 statusHost.replaceChildren();
9935 for (const seg of statusSegments) {
9936 const el = document.createElement("wpd-segment");
9937 el.setAttribute("value", seg.value);
9938 el.textContent = seg.label;
9939 statusHost.appendChild(el);
9940 }
9941 statusHost.setAttribute("value", view.status);
9942 }
9943 const updatePager = () => {
9944 if (indicator) {
9945 if (totalRows === 0) {
9946 indicator.textContent = __("No posts");
9947 } else {
9948 indicator.textContent = sprintf(
9949 /* translators: 1: current page, 2: total pages, 3: total posts. */
9950 __("Page %1$d of %2$d · %3$d posts"),
9951 view.page,
9952 Math.max(totalPages, 1),
9953 totalRows
9954 );
9955 }
9956 }
9957 if (prevBtn) {
9958 prevBtn.toggleAttribute("disabled", view.page <= 1);
9959 }
9960 if (nextBtn) {
9961 nextBtn.toggleAttribute("disabled", view.page >= totalPages);
9962 }
9963 };
9964 const updateBulkBar = () => {
9965 if (!bulkBar || !countEl) {
9966 return;
9967 }
9968 const sel = Array.from(table.selection ?? []);
9969 if (sel.length === 0) {
9970 bulkBar.hidden = true;
9971 return;
9972 }
9973 bulkBar.hidden = false;
9974 countEl.textContent = sprintf(
9975 /* translators: %d: selected row count. */
9976 __("%d selected"),
9977 sel.length
9978 );
9979 };
9980 const clearSelectionOnQueryChange = () => {
9981 table.clearSelection();
9982 };
9983 const buildParams = () => ({
9984 page: view.page,
9985 perPage: view.perPage,
9986 search: view.search || void 0,
9987 status: view.status || void 0,
9988 orderby: view.orderby,
9989 order: view.order,
9990 author: view.author.length > 0 ? view.author : void 0,
9991 tag: view.tag.length > 0 ? view.tag : void 0
9992 });
9993 const ctx = {
9994 body,
9995 table,
9996 refresh: () => refresh(),
9997 getSelectedIds: () => Array.from(table.selection ?? []).map((id) => Number(id)),
9998 getSelectedRows: () => {
9999 const ids = new Set(ctx.getSelectedIds());
10000 return (table.data ?? []).filter((r) => ids.has(r.id));
10001 },
10002 getCurrentParams: () => buildParams()
10003 };
10004 const refresh = async () => {
10005 const mySeq = ++refreshSeq;
10006 table.toggleAttribute("loading", true);
10007 try {
10008 const result = await client.fetchPosts(buildParams());
10009 if (mySeq !== refreshSeq) {
10010 return;
10011 }
10012 if (result.items.length === 0 && view.page > 1 && result.totalPages > 0 && view.page > result.totalPages) {
10013 view.page = 1;
10014 await refresh();
10015 return;
10016 }
10017 cellCache.clear();
10018 refreshParentTitleRoster(result.items);
10019 table.data = result.items;
10020 totalRows = result.total;
10021 totalPages = result.totalPages;
10022 updatePager();
10023 const hooks2 = window.wp?.hooks;
10024 if (hooks2 && typeof hooks2.doAction === "function") {
10025 hooks2.doAction(HOOK_ACTION_DATA_LOADED, {
10026 items: result.items,
10027 total: result.total,
10028 totalPages: result.totalPages,
10029 page: view.page
10030 });
10031 }
10032 document.dispatchEvent(
10033 new CustomEvent("desktop-mode-posts-window-data-loaded", {
10034 detail: {
10035 items: result.items,
10036 total: result.total,
10037 totalPages: result.totalPages,
10038 page: view.page
10039 }
10040 })
10041 );
10042 } catch (err) {
10043 if (mySeq !== refreshSeq) {
10044 return;
10045 }
10046 console.error("[posts-window] list failed", err);
10047 table.data = [];
10048 totalRows = 0;
10049 totalPages = 0;
10050 updatePager();
10051 } finally {
10052 if (mySeq === refreshSeq) {
10053 table.toggleAttribute("loading", false);
10054 updateBulkBar();
10055 }
10056 }
10057 };
10058 const goToFirstPage = () => {
10059 if (view.page !== 1) {
10060 view.page = 1;
10061 }
10062 };
10063 root.querySelector(STATUS$1)?.addEventListener("wpd-pick", (e) => {
10064 const value = e.detail?.value ?? "";
10065 view.status = value;
10066 goToFirstPage();
10067 clearSelectionOnQueryChange();
10068 void refresh();
10069 });
10070 root.querySelector(SEARCH$1)?.addEventListener(
10071 "wpd-input-change",
10072 (e) => {
10073 const value = e.detail?.value ?? "";
10074 view.search = value;
10075 if (view.searchDebounce !== null) {
10076 window.clearTimeout(view.searchDebounce);
10077 }
10078 view.searchDebounce = window.setTimeout(() => {
10079 goToFirstPage();
10080 clearSelectionOnQueryChange();
10081 void refresh();
10082 }, SEARCH_DEBOUNCE_MS$1);
10083 }
10084 );
10085 body.addEventListener("click", (e) => {
10086 const target = e.target;
10087 if (!target) {
10088 return;
10089 }
10090 if (target.closest(REFRESH$1)) {
10091 void refresh();
10092 return;
10093 }
10094 if (target.closest(NEW_BTN$1)) {
10095 const isPages = cfg.mode === "pages";
10096 openAdminUrl(cfg.newPostUrl, {
10097 title: isPages ? __("Add New Page") : __("Add New Post"),
10098 icon: isPages ? "dashicons-admin-page" : "dashicons-admin-post"
10099 });
10100 return;
10101 }
10102 if (target.closest(PREV$1)) {
10103 if (view.page > 1) {
10104 view.page -= 1;
10105 clearSelectionOnQueryChange();
10106 void refresh();
10107 }
10108 return;
10109 }
10110 if (target.closest(NEXT$1)) {
10111 if (view.page < totalPages) {
10112 view.page += 1;
10113 clearSelectionOnQueryChange();
10114 void refresh();
10115 }
10116 }
10117 });
10118 const bulkActions = resolveBulkActions(client);
10119 if (bulkActionsHost) {
10120 bulkActionsHost.replaceChildren();
10121 for (const action of bulkActions) {
10122 bulkActionsHost.appendChild(buildBulkActionButton(action, ctx));
10123 }
10124 }
10125 if (trailingExtras) {
10126 const extras = resolveToolbarTrailing(ctx);
10127 trailingExtras.replaceChildren(...extras);
10128 }
10129 perPageEl?.addEventListener("change", () => {
10130 const next = parseInt(perPageEl.value, 10);
10131 if (!Number.isFinite(next) || next < 1) {
10132 return;
10133 }
10134 view.perPage = next;
10135 goToFirstPage();
10136 clearSelectionOnQueryChange();
10137 void refresh();
10138 });
10139 table.addEventListener("wpd-table-selection-change", () => {
10140 updateBulkBar();
10141 });
10142 table.addEventListener("wpd-table-sort-change", (e) => {
10143 const detail = e.detail;
10144 if (!detail || !detail.sort) {
10145 view.orderby = "date";
10146 view.order = "desc";
10147 } else {
10148 view.orderby = mapColumnToOrderby(detail.sort.key);
10149 view.order = detail.sort.direction;
10150 }
10151 clearSelectionOnQueryChange();
10152 void refresh();
10153 });
10154 const parseIds = (raw) => raw.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => Number.isFinite(n) && n > 0);
10155 const sameIds = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
10156 table.addEventListener("wpd-table-filter-change", (e) => {
10157 const detail = e.detail;
10158 const filters = detail?.filters ?? {};
10159 const nextAuthor = parseIds(filters.author ?? "");
10160 const nextTag = parseIds(filters.tags ?? "");
10161 const changed = !sameIds(nextAuthor, view.author) || !sameIds(nextTag, view.tag);
10162 if (!changed) {
10163 return;
10164 }
10165 view.author = nextAuthor;
10166 view.tag = nextTag;
10167 view.page = 1;
10168 clearSelectionOnQueryChange();
10169 void refresh();
10170 });
10171 activeRunBulkAction = async (action, actionCtx) => {
10172 const ids = actionCtx.getSelectedIds();
10173 if (ids.length === 0) {
10174 return;
10175 }
10176 if (action.confirm) {
10177 const ok = await wpdConfirmGlobal$1({
10178 message: sprintf(
10179 /* translators: %d: row count. */
10180 action.confirm,
10181 ids.length
10182 ),
10183 danger: true
10184 });
10185 if (!ok) {
10186 return;
10187 }
10188 }
10189 try {
10190 const result = await action.run(ids, actionCtx);
10191 if (result === false) {
10192 return;
10193 }
10194 } catch (err) {
10195 console.error(
10196 `[posts-window] bulk action "${action.id}" failed`,
10197 err
10198 );
10199 }
10200 table.clearSelection();
10201 await refresh();
10202 };
10203 const broadcastUnsubs = [];
10204 if (window.wp?.desktop && typeof window.wp.desktop.subscribe === "function") {
10205 const onChange = (payload) => {
10206 const detail = payload;
10207 if (detail?.source === "posts-window") {
10208 return;
10209 }
10210 void refresh();
10211 };
10212 broadcastUnsubs.push(
10213 window.wp.desktop.subscribe("desktop-mode.post.changed", onChange)
10214 );
10215 const onTermChange = (payload) => {
10216 const detail = payload;
10217 if (detail?.taxonomy === "category") {
10218 clearCategoryTreeCache();
10219 broadcastFreshCategoryTreeToPickers(client);
10220 }
10221 };
10222 broadcastUnsubs.push(
10223 window.wp.desktop.subscribe(
10224 "desktop-mode.term.changed",
10225 onTermChange
10226 )
10227 );
10228 }
10229 const repaintColumns = () => {
10230 cellCache.clear();
10231 table.columns = buildColumns$1(cellCache, client, filterData);
10232 };
10233 void client.fetchAuthorOptions().then((authors) => {
10234 filterData.authors = authors;
10235 repaintColumns();
10236 });
10237 let tagPage = 0;
10238 let tagTotalPages = 1;
10239 let tagFetching = false;
10240 const TAG_PAGE_SIZE = 50;
10241 const fetchNextTagPage = async () => {
10242 if (tagFetching || tagPage >= tagTotalPages) {
10243 return;
10244 }
10245 tagFetching = true;
10246 try {
10247 const next = tagPage + 1;
10248 const res = await client.fetchTagOptions(next, TAG_PAGE_SIZE);
10249 tagPage = next;
10250 tagTotalPages = Math.max(tagTotalPages, res.totalPages || next);
10251 const seen = new Set(filterData.tags.map((t) => t.id));
10252 for (const item of res.items) {
10253 if (!seen.has(item.id)) {
10254 filterData.tags.push(item);
10255 seen.add(item.id);
10256 }
10257 }
10258 filterData.tagsHasMore = tagPage < tagTotalPages;
10259 repaintColumns();
10260 } finally {
10261 tagFetching = false;
10262 }
10263 };
10264 filterData.loadMoreTags = () => {
10265 void fetchNextTagPage();
10266 };
10267 void fetchNextTagPage();
10268 const teardownKebabColumns = mountKebabColumnToggles(
10269 body,
10270 cellCache,
10271 repaintColumns,
10272 client
10273 );
10274 let unsubOsSettings = null;
10275 if (window.wp?.desktop && typeof window.wp.desktop.subscribeOsSettings === "function") {
10276 let lastHidden = JSON.stringify(
10277 Array.from(getHiddenColumns()).sort()
10278 );
10279 unsubOsSettings = window.wp.desktop.subscribeOsSettings(() => {
10280 const next = JSON.stringify(
10281 Array.from(getHiddenColumns()).sort()
10282 );
10283 if (next === lastHidden) {
10284 return;
10285 }
10286 lastHidden = next;
10287 repaintColumns();
10288 teardownKebabColumns?.refresh();
10289 });
10290 }
10291 const onWindowClosed = (e) => {
10292 const detail = e.detail;
10293 if (detail?.windowId !== "desktop-mode-posts") {
10294 return;
10295 }
10296 document.removeEventListener("desktop-mode-window-closed", onWindowClosed);
10297 for (const unsub of broadcastUnsubs) {
10298 try {
10299 unsub();
10300 } catch {
10301 }
10302 }
10303 broadcastUnsubs.length = 0;
10304 teardownKebabColumns?.dispose();
10305 unsubOsSettings?.();
10306 catsTeardown?.();
10307 catsTeardown = null;
10308 tagsTeardown?.();
10309 tagsTeardown = null;
10310 if (view.searchDebounce !== null) {
10311 window.clearTimeout(view.searchDebounce);
10312 view.searchDebounce = null;
10313 }
10314 clearCategoryTreeCache();
10315 };
10316 document.addEventListener("desktop-mode-window-closed", onWindowClosed);
10317 await refresh();
10318 const hooks = window.wp?.hooks;
10319 if (hooks && typeof hooks.doAction === "function") {
10320 hooks.doAction(HOOK_ACTION_OPENED, ctx);
10321 }
10322 document.dispatchEvent(
10323 new CustomEvent("desktop-mode-posts-window-opened", {
10324 detail: ctx
10325 })
10326 );
10327 }
10328 function buildBulkActionButton(action, ctx) {
10329 const btn = document.createElement("wpd-button");
10330 btn.setAttribute("variant", action.variant ?? "secondary");
10331 btn.setAttribute("data-desktop-mode-posts-bulk-action", action.id);
10332 if (action.icon) {
10333 const icon = document.createElement("span");
10334 icon.className = `dashicons ${action.icon}`;
10335 icon.setAttribute("aria-hidden", "true");
10336 btn.appendChild(icon);
10337 }
10338 btn.appendChild(document.createTextNode(" " + action.label));
10339 btn.addEventListener("click", () => {
10340 void runBulkActionFor(action, ctx);
10341 });
10342 return btn;
10343 }
10344 let activeRunBulkAction = async () => {
10345 };
10346 async function runBulkActionFor(action, ctx) {
10347 await activeRunBulkAction(action, ctx);
10348 }
10349 function openAdminUrl(url, opts = {}) {
10350 const api = window.wp?.desktop;
10351 if (!api || !api.windowManager || !api.deriveWindowId) {
10352 window.location.href = url;
10353 return;
10354 }
10355 const id = api.deriveWindowId(url);
10356 api.windowManager.open({
10357 id,
10358 baseId: id,
10359 url,
10360 title: opts.title ?? url,
10361 icon: opts.icon ?? "dashicons-admin-generic"
10362 });
10363 }
10364 function mapColumnToOrderby(key) {
10365 switch (key) {
10366 case "title":
10367 return "title";
10368 case "author":
10369 return "author";
10370 case "date":
10371 return "date";
10372 case "modified":
10373 return "modified";
10374 case "comments":
10375 return "comment_count";
10376 default:
10377 return "date";
10378 }
10379 }
10380 const registry = window.desktopModeNativeWindows ?? (window.desktopModeNativeWindows = {});
10381 registry["desktop-mode-posts"] = (body) => {
10382 const client = createPostsWindowClient("desktop-mode-posts");
10383 return renderPostsWindow(body, client).catch((err) => {
10384 console.error("[posts-window] render failed:", err);
10385 });
10386 };
10387 registry["desktop-mode-pages"] = (body) => {
10388 const client = createPostsWindowClient("desktop-mode-pages");
10389 return renderPostsWindow(body, client).catch((err) => {
10390 console.error("[pages-window] render failed:", err);
10391 });
10392 };
10393 registry["desktop-mode-users"] = (body) => {
10394 const client = createUsersWindowClient("desktop-mode-users");
10395 return Promise.resolve().then(() => usersRender).then((m) => m.renderUsersWindow(body, client)).catch((err) => {
10396 console.error("[users-window] render failed:", err);
10397 });
10398 };
10399 registry["desktop-mode-user-edit"] = (body) => {
10400 const profile = body.querySelector(
10401 "wpd-user-profile[data-wpd-user-profile-host]"
10402 );
10403 if (!profile) {
10404 return;
10405 }
10406 void Promise.resolve().then(() => userEditTarget).then((target) => {
10407 const pending = target.readUserEditTarget();
10408 let userId = pending.userId && pending.userId > 0 ? pending.userId : 0;
10409 if (userId <= 0) {
10410 try {
10411 userId = window.desktopModeWindowConfig?.["desktop-mode-user-edit"]?.currentUserId ?? 0;
10412 } catch {
10413 userId = 0;
10414 }
10415 }
10416 if (userId > 0) {
10417 profile.setAttribute("user-id", String(userId));
10418 }
10419 target.clearUserEditTarget();
10420 target.subscribeUserEditTarget((next) => {
10421 if (!profile.isConnected) {
10422 return;
10423 }
10424 if (next.userId && next.userId > 0 && next.userId !== userId) {
10425 userId = next.userId;
10426 profile.setAttribute("user-id", String(userId));
10427 target.clearUserEditTarget();
10428 }
10429 });
10430 });
10431 };
10432 function createUserEditClient(windowId = "desktop-mode-user-edit") {
10433 const getConfig = () => {
10434 const store = window.desktopModeWindowConfig;
10435 const cfg = store?.[windowId];
10436 if (!cfg) {
10437 throw new Error(
10438 `[${windowId}] config blob is missing — was the window opened without registration? See \`includes/user-edit-window/window.php\`.`
10439 );
10440 }
10441 return cfg;
10442 };
10443 const shellFetch = (input, init, source) => {
10444 return trackedFetch(input, init, {
10445 windowId,
10446 source: source ?? "user-edit-window/rest"
10447 });
10448 };
10449 const fetchUser = async (id) => {
10450 const cfg = getConfig();
10451 const base = cfg.usersUrl ?? joinRestUrl(cfg.restRoot, "wp/v2/users");
10452 const url = joinRestUrl(base, `${id}?context=edit`);
10453 const res = await shellFetch(
10454 url,
10455 {
10456 method: "GET",
10457 credentials: "same-origin",
10458 headers: {
10459 Accept: "application/json",
10460 "X-WP-Nonce": cfg.restNonce
10461 }
10462 },
10463 "user-edit-window/load"
10464 );
10465 if (!res.ok) {
10466 throw new Error(`[user-edit] load failed: ${res.status}`);
10467 }
10468 return await res.json();
10469 };
10470 const saveUser = async (id, patch) => {
10471 const cfg = getConfig();
10472 const base = cfg.usersUrl ?? joinRestUrl(cfg.restRoot, "wp/v2/users");
10473 const res = await shellFetch(
10474 joinRestUrl(base, `${id}?context=edit`),
10475 {
10476 method: "POST",
10477 // PUT == POST for WP REST when X-HTTP-Method-Override is unsupported.
10478 credentials: "same-origin",
10479 headers: {
10480 "Content-Type": "application/json",
10481 "X-WP-Nonce": cfg.restNonce,
10482 "X-HTTP-Method-Override": "PUT"
10483 },
10484 body: JSON.stringify(patch)
10485 },
10486 "user-edit-window/save"
10487 );
10488 if (!res.ok) {
10489 const data = await res.json().catch(() => ({}));
10490 const fieldErrors = {};
10491 const params = data.data?.params;
10492 if (params && typeof params === "object") {
10493 for (const [k, v] of Object.entries(params)) {
10494 fieldErrors[k] = String(v);
10495 }
10496 }
10497 return {
10498 ok: false,
10499 error: data.code ?? `http_${res.status}`,
10500 message: data.message,
10501 fieldErrors
10502 };
10503 }
10504 const user = await res.json();
10505 return { ok: true, user };
10506 };
10507 const fetchInsights = async (id, opts = {}) => {
10508 const cfg = getConfig();
10509 const base = cfg.insightsUrlBase ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/");
10510 const url = new URL(joinRestUrl(base, `${id}/insights`));
10511 if (opts.fresh) {
10512 url.searchParams.set("fresh", "1");
10513 }
10514 const res = await shellFetch(
10515 url.toString(),
10516 {
10517 method: "GET",
10518 credentials: "same-origin",
10519 headers: {
10520 Accept: "application/json",
10521 "X-WP-Nonce": cfg.restNonce
10522 }
10523 },
10524 "user-edit-window/insights"
10525 );
10526 if (!res.ok) {
10527 throw new Error(`[user-edit] insights failed: ${res.status}`);
10528 }
10529 return await res.json();
10530 };
10531 return {
10532 windowId,
10533 getConfig,
10534 fetchUser,
10535 saveUser,
10536 fetchInsights
10537 };
10538 }
10539 const styles$1 = css`:host{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var( --desktop-mode-text,#1d2327 );cursor:pointer}label{display:inline-flex;align-items:center;gap:6px;cursor:pointer}input[ type='checkbox' ]{accent-color:var( --wp-admin-theme-color,#2271b1 );cursor:pointer}:host( [ disabled ] ){opacity:0.5;cursor:not-allowed}:host( [ disabled ] ) label,:host( [ disabled ] ) input[ type='checkbox' ]{cursor:not-allowed}`;
10540 const _WpdCheckboxLabel = class _WpdCheckboxLabel extends Component {
10541 render() {
10542 const label = this.label || "";
10543 const checked = this.checked !== null;
10544 const disabled = this.disabled !== null;
10545 return html`
10546 <label>
10547 <input
10548 type="checkbox"
10549 ?checked=${checked}
10550 ?disabled=${disabled}
10551 @change=${(e) => this._onChange(e)}
10552 />
10553 <span class="wpd-checkbox-label__text">${label}</span>
10554 </label>
10555 `;
10556 }
10557 _onChange(e) {
10558 if (this.disabled !== null) {
10559 return;
10560 }
10561 const next = e.target.checked;
10562 if (next) {
10563 this.setAttribute("checked", "");
10564 } else {
10565 this.removeAttribute("checked");
10566 }
10567 this.emit("wpd-checkbox-change", { checked: next });
10568 }
10569 };
10570 _WpdCheckboxLabel.props = ["label", "checked", "disabled"];
10571 _WpdCheckboxLabel.styles = [styles$1];
10572 _WpdCheckboxLabel.help = {
10573 title: "Checkbox label",
10574 summary: "Opinionated label-row variant of <wpd-checkbox>: label text + checkbox in a single aligned row. Use when you want the shipped layout without any layout work.",
10575 status: "stable",
10576 since: "0.9.0",
10577 props: [
10578 {
10579 name: "label",
10580 type: "string",
10581 description: "Visible label text, paired with the checkbox via a native <label>."
10582 },
10583 {
10584 name: "checked",
10585 type: "boolean attribute",
10586 description: "Reflects and controls the checked state."
10587 },
10588 {
10589 name: "disabled",
10590 type: "boolean attribute",
10591 description: "When present, the checkbox is not interactive and dimmed."
10592 }
10593 ],
10594 events: [
10595 {
10596 name: "wpd-checkbox-change",
10597 description: "Fires when the user toggles the checkbox.",
10598 detail: "{ checked: boolean }"
10599 }
10600 ],
10601 cssProps: [
10602 { name: "--desktop-mode-text", description: "Label colour." }
10603 ],
10604 example: html`
10605 <wpd-checkbox-label label="Reduce motion" checked></wpd-checkbox-label>
10606 `
10607 };
10608 let WpdCheckboxLabel = _WpdCheckboxLabel;
10609 defineComponent("wpd-checkbox-label", WpdCheckboxLabel);
10610 const styles = css`:host{display:inline-flex;align-items:center;justify-content:center;width:var( --wpd-icon-size,16px );height:var( --wpd-icon-size,16px );color:inherit;line-height:1}:host( [ hidden ] ){display:none}.wpd-icon__glyph{font-size:var( --wpd-icon-size,16px );width:var( --wpd-icon-size,16px );height:var( --wpd-icon-size,16px );line-height:1;color:inherit;display:inline-flex;align-items:center;justify-content:center}.wpd-icon__glyph--char{font-family:dashicons;font-style:normal;font-weight:normal;font-variant:normal;text-transform:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;speak:none}.wpd-icon__glyph.dashicons{font-family:dashicons}`;
10611 let _cache = null;
10612 function parseCssContentToChar(raw) {
10613 let value = raw.trim();
10614 if (value === "") {
10615 return null;
10616 }
10617 if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
10618 value = value.slice(1, -1);
10619 }
10620 const escaped = value.match(/^\\([0-9a-f]{1,6})\s?$/i);
10621 if (escaped) {
10622 return String.fromCodePoint(parseInt(escaped[1], 16));
10623 }
10624 return value || null;
10625 }
10626 function buildMap() {
10627 const map = /* @__PURE__ */ new Map();
10628 if (typeof document === "undefined") {
10629 return map;
10630 }
10631 const sheets = Array.from(document.styleSheets ?? []);
10632 for (const sheet of sheets) {
10633 let rules = null;
10634 try {
10635 rules = sheet.cssRules;
10636 } catch {
10637 continue;
10638 }
10639 if (!rules) {
10640 continue;
10641 }
10642 for (const rule of Array.from(rules)) {
10643 const styleRule = rule;
10644 if (!styleRule || !styleRule.selectorText) {
10645 continue;
10646 }
10647 const match = styleRule.selectorText.match(
10648 /\.dashicons-([a-z0-9-]+)::?before/i
10649 );
10650 if (!match) {
10651 continue;
10652 }
10653 const content = styleRule.style?.content;
10654 if (!content) {
10655 continue;
10656 }
10657 const char = parseCssContentToChar(content);
10658 if (char) {
10659 map.set(match[1], char);
10660 }
10661 }
10662 }
10663 return map;
10664 }
10665 function resolveDashicon(name) {
10666 if (!_cache) {
10667 _cache = buildMap();
10668 }
10669 const slug = name.startsWith("dashicons-") ? name.slice("dashicons-".length) : name;
10670 return _cache.get(slug) ?? null;
10671 }
10672 function refreshDashiconCache() {
10673 _cache = buildMap();
10674 }
10675 let _scheduled = false;
10676 function primeOnLoad() {
10677 if (_scheduled || typeof window === "undefined") {
10678 return;
10679 }
10680 _scheduled = true;
10681 const refresh = () => {
10682 refreshDashiconCache();
10683 };
10684 if (document.readyState === "loading") {
10685 document.addEventListener("DOMContentLoaded", refresh, { once: true });
10686 }
10687 window.addEventListener("load", refresh, { once: true });
10688 }
10689 primeOnLoad();
10690 const _WpdIcon = class _WpdIcon extends Component {
10691 render() {
10692 const rawName = this.name || "";
10693 const slug = rawName.startsWith("dashicons-") ? rawName.slice("dashicons-".length) : rawName;
10694 const size = this.size;
10695 if (size && /^\d+$/.test(size)) {
10696 this.style.setProperty("--wpd-icon-size", `${size}px`);
10697 }
10698 const char = resolveDashicon(slug);
10699 if (char) {
10700 return html`<span
10701 class="wpd-icon__glyph wpd-icon__glyph--char dashicons dashicons-${slug}"
10702 aria-hidden="true"
10703 >${char}</span>`;
10704 }
10705 return html`<span
10706 class="wpd-icon__glyph dashicons dashicons-${slug}"
10707 aria-hidden="true"
10708 ></span>`;
10709 }
10710 };
10711 _WpdIcon.props = ["name", "size"];
10712 _WpdIcon.styles = [styles];
10713 _WpdIcon.help = {
10714 title: "Icon",
10715 summary: 'Dashicon wrapper that inherits theme colour + sizing from its context. Accepts either the dashicon suffix ("calculator") or the full class ("dashicons-calculator"). Marked aria-hidden; wrap in a button/link with its own label for accessible use.',
10716 status: "stable",
10717 since: "0.5.0",
10718 props: [
10719 {
10720 name: "name",
10721 type: "string",
10722 description: "Dashicon identifier, with or without the `dashicons-` prefix."
10723 },
10724 {
10725 name: "size",
10726 type: "integer (px)",
10727 default: "16",
10728 description: "Glyph size in pixels."
10729 }
10730 ],
10731 cssProps: [
10732 { name: "--wpd-icon-size", default: "16px" }
10733 ],
10734 example: html`
10735 <wpd-cluster gap="8" align="center">
10736 <wpd-icon name="admin-post"></wpd-icon>
10737 <wpd-icon name="calculator" size="20"></wpd-icon>
10738 <wpd-icon name="dashicons-star-filled" size="32"></wpd-icon>
10739 </wpd-cluster>
10740 `
10741 };
10742 let WpdIcon = _WpdIcon;
10743 defineComponent("wpd-icon", WpdIcon);
10744 const textFieldStyles = css`:host{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var( --desktop-mode-text,#1d2327 );min-width:0}:host( [ hidden ] ){display:none}.wpd-text-field__label{font-size:12px;color:var( --desktop-mode-muted,#646970 )}.wpd-text-field__row{position:relative;display:flex;align-items:center;width:100%}input{appearance:none;-webkit-appearance:none;display:block;width:100%;min-width:0;box-sizing:border-box;padding:7px 10px;background:var( --desktop-mode-window-bg,#fff );border:1px solid var( --desktop-mode-border,#dcdcde );border-radius:6px;font:inherit;font-size:13px;color:var( --desktop-mode-text,#1d2327 );transition:border-color 0.12s ease,box-shadow 0.12s ease}.wpd-text-field__suffix{position:absolute;inset-inline-end:10px;top:50%;transform:translateY( -50% );pointer-events:none;font-size:12px;color:var( --desktop-mode-muted,#646970 )}.wpd-text-field__row--has-reveal input{padding-inline-end:36px}.wpd-text-field__reveal{position:absolute;inset-inline-end:0;top:0;bottom:0;width:34px;display:flex;align-items:center;justify-content:center;padding:0;border:none;background:transparent;color:var( --desktop-mode-muted,#646970 );cursor:pointer;border-radius:0 6px 6px 0;transition:color 0.12s ease}.wpd-text-field__reveal:hover{color:var( --wp-admin-theme-color,#2271b1 )}.wpd-text-field__reveal:focus-visible{outline:2px solid var( --wp-admin-theme-color,#2271b1 );outline-offset:-2px;border-radius:0 6px 6px 0}.wpd-text-field__reveal:disabled{opacity:0.45;cursor:not-allowed}.wpd-text-field__input--masked{-webkit-text-security:disc;text-security:disc}@supports not ( ( -webkit-text-security:disc ) or ( text-security:disc ) ){.wpd-text-field__input--masked{font-family:text-security-disc,"password",monospace;letter-spacing:0.2em}}input:hover{border-color:var( --desktop-mode-muted,#8c8f94 )}input:focus-visible{outline:none;border-color:var( --wp-admin-theme-color,#2271b1 );box-shadow:0 0 0 1px var( --wp-admin-theme-color,#2271b1 )}input:disabled{opacity:0.55;cursor:not-allowed;background:rgba( 0,0,0,0.03 )}input[ aria-invalid='true' ]{border-color:#d63638}input[ aria-invalid='true' ]:focus-visible{box-shadow:0 0 0 1px #d63638}input[ type='number' ]::-webkit-inner-spin-button,input[ type='number' ]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}input[ type='number' ]{-moz-appearance:textfield}`;
10745 const _WpdTextField = class _WpdTextField extends Component {
10746 constructor() {
10747 super(...arguments);
10748 this._revealed = false;
10749 }
10750 connectedCallback() {
10751 super.connectedCallback();
10752 ensureAutoId(this);
10753 }
10754 render() {
10755 const label = this.label || "";
10756 const value = this.value ?? "";
10757 const placeholder = this.placeholder || "";
10758 const disabled = this.disabled !== null;
10759 const readonly = this.readonly !== null;
10760 const declaredAutocomplete = this.autocomplete;
10761 const declaredType = this.type || "text";
10762 const isPassword = declaredType === "password";
10763 let autocomplete = declaredAutocomplete || "off";
10764 if (isPassword && (!declaredAutocomplete || autocomplete === "off")) {
10765 autocomplete = "new-password";
10766 }
10767 const maxLength = this.maxlength;
10768 const minLength = this.minlength;
10769 const pattern = this.pattern || "";
10770 const name = this.name || "";
10771 const suffix = this.suffix || "";
10772 const invalid = this.invalid !== null;
10773 const reveal = this.reveal !== null;
10774 const isPasswordIntent = declaredType === "password";
10775 const isMasked = isPasswordIntent && !(reveal && this._revealed);
10776 let effectiveType;
10777 if (isPasswordIntent) {
10778 effectiveType = "text";
10779 } else if (reveal && this._revealed) {
10780 effectiveType = "text";
10781 } else {
10782 effectiveType = declaredType;
10783 }
10784 const rowClass = reveal ? "wpd-text-field__row wpd-text-field__row--has-reveal" : "wpd-text-field__row";
10785 const inputClass = isMasked ? "wpd-text-field__input wpd-text-field__input--masked" : "wpd-text-field__input";
10786 const hostId = this.id || "wpd-unnamed";
10787 const inputId = `${hostId}__input`;
10788 return html`
10789 ${label ? html`<label
10790 class="wpd-text-field__label"
10791 for=${inputId}
10792 >${label}</label>` : html``}
10793 <span class=${rowClass}>
10794 <input
10795 id=${inputId}
10796 class=${inputClass}
10797 type=${effectiveType}
10798 .value=${value}
10799 placeholder=${placeholder}
10800 ?disabled=${disabled}
10801 ?readonly=${readonly}
10802 autocomplete=${autocomplete}
10803 maxlength=${maxLength ?? ""}
10804 minlength=${minLength ?? ""}
10805 pattern=${pattern}
10806 name=${name}
10807 aria-invalid=${invalid ? "true" : "false"}
10808 aria-label=${label || ""}
10809 @input=${(e) => this._onInput(e)}
10810 @change=${(e) => this._onChange(e)}
10811 @keydown=${(e) => this._onKeyDown(e)}
10812 />
10813 ${suffix ? html`<span class="wpd-text-field__suffix">${suffix}</span>` : html``}
10814 ${reveal ? this._renderRevealButton(disabled) : html``}
10815 </span>
10816 `;
10817 }
10818 _renderRevealButton(disabled) {
10819 const label = this._revealed ? "Hide" : "Show";
10820 return html`
10821 <button
10822 type="button"
10823 class="wpd-text-field__reveal"
10824 aria-label=${label}
10825 aria-pressed=${this._revealed ? "true" : "false"}
10826 ?disabled=${disabled}
10827 tabindex="0"
10828 @click=${() => this._onToggleReveal()}
10829 >
10830 ${this._revealed ? _iconEyeOff() : _iconEye()}
10831 </button>
10832 `;
10833 }
10834 _onToggleReveal() {
10835 this._revealed = !this._revealed;
10836 this.requestUpdate();
10837 }
10838 _onInput(e) {
10839 const input = e.target;
10840 this.value = input.value;
10841 this.emit("wpd-input-change", { value: input.value });
10842 }
10843 _onChange(e) {
10844 const input = e.target;
10845 this.emit("wpd-input-commit", { value: input.value });
10846 }
10847 _onKeyDown(e) {
10848 if (e.key === "Enter" && !e.shiftKey && !e.altKey && !e.metaKey) {
10849 const input = e.target;
10850 this.emit("wpd-submit", { value: input.value });
10851 }
10852 }
10853 };
10854 _WpdTextField.props = [
10855 "label",
10856 "value",
10857 "placeholder",
10858 "disabled",
10859 "readonly",
10860 "autocomplete",
10861 "type",
10862 "maxlength",
10863 "minlength",
10864 "pattern",
10865 "name",
10866 "suffix",
10867 "invalid",
10868 "reveal"
10869 ];
10870 _WpdTextField.styles = [textFieldStyles];
10871 _WpdTextField.help = {
10872 title: "Text field",
10873 summary: "Labelled text input primitive. Two-way reflects `value`, emits wpd-input-change per keystroke, wpd-input-commit on blur/change, and wpd-submit on Enter. Optional password reveal toggle.",
10874 status: "stable",
10875 since: "0.5.0",
10876 props: [
10877 { name: "label", type: "string", description: "Visible label above the input." },
10878 { name: "value", type: "string", description: "Current input value; reflected two-way." },
10879 { name: "placeholder", type: "string", description: "Native placeholder string." },
10880 { name: "disabled", type: "boolean attribute", description: "Disables the native input." },
10881 { name: "readonly", type: "boolean attribute", description: "Marks the input readonly." },
10882 {
10883 name: "autocomplete",
10884 type: "string",
10885 default: "off",
10886 description: "Forwarded to the native input autocomplete attribute."
10887 },
10888 {
10889 name: "type",
10890 type: "string",
10891 default: "text",
10892 description: "Native input type (text, password, email, search, tel, url)."
10893 },
10894 { name: "maxlength", type: "integer (string)", description: "Native maxlength." },
10895 { name: "minlength", type: "integer (string)", description: "Native minlength." },
10896 { name: "pattern", type: "regex string", description: "Native validation pattern." },
10897 { name: "name", type: "string", description: "Forwarded to the native input for form submission." },
10898 { name: "suffix", type: "string", description: "Text rendered inside the right edge of the input row." },
10899 {
10900 name: "invalid",
10901 type: "boolean attribute",
10902 description: "Marks the field aria-invalid and applies the error style."
10903 },
10904 {
10905 name: "reveal",
10906 type: "boolean attribute",
10907 description: 'On type="password" fields, adds an eye-icon toggle that flips the input between hidden and visible text.'
10908 }
10909 ],
10910 events: [
10911 {
10912 name: "wpd-input-change",
10913 description: "Fires on every input keystroke.",
10914 detail: "{ value: string }"
10915 },
10916 {
10917 name: "wpd-input-commit",
10918 description: "Fires on the native change event (blur / Enter).",
10919 detail: "{ value: string }"
10920 },
10921 {
10922 name: "wpd-submit",
10923 description: "Fires when the user presses Enter (without Shift/Alt/Meta).",
10924 detail: "{ value: string }"
10925 }
10926 ],
10927 cssProps: [
10928 { name: "--desktop-mode-text", description: "Text colour." },
10929 { name: "--desktop-mode-muted", description: "Label + suffix colour." },
10930 { name: "--desktop-mode-border", description: "Input outline." },
10931 { name: "--desktop-mode-window-bg", description: "Input background." }
10932 ],
10933 example: html`
10934 <wpd-stack gap="8">
10935 <wpd-text-field label="Note title" value="Untitled" placeholder="Name this note"></wpd-text-field>
10936 <wpd-text-field type="password" reveal label="API key"></wpd-text-field>
10937 </wpd-stack>
10938 `
10939 };
10940 let WpdTextField = _WpdTextField;
10941 defineComponent("wpd-text-field", WpdTextField);
10942 function _iconEye() {
10943 return html`
10944 <svg
10945 viewBox="0 0 16 16"
10946 width="14"
10947 height="14"
10948 fill="none"
10949 stroke="currentColor"
10950 stroke-width="1.5"
10951 stroke-linecap="round"
10952 stroke-linejoin="round"
10953 aria-hidden="true"
10954 focusable="false"
10955 >
10956 <path d="M1 8C1 8 3.5 3 8 3s7 5 7 5-2.5 5-7 5S1 8 1 8z" />
10957 <circle cx="8" cy="8" r="2" />
10958 </svg>
10959 `;
10960 }
10961 function _iconEyeOff() {
10962 return html`
10963 <svg
10964 viewBox="0 0 16 16"
10965 width="14"
10966 height="14"
10967 fill="none"
10968 stroke="currentColor"
10969 stroke-width="1.5"
10970 stroke-linecap="round"
10971 stroke-linejoin="round"
10972 aria-hidden="true"
10973 focusable="false"
10974 >
10975 <path d="M1 8C1 8 3.5 3 8 3s7 5 7 5-2.5 5-7 5S1 8 1 8z" />
10976 <circle cx="8" cy="8" r="2" />
10977 <line x1="2" y1="2" x2="14" y2="14" />
10978 </svg>
10979 `;
10980 }
10981 function resolveUserEditClient() {
10982 const store = window.desktopModeWindowConfig;
10983 if (store?.["desktop-mode-user-edit"]) {
10984 return createUserEditClient("desktop-mode-user-edit");
10985 }
10986 if (store?.["desktop-mode-users"]) {
10987 return createUserEditClient("desktop-mode-users");
10988 }
10989 return createUserEditClient("desktop-mode-user-edit");
10990 }
10991 function notifyToast$1(body, kind = "info") {
10992 const api = window.wp?.desktop;
10993 if (api?.showToast) {
10994 let duration;
10995 if (kind === "error") {
10996 duration = 8e3;
10997 } else if (kind === "success") {
10998 duration = 5e3;
10999 }
11000 api.showToast({ message: body, duration });
11001 return;
11002 }
11003 console.info("[user-edit-window]", body);
11004 }
11005 async function mountProfileFormAt(host, userId) {
11006 return loadAndMountProfile(host, userId);
11007 }
11008 async function mountProfileAsideAt(host, userId, fresh) {
11009 return renderInsightsAside(host, userId, fresh);
11010 }
11011 async function mountProfileActivityAt(host, userId, fresh) {
11012 return renderInsightsActivity(host, userId, fresh);
11013 }
11014 async function loadAndMountProfile(host, userId) {
11015 host.replaceChildren();
11016 const skeleton = document.createElement("div");
11017 skeleton.className = "desktop-mode-user-edit__skeleton";
11018 skeleton.style.cssText = "display:flex;align-items:center;justify-content:center;padding:48px;color:var(--desktop-mode-muted, #50575e);font-size:13px;";
11019 skeleton.textContent = __("Loading profile…");
11020 host.appendChild(skeleton);
11021 let user;
11022 try {
11023 user = await resolveUserEditClient().fetchUser(userId);
11024 } catch (err) {
11025 host.replaceChildren();
11026 const msg = document.createElement("p");
11027 msg.style.cssText = "padding:32px;color:#b32d2e;font-size:13px;text-align:center;";
11028 msg.textContent = sprintf(
11029 // translators: %s is an error message.
11030 __("Could not load profile (%s)."),
11031 String(err.message ?? err)
11032 );
11033 host.appendChild(msg);
11034 throw err;
11035 }
11036 host.replaceChildren();
11037 mountProfileForm(host, user, userId);
11038 return user;
11039 }
11040 function resolveProfileConfig() {
11041 const store = window.desktopModeWindowConfig;
11042 const userEdit = store?.["desktop-mode-user-edit"];
11043 const users = store?.["desktop-mode-users"];
11044 return {
11045 ...users ?? {},
11046 ...userEdit ?? {}
11047 };
11048 }
11049 function mountProfileForm(host, user, userId) {
11050 const cfg = resolveProfileConfig();
11051 const wrap = document.createElement("div");
11052 wrap.className = "desktop-mode-user-edit__profile";
11053 const form = document.createElement("wpd-form");
11054 form.setAttribute("submit-label", __("Save changes"));
11055 form.setAttribute("reset-label", __("Revert"));
11056 form.setAttribute("columns", "auto");
11057 const header = document.createElement("div");
11058 header.setAttribute("slot", "header");
11059 let profileHeader = buildProfileHeader(user);
11060 header.appendChild(profileHeader);
11061 form.appendChild(header);
11062 form.appendChild(textField("username", __("Username"), user.username, {
11063 readonly: true
11064 }));
11065 form.appendChild(textField("first_name", __("First name"), user.first_name));
11066 form.appendChild(textField("last_name", __("Last name"), user.last_name));
11067 form.appendChild(
11068 textField("nickname", __("Nickname"), user.nickname ?? "", {
11069 required: true,
11070 fullWidth: false
11071 })
11072 );
11073 const displaySelect = document.createElement("wpd-select");
11074 displaySelect.setAttribute("name", "name");
11075 displaySelect.setAttribute("label", __("Display name publicly as"));
11076 displaySelect.items = displayNameCandidates(user);
11077 displaySelect.value = user.name;
11078 form.appendChild(displaySelect);
11079 form.appendChild(
11080 textField("email", __("Email (required)"), user.email, {
11081 required: true,
11082 type: "email"
11083 })
11084 );
11085 form.appendChild(textField("url", __("Website"), user.url, { type: "url" }));
11086 const contactMethods = cfg.contactMethods ?? {};
11087 for (const [slug, label] of Object.entries(contactMethods)) {
11088 const value = typeof user.meta === "object" && user.meta !== null ? String(
11089 user.meta[slug] ?? ""
11090 ) : "";
11091 form.appendChild(
11092 textField(`meta.${slug}`, label, value, {
11093 dataset: { meta: slug }
11094 })
11095 );
11096 }
11097 const bio = document.createElement("wpd-textarea");
11098 bio.setAttribute("name", "description");
11099 bio.setAttribute("label", __("Biographical info"));
11100 bio.setAttribute(
11101 "placeholder",
11102 __("Share a little about yourself — visible on author archives.")
11103 );
11104 bio.setAttribute("rows", "4");
11105 bio.setAttribute("full-width", "");
11106 bio.value = user.description;
11107 bio.setAttribute("value", user.description);
11108 form.appendChild(bio);
11109 const localeSelect = document.createElement("wpd-select");
11110 localeSelect.setAttribute("name", "locale");
11111 localeSelect.setAttribute("label", __("Language"));
11112 const locales = cfg.locales ?? { "": __("Site default") };
11113 localeSelect.items = Object.entries(locales).map(([value, label]) => ({
11114 value,
11115 label
11116 }));
11117 localeSelect.value = String(user.locale ?? "");
11118 form.appendChild(localeSelect);
11119 const isSelfEdit = userId === (cfg.currentUserId ?? 0);
11120 const roleMap = (() => {
11121 const assignable = cfg.assignableRoles;
11122 if (assignable && Object.keys(assignable).length > 0) {
11123 return assignable;
11124 }
11125 return cfg.allRoles ?? {};
11126 })();
11127 if (!isSelfEdit) {
11128 const roleSelect = document.createElement("wpd-select");
11129 roleSelect.setAttribute("name", "roles[0]");
11130 roleSelect.setAttribute("label", __("Role"));
11131 roleSelect.items = Object.entries(roleMap).map(([value, label]) => ({
11132 value,
11133 label
11134 }));
11135 const currentRole = Array.isArray(user.roles) ? user.roles[0] ?? "" : "";
11136 roleSelect.value = currentRole;
11137 form.appendChild(roleSelect);
11138 }
11139 {
11140 const optsHeading = document.createElement("h3");
11141 optsHeading.setAttribute("full-width", "");
11142 optsHeading.textContent = __("Personal options");
11143 optsHeading.style.cssText = "margin:18px 0 4px;font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;color:var(--desktop-mode-muted, #50575e);";
11144 form.appendChild(optsHeading);
11145 const meta = user.meta ?? {};
11146 const richEditing = String(meta.rich_editing ?? "") !== "false";
11147 const syntaxHighlighting = String(meta.syntax_highlighting ?? "") !== "false";
11148 const commentShortcuts = String(meta.comment_shortcuts ?? "false") === "true";
11149 const adminBarFront = String(meta.show_admin_bar_front ?? "true") !== "false";
11150 form.appendChild(
11151 checkboxField(
11152 "meta.rich_editing",
11153 __("Disable the visual editor when writing"),
11154 !richEditing,
11155 { trueValue: "false", falseValue: "true", fullWidth: true }
11156 )
11157 );
11158 form.appendChild(
11159 checkboxField(
11160 "meta.syntax_highlighting",
11161 __("Disable syntax highlighting when editing code"),
11162 !syntaxHighlighting,
11163 { trueValue: "false", falseValue: "true", fullWidth: true }
11164 )
11165 );
11166 form.appendChild(
11167 checkboxField(
11168 "meta.comment_shortcuts",
11169 __("Enable keyboard shortcuts for comment moderation"),
11170 commentShortcuts,
11171 { trueValue: "true", falseValue: "false", fullWidth: true }
11172 )
11173 );
11174 form.appendChild(
11175 checkboxField(
11176 "meta.show_admin_bar_front",
11177 __("Show toolbar when viewing site"),
11178 adminBarFront,
11179 { trueValue: "true", falseValue: "false", fullWidth: true }
11180 )
11181 );
11182 const colorSchemes = cfg.colorSchemes ?? {};
11183 const currentScheme = String(meta.admin_color ?? "fresh");
11184 form.appendChild(
11185 buildAdminColorPicker(colorSchemes, currentScheme, {
11186 livePreview: isSelfEdit
11187 })
11188 );
11189 }
11190 const pwdHeading = document.createElement("h3");
11191 pwdHeading.setAttribute("full-width", "");
11192 pwdHeading.textContent = __("Account management");
11193 pwdHeading.style.cssText = "margin:18px 0 4px;font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;color:var(--desktop-mode-muted, #50575e);";
11194 form.appendChild(pwdHeading);
11195 const pwdRow = document.createElement("div");
11196 pwdRow.setAttribute("full-width", "");
11197 pwdRow.style.cssText = "display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;";
11198 const pwd = document.createElement("wpd-text-field");
11199 pwd.setAttribute("name", "password");
11200 pwd.setAttribute("type", "password");
11201 pwd.setAttribute("reveal", "");
11202 pwd.setAttribute("label", __("New password"));
11203 pwd.setAttribute(
11204 "placeholder",
11205 __("Leave blank to keep the current password.")
11206 );
11207 pwd.setAttribute("autocomplete", "new-password");
11208 pwd.style.flex = "1 1 280px";
11209 pwdRow.appendChild(pwd);
11210 const genBtn = document.createElement("wpd-button");
11211 genBtn.setAttribute("variant", "ghost");
11212 genBtn.setAttribute("type", "button");
11213 const genIcon = document.createElement("wpd-icon");
11214 genIcon.setAttribute("name", "randomize");
11215 genIcon.setAttribute("size", "14");
11216 genBtn.appendChild(genIcon);
11217 genBtn.appendChild(document.createTextNode(__("Generate strong")));
11218 genBtn.addEventListener("click", (e) => {
11219 e.preventDefault();
11220 const next = generateStrongPassword$1(18);
11221 pwd.value = next;
11222 pwd.setAttribute("value", next);
11223 const pwdConfirmEl = form.querySelector(
11224 'wpd-text-field[name="password_confirm"]'
11225 );
11226 if (pwdConfirmEl) {
11227 pwdConfirmEl.value = next;
11228 pwdConfirmEl.setAttribute("value", next);
11229 }
11230 void navigator.clipboard?.writeText(next).catch(() => {
11231 });
11232 notifyToast$1(__("Password generated and copied to clipboard."), "success");
11233 });
11234 pwdRow.appendChild(genBtn);
11235 form.appendChild(pwdRow);
11236 const pwdConfirm = document.createElement("wpd-text-field");
11237 pwdConfirm.setAttribute("name", "password_confirm");
11238 pwdConfirm.setAttribute("type", "password");
11239 pwdConfirm.setAttribute("reveal", "");
11240 pwdConfirm.setAttribute("label", __("Confirm new password"));
11241 pwdConfirm.setAttribute(
11242 "placeholder",
11243 __("Type the new password again.")
11244 );
11245 pwdConfirm.setAttribute("autocomplete", "new-password");
11246 pwdConfirm.setAttribute("full-width", "");
11247 form.appendChild(pwdConfirm);
11248 form.appendChild(
11249 buildSessionsRow(userId, isSelfEdit)
11250 );
11251 form.appendChild(buildAppPasswordsRow(userId));
11252 if (!isSelfEdit && cfg.isMultisite && user.meta?.is_super_admin !== void 0) {
11253 form.appendChild(
11254 checkboxField(
11255 "meta.is_super_admin",
11256 __("Grant super admin privileges for the network"),
11257 Boolean(
11258 user.meta?.is_super_admin
11259 ),
11260 { trueValue: "true", falseValue: "false", fullWidth: true }
11261 )
11262 );
11263 }
11264 let pending = false;
11265 form.addEventListener("wpd-form-submit", (e) => {
11266 const detail = e.detail;
11267 void onSubmit(detail.values);
11268 });
11269 const onSubmit = async (values) => {
11270 if (pending) {
11271 return;
11272 }
11273 pending = true;
11274 form.setBusy(true);
11275 form.clearErrors();
11276 const patch = {
11277 first_name: values.first_name,
11278 last_name: values.last_name,
11279 nickname: values.nickname,
11280 name: values.name,
11281 email: values.email,
11282 url: values.url,
11283 description: values.description,
11284 locale: values.locale ?? ""
11285 };
11286 if (typeof values.password === "string" && values.password !== "") {
11287 const confirm = String(values.password_confirm ?? "");
11288 if (confirm !== values.password) {
11289 form.setError(__("The two password fields do not match."));
11290 form.setFieldInvalid("password_confirm");
11291 pending = false;
11292 form.setBusy(false);
11293 return;
11294 }
11295 patch.password = values.password;
11296 }
11297 if (typeof values["roles[0]"] === "string" && values["roles[0]"]) {
11298 patch.roles = [values["roles[0]"]];
11299 }
11300 const meta = {};
11301 for (const [k, v] of Object.entries(values)) {
11302 if (!k.startsWith("meta.")) {
11303 continue;
11304 }
11305 let resolved = v;
11306 if (typeof v === "boolean") {
11307 const field = form.querySelector(`[name="${k}"]`);
11308 const valueAttr = field?.getAttribute("value");
11309 resolved = valueAttr ?? String(v);
11310 }
11311 meta[k.slice(5)] = resolved;
11312 }
11313 if (Object.keys(meta).length > 0) {
11314 patch.meta = meta;
11315 }
11316 const result = await resolveUserEditClient().saveUser(userId, patch);
11317 pending = false;
11318 form.setBusy(false);
11319 if (!result.ok) {
11320 const summary = result.message ?? mapErrorCode(result.error) ?? __("Save failed.");
11321 form.setError(summary);
11322 notifyToast$1(summary, "error");
11323 if (result.fieldErrors) {
11324 for (const field of Object.keys(result.fieldErrors)) {
11325 form.setFieldInvalid(field);
11326 }
11327 }
11328 console.warn("[user-edit] save failed", {
11329 code: result.error,
11330 message: result.message
11331 });
11332 return;
11333 }
11334 notifyToast$1(__("Profile saved."), "success");
11335 const broadcastApi = window.wp?.desktop;
11336 broadcastApi?.broadcast?.("desktop-mode.user.changed", {
11337 source: "user-edit-window",
11338 action: "updated",
11339 ids: [userId]
11340 });
11341 pwd.value = "";
11342 pwd.setAttribute("value", "");
11343 pwdConfirm.value = "";
11344 pwdConfirm.setAttribute("value", "");
11345 if (result.user) {
11346 Object.assign(user, result.user);
11347 const next = buildProfileHeader(user);
11348 profileHeader.replaceWith(next);
11349 profileHeader = next;
11350 const aside = host.ownerDocument?.querySelector(
11351 "[data-wpd-user-profile-aside]"
11352 );
11353 if (aside) {
11354 void mountProfileAsideAt(aside, userId, true);
11355 }
11356 }
11357 };
11358 wrap.appendChild(form);
11359 host.appendChild(wrap);
11360 }
11361 function buildProfileHeader(user) {
11362 const wrap = document.createElement("div");
11363 wrap.className = "desktop-mode-user-edit__header";
11364 wrap.style.cssText = "display:flex;align-items:center;gap:16px;margin:0 0 12px;";
11365 const avatar = document.createElement("wpd-avatar");
11366 avatar.setAttribute("size", "64");
11367 if (user.name || user.username) {
11368 avatar.setAttribute("name", user.name || user.username || "");
11369 }
11370 if (user.id > 0) {
11371 avatar.setAttribute("user-id", String(user.id));
11372 }
11373 const avatars = user.avatar_urls ?? {};
11374 const rawAvatar = avatars["96"] ?? avatars["48"] ?? "";
11375 if (rawAvatar) {
11376 applyAvatarSrc(avatar, rawAvatar);
11377 }
11378 wrap.appendChild(avatar);
11379 const text = document.createElement("div");
11380 text.style.cssText = "min-width:0;display:flex;flex-direction:column;gap:4px;";
11381 const name = document.createElement("div");
11382 name.style.cssText = "font-size:18px;font-weight:600;letter-spacing:-0.01em;";
11383 name.textContent = user.name || user.username || `#${user.id}`;
11384 text.appendChild(name);
11385 const sub = document.createElement("div");
11386 sub.style.cssText = "display:flex;align-items:center;gap:6px;font-size:12px;color:var(--desktop-mode-muted, #50575e);flex-wrap:wrap;";
11387 const handle = document.createElement("span");
11388 handle.textContent = `@${user.username}`;
11389 sub.appendChild(handle);
11390 const dot = document.createElement("span");
11391 dot.textContent = "·";
11392 dot.setAttribute("aria-hidden", "true");
11393 sub.appendChild(dot);
11394 const roleStr = Array.isArray(user.roles) ? user.roles.join(", ") : "";
11395 const roleSpan = document.createElement("span");
11396 roleSpan.textContent = roleStr || __("No role");
11397 sub.appendChild(roleSpan);
11398 text.appendChild(sub);
11399 wrap.appendChild(text);
11400 return wrap;
11401 }
11402 async function loadInsightsInto(host, userId, fresh) {
11403 host.replaceChildren();
11404 const skeleton = document.createElement("div");
11405 skeleton.style.cssText = "display:flex;align-items:center;justify-content:center;padding:32px;color:var(--desktop-mode-muted, #50575e);font-size:13px;";
11406 skeleton.textContent = __("Loading insights…");
11407 host.appendChild(skeleton);
11408 try {
11409 return await resolveUserEditClient().fetchInsights(userId, { fresh });
11410 } catch (err) {
11411 host.replaceChildren();
11412 const msg = document.createElement("p");
11413 msg.style.cssText = "padding:24px;color:#b32d2e;font-size:13px;text-align:center;";
11414 msg.textContent = sprintf(
11415 // translators: %s is an error message.
11416 __("Could not load insights (%s)."),
11417 String(err.message ?? err)
11418 );
11419 host.appendChild(msg);
11420 return null;
11421 }
11422 }
11423 async function renderInsightsAside(host, userId, fresh) {
11424 const data = await loadInsightsInto(host, userId, fresh);
11425 if (!data) {
11426 return;
11427 }
11428 host.replaceChildren();
11429 host.appendChild(buildAsideSummary(data));
11430 host.appendChild(buildAsideStatGrid(data));
11431 host.appendChild(buildContentSparkline(data));
11432 }
11433 async function renderInsightsActivity(host, userId, fresh) {
11434 const data = await loadInsightsInto(host, userId, fresh);
11435 if (!data) {
11436 return;
11437 }
11438 host.replaceChildren();
11439 const wrap = document.createElement("div");
11440 wrap.className = "desktop-mode-user-edit__activity";
11441 const heading = document.createElement("h3");
11442 heading.textContent = __("Recent activity");
11443 heading.style.cssText = "margin:24px 0 12px;font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;color:var(--desktop-mode-muted, #50575e);";
11444 wrap.appendChild(heading);
11445 wrap.appendChild(buildRecentLists(data));
11446 wrap.appendChild(buildSecurityPanel(data));
11447 host.appendChild(wrap);
11448 }
11449 function buildAsideSummary(data) {
11450 const card = document.createElement("div");
11451 card.style.cssText = [
11452 "display:flex",
11453 "flex-direction:column",
11454 "align-items:center",
11455 "text-align:center",
11456 "gap:6px",
11457 "padding:16px",
11458 "border:1px solid var(--desktop-mode-border, #dcdcde)",
11459 "border-radius:12px",
11460 "background:var(--wp-admin-theme-bg-elevated, #f6f7f7)"
11461 ].join(";");
11462 const avatar = document.createElement("img");
11463 avatar.src = data.avatarUrl;
11464 avatar.alt = "";
11465 avatar.style.cssText = "width:72px;height:72px;border-radius:50%;flex-shrink:0;";
11466 card.appendChild(avatar);
11467 const name = document.createElement("div");
11468 name.style.cssText = "font-size:15px;font-weight:600;letter-spacing:-0.01em;";
11469 name.textContent = data.displayName || `#${data.userId}`;
11470 card.appendChild(name);
11471 const roles = document.createElement("div");
11472 roles.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;justify-content:center;";
11473 for (const role of data.roles) {
11474 const chip = document.createElement("span");
11475 chip.textContent = role;
11476 chip.style.cssText = [
11477 "display:inline-flex",
11478 "padding:2px 8px",
11479 "border-radius:10px",
11480 "background:rgba(34,113,177,0.10)",
11481 "color:#0a4b78",
11482 "font-size:11px",
11483 "font-weight:600"
11484 ].join(";");
11485 roles.appendChild(chip);
11486 }
11487 if (data.roles.length === 0) {
11488 const noRole = document.createElement("span");
11489 noRole.textContent = __("No role");
11490 noRole.style.cssText = "font-size:11px;color:var(--desktop-mode-muted, #8c8f94);";
11491 roles.appendChild(noRole);
11492 }
11493 card.appendChild(roles);
11494 const completeness = data.profileCompleteness;
11495 if (completeness && completeness.total > 0) {
11496 const cwrap = document.createElement("div");
11497 cwrap.style.cssText = "display:flex;flex-direction:column;gap:4px;width:100%;margin-top:6px;";
11498 const top = document.createElement("div");
11499 top.style.cssText = "display:flex;justify-content:space-between;align-items:baseline;font-size:11px;color:var(--desktop-mode-muted, #50575e);";
11500 const lbl = document.createElement("span");
11501 lbl.textContent = __("Profile completeness");
11502 const pct = document.createElement("span");
11503 pct.style.cssText = "font-variant-numeric:tabular-nums;font-weight:600;";
11504 pct.textContent = `${completeness.percent}%`;
11505 top.appendChild(lbl);
11506 top.appendChild(pct);
11507 cwrap.appendChild(top);
11508 const track = document.createElement("div");
11509 track.style.cssText = [
11510 "height:4px",
11511 "border-radius:999px",
11512 "background:rgba(0,0,0,0.06)",
11513 "position:relative",
11514 "overflow:hidden"
11515 ].join(";");
11516 const bar = document.createElement("div");
11517 bar.style.cssText = [
11518 "position:absolute",
11519 "inset:0",
11520 `width:${completeness.percent}%`,
11521 "background:var(--wp-admin-theme-color, #2271b1)",
11522 "transition:width 360ms ease"
11523 ].join(";");
11524 track.appendChild(bar);
11525 cwrap.appendChild(track);
11526 card.appendChild(cwrap);
11527 }
11528 return card;
11529 }
11530 function buildAsideStatGrid(data) {
11531 const grid = document.createElement("div");
11532 grid.style.cssText = [
11533 "display:grid",
11534 "grid-template-columns:1fr 1fr",
11535 "gap:8px",
11536 "margin-top:12px"
11537 ].join(";");
11538 const tile = (label, value, sub) => {
11539 const card = document.createElement("div");
11540 card.style.cssText = [
11541 "border:1px solid var(--desktop-mode-border, #dcdcde)",
11542 "border-radius:8px",
11543 "padding:8px 10px",
11544 "display:flex",
11545 "flex-direction:column",
11546 "gap:1px",
11547 "min-width:0"
11548 ].join(";");
11549 const lbl = document.createElement("div");
11550 lbl.style.cssText = "font-size:10px;text-transform:uppercase;letter-spacing:0.04em;color:var(--desktop-mode-muted, #50575e);font-weight:600;";
11551 lbl.textContent = label;
11552 const val = document.createElement("div");
11553 val.style.cssText = "font-size:18px;font-weight:600;font-variant-numeric:tabular-nums;";
11554 val.textContent = value;
11555 card.appendChild(lbl);
11556 card.appendChild(val);
11557 if (sub) {
11558 const subEl = document.createElement("div");
11559 subEl.style.cssText = "font-size:10px;color:var(--desktop-mode-muted, #8c8f94);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
11560 subEl.title = sub;
11561 subEl.textContent = sub;
11562 card.appendChild(subEl);
11563 }
11564 return card;
11565 };
11566 const stats = data.stats;
11567 let postsSub;
11568 if (stats.pages > 0) {
11569 postsSub = sprintf(
11570 // translators: %d is a count of pages.
11571 _n("+ %d page", "+ %d pages", stats.pages),
11572 stats.pages
11573 );
11574 }
11575 grid.appendChild(
11576 tile(__("Posts"), String(stats.posts), postsSub)
11577 );
11578 let commentsSub;
11579 if (stats.commentsReceived > 0) {
11580 commentsSub = sprintf(
11581 // translators: %d is a count of received comments.
11582 __("%d received"),
11583 stats.commentsReceived
11584 );
11585 }
11586 grid.appendChild(
11587 tile(__("Comments"), String(stats.commentsAuthored), commentsSub)
11588 );
11589 grid.appendChild(
11590 tile(
11591 __("Last login"),
11592 stats.lastLoginAt ? relativeTime$1(stats.lastLoginAt) : __("Never"),
11593 stats.lastLoginAt ? new Date(stats.lastLoginAt * 1e3).toLocaleDateString() : void 0
11594 )
11595 );
11596 let memberValue = "—";
11597 if (stats.daysSinceRegistration !== null) {
11598 memberValue = sprintf(
11599 // translators: %d is a number of days.
11600 _n("%d day", "%d days", stats.daysSinceRegistration),
11601 stats.daysSinceRegistration
11602 );
11603 }
11604 grid.appendChild(
11605 tile(
11606 __("Member"),
11607 memberValue,
11608 stats.registeredAt ? new Date(stats.registeredAt * 1e3).toLocaleDateString() : void 0
11609 )
11610 );
11611 return grid;
11612 }
11613 function buildContentSparkline(data) {
11614 const wrap = document.createElement("div");
11615 wrap.style.cssText = [
11616 "border:1px solid var(--desktop-mode-border, #dcdcde)",
11617 "border-radius:10px",
11618 "padding:14px 16px",
11619 "margin:0 0 22px"
11620 ].join(";");
11621 const head = document.createElement("div");
11622 head.style.cssText = "display:flex;justify-content:space-between;align-items:baseline;margin:0 0 8px;";
11623 const title = document.createElement("div");
11624 title.style.cssText = "font-size:13px;font-weight:600;";
11625 title.textContent = __("Posts published — last 12 months");
11626 head.appendChild(title);
11627 const total = data.contentByMonth.reduce((s, m) => s + m.count, 0);
11628 const sub = document.createElement("div");
11629 sub.style.cssText = "font-size:11px;color:var(--desktop-mode-muted, #50575e);";
11630 sub.textContent = sprintf(
11631 // translators: %d is a count of posts.
11632 __("%d total"),
11633 total
11634 );
11635 head.appendChild(sub);
11636 wrap.appendChild(head);
11637 if (data.contentByMonth.length === 0) {
11638 const empty = document.createElement("p");
11639 empty.style.cssText = "margin:0;color:var(--desktop-mode-muted, #50575e);font-size:12px;";
11640 empty.textContent = __("No activity in the last 12 months.");
11641 wrap.appendChild(empty);
11642 return wrap;
11643 }
11644 const max = Math.max(1, ...data.contentByMonth.map((m) => m.count));
11645 const bars = document.createElement("div");
11646 bars.style.cssText = [
11647 "display:grid",
11648 `grid-template-columns:repeat(${data.contentByMonth.length}, 1fr)`,
11649 "gap:4px",
11650 "align-items:end",
11651 "height:60px"
11652 ].join(";");
11653 for (const month of data.contentByMonth) {
11654 const col = document.createElement("div");
11655 col.style.cssText = "display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end;";
11656 const bar = document.createElement("div");
11657 const heightPct = Math.round(month.count / max * 100);
11658 bar.style.cssText = [
11659 "width:100%",
11660 `height:${Math.max(3, heightPct)}%`,
11661 "background:var(--wp-admin-theme-color, #2271b1)",
11662 month.count === 0 ? "opacity:0.18" : "opacity:1",
11663 "border-radius:3px 3px 0 0",
11664 "transition:height 360ms ease"
11665 ].join(";");
11666 bar.title = sprintf(
11667 // translators: %1$s is a YYYY-MM month, %2$d is post count.
11668 __("%1$s — %2$d posts"),
11669 month.month,
11670 month.count
11671 );
11672 col.appendChild(bar);
11673 wrap.appendChild(col);
11674 bars.appendChild(col);
11675 }
11676 wrap.appendChild(bars);
11677 const labels = document.createElement("div");
11678 labels.style.cssText = [
11679 "display:grid",
11680 `grid-template-columns:repeat(${data.contentByMonth.length}, 1fr)`,
11681 "gap:4px",
11682 "margin-top:4px",
11683 "font-size:10px",
11684 "color:var(--desktop-mode-muted, #8c8f94)",
11685 "text-align:center"
11686 ].join(";");
11687 for (const month of data.contentByMonth) {
11688 const span = document.createElement("span");
11689 const parts = month.month.split("-");
11690 span.textContent = parts.length === 2 ? parts[1] : month.month;
11691 labels.appendChild(span);
11692 }
11693 wrap.appendChild(labels);
11694 return wrap;
11695 }
11696 function buildRecentLists(data) {
11697 const wrap = document.createElement("div");
11698 wrap.style.cssText = "display:grid;grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));gap:14px;margin:0 0 22px;";
11699 wrap.appendChild(
11700 buildRecentList(
11701 __("Recent posts"),
11702 __("No recent posts."),
11703 data.recentPosts.map((p) => ({
11704 primary: p.title,
11705 secondary: relativeFromIso(p.dateGmt),
11706 tag: p.status !== "publish" ? p.status : null,
11707 badge: p.commentCount > 0 ? sprintf(
11708 // translators: %d is a count of comments.
11709 __("%d 💬"),
11710 p.commentCount
11711 ) : null
11712 }))
11713 )
11714 );
11715 wrap.appendChild(
11716 buildRecentList(
11717 __("Recent comments"),
11718 __("No recent comments."),
11719 data.recentComments.map((c) => {
11720 const when = relativeFromIso(c.dateGmt);
11721 return {
11722 primary: c.excerpt || __("(empty comment)"),
11723 secondary: c.postTitle ? `${__("on")} "${c.postTitle}" · ${when}` : when,
11724 tag: c.approved ? null : __("pending"),
11725 badge: null
11726 };
11727 })
11728 )
11729 );
11730 return wrap;
11731 }
11732 function buildRecentList(title, emptyText, items) {
11733 const card = document.createElement("div");
11734 card.style.cssText = [
11735 "border:1px solid var(--desktop-mode-border, #dcdcde)",
11736 "border-radius:10px",
11737 "padding:14px 16px",
11738 "min-width:0"
11739 ].join(";");
11740 const head = document.createElement("div");
11741 head.style.cssText = "font-size:13px;font-weight:600;margin:0 0 10px;";
11742 head.textContent = title;
11743 card.appendChild(head);
11744 if (items.length === 0) {
11745 const empty = document.createElement("p");
11746 empty.style.cssText = "margin:0;color:var(--desktop-mode-muted, #50575e);font-size:12px;";
11747 empty.textContent = emptyText;
11748 card.appendChild(empty);
11749 return card;
11750 }
11751 const list = document.createElement("ul");
11752 list.style.cssText = "list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px;";
11753 for (const item of items) {
11754 const li = document.createElement("li");
11755 li.style.cssText = "min-width:0;";
11756 const top = document.createElement("div");
11757 top.style.cssText = "display:flex;align-items:baseline;gap:6px;min-width:0;";
11758 const primary = document.createElement("span");
11759 primary.style.cssText = "font-size:13px;line-height:1.35;flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
11760 primary.textContent = item.primary;
11761 primary.title = item.primary;
11762 top.appendChild(primary);
11763 if (item.tag) {
11764 const tag = document.createElement("span");
11765 tag.style.cssText = "font-size:10px;text-transform:uppercase;letter-spacing:0.04em;background:rgba(0,0,0,0.06);padding:1px 6px;border-radius:8px;flex-shrink:0;";
11766 tag.textContent = item.tag;
11767 top.appendChild(tag);
11768 }
11769 if (item.badge) {
11770 const badge = document.createElement("span");
11771 badge.style.cssText = "font-size:11px;color:var(--desktop-mode-muted, #50575e);flex-shrink:0;";
11772 badge.textContent = item.badge;
11773 top.appendChild(badge);
11774 }
11775 li.appendChild(top);
11776 const sub = document.createElement("div");
11777 sub.style.cssText = "font-size:11px;color:var(--desktop-mode-muted, #8c8f94);";
11778 sub.textContent = item.secondary;
11779 li.appendChild(sub);
11780 list.appendChild(li);
11781 }
11782 card.appendChild(list);
11783 return card;
11784 }
11785 function buildSecurityPanel(data) {
11786 const card = document.createElement("div");
11787 card.style.cssText = [
11788 "border:1px solid var(--desktop-mode-border, #dcdcde)",
11789 "border-radius:10px",
11790 "padding:14px 16px"
11791 ].join(";");
11792 const head = document.createElement("div");
11793 head.style.cssText = "font-size:13px;font-weight:600;margin:0 0 10px;";
11794 head.textContent = __("Active sessions & app access");
11795 card.appendChild(head);
11796 const grid = document.createElement("div");
11797 grid.style.cssText = "display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:12px;";
11798 const sessionTile = document.createElement("div");
11799 sessionTile.style.cssText = "display:flex;flex-direction:column;gap:2px;font-size:12px;";
11800 const sessionLabel = document.createElement("div");
11801 sessionLabel.style.cssText = "color:var(--desktop-mode-muted, #50575e);font-size:11px;text-transform:uppercase;letter-spacing:0.04em;font-weight:600;";
11802 sessionLabel.textContent = __("Active sessions");
11803 const sessionValue = document.createElement("div");
11804 sessionValue.style.cssText = "font-size:18px;font-weight:600;";
11805 sessionValue.textContent = String(data.sessions.length);
11806 const sessionSub = document.createElement("div");
11807 sessionSub.style.cssText = "color:var(--desktop-mode-muted, #8c8f94);";
11808 const currentCount = data.sessions.filter((s) => s.current).length;
11809 sessionSub.textContent = currentCount > 0 ? __("Includes the current device.") : __("Logged in across multiple devices.");
11810 sessionTile.appendChild(sessionLabel);
11811 sessionTile.appendChild(sessionValue);
11812 sessionTile.appendChild(sessionSub);
11813 grid.appendChild(sessionTile);
11814 const appTile = document.createElement("div");
11815 appTile.style.cssText = "display:flex;flex-direction:column;gap:2px;font-size:12px;";
11816 const appLabel = document.createElement("div");
11817 appLabel.style.cssText = "color:var(--desktop-mode-muted, #50575e);font-size:11px;text-transform:uppercase;letter-spacing:0.04em;font-weight:600;";
11818 appLabel.textContent = __("Application passwords");
11819 const appValue = document.createElement("div");
11820 appValue.style.cssText = "font-size:18px;font-weight:600;";
11821 appValue.textContent = String(data.applicationPasswords.total);
11822 const appSub = document.createElement("div");
11823 appSub.style.cssText = "color:var(--desktop-mode-muted, #8c8f94);";
11824 if (data.applicationPasswords.lastUsedAt && data.applicationPasswords.lastUsedName) {
11825 appSub.textContent = sprintf(
11826 // translators: %1$s is the app password name, %2$s is a relative time.
11827 __('"%1$s" last used %2$s'),
11828 data.applicationPasswords.lastUsedName,
11829 relativeTime$1(data.applicationPasswords.lastUsedAt)
11830 );
11831 } else {
11832 appSub.textContent = data.applicationPasswords.total ? __("No recent use.") : __("No app passwords issued yet.");
11833 }
11834 appTile.appendChild(appLabel);
11835 appTile.appendChild(appValue);
11836 appTile.appendChild(appSub);
11837 grid.appendChild(appTile);
11838 card.appendChild(grid);
11839 return card;
11840 }
11841 function textField(formName, label, value, opts = {}) {
11842 const el = document.createElement("wpd-text-field");
11843 el.setAttribute("name", formName);
11844 el.setAttribute("label", label);
11845 el.setAttribute("value", value);
11846 el.value = value;
11847 if (opts.required) {
11848 el.setAttribute("required", "");
11849 }
11850 if (opts.readonly) {
11851 el.setAttribute("readonly", "");
11852 }
11853 if (opts.type) {
11854 el.setAttribute("type", opts.type);
11855 }
11856 if (opts.fullWidth !== false && opts.fullWidth) {
11857 el.setAttribute("full-width", "");
11858 }
11859 if (opts.dataset) {
11860 for (const [k, v] of Object.entries(opts.dataset)) {
11861 el.dataset[k] = v;
11862 }
11863 }
11864 return el;
11865 }
11866 function displayNameCandidates(user) {
11867 const candidates = /* @__PURE__ */ new Set();
11868 const add = (s) => {
11869 const t = s.trim();
11870 if (t !== "") {
11871 candidates.add(t);
11872 }
11873 };
11874 add(user.username);
11875 add(user.nickname ?? "");
11876 add(user.first_name);
11877 add(user.last_name);
11878 if (user.first_name || user.last_name) {
11879 add(`${user.first_name} ${user.last_name}`.trim());
11880 add(`${user.last_name} ${user.first_name}`.trim());
11881 }
11882 if (user.name) {
11883 add(user.name);
11884 }
11885 return Array.from(candidates).map((name) => ({
11886 value: name,
11887 label: name
11888 }));
11889 }
11890 function relativeFromIso(iso) {
11891 const ms = msFromIso(iso);
11892 if (!Number.isFinite(ms)) {
11893 return "—";
11894 }
11895 return relativeTime$1(Math.floor(ms / 1e3));
11896 }
11897 function relativeTime$1(ts) {
11898 if (!Number.isFinite(ts)) {
11899 return "—";
11900 }
11901 const now = Math.floor(Date.now() / 1e3);
11902 const delta = now - ts;
11903 if (delta < 60) {
11904 return __("just now");
11905 }
11906 if (delta < 3600) {
11907 return sprintf(__("%d min ago"), Math.floor(delta / 60));
11908 }
11909 if (delta < 86400) {
11910 return sprintf(__("%d h ago"), Math.floor(delta / 3600));
11911 }
11912 if (delta < 86400 * 30) {
11913 return sprintf(__("%d d ago"), Math.floor(delta / 86400));
11914 }
11915 if (delta < 86400 * 365) {
11916 return sprintf(__("%d mo ago"), Math.floor(delta / (86400 * 30)));
11917 }
11918 return sprintf(__("%d y ago"), Math.floor(delta / (86400 * 365)));
11919 }
11920 function msFromIso(iso) {
11921 if (!iso) {
11922 return NaN;
11923 }
11924 if (iso.startsWith("0000-00-00")) {
11925 return NaN;
11926 }
11927 let normalized = iso;
11928 if (normalized.includes(" ")) {
11929 normalized = normalized.replace(" ", "T");
11930 }
11931 if (!/Z$/.test(normalized) && !/[+-]\d{2}:?\d{2}$/.test(normalized)) {
11932 normalized += "Z";
11933 }
11934 const parsed = Date.parse(normalized);
11935 return Number.isFinite(parsed) ? parsed : NaN;
11936 }
11937 function generateStrongPassword$1(length) {
11938 const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
11939 const lower = "abcdefghjkmnpqrstuvwxyz";
11940 const digits = "23456789";
11941 const symbols = "!@#$%^&*-_=+";
11942 const all = upper + lower + digits + symbols;
11943 const buf = new Uint32Array(length);
11944 crypto.getRandomValues(buf);
11945 let out = "";
11946 for (let i = 0; i < length; i += 1) {
11947 out += all[buf[i] % all.length];
11948 }
11949 return out;
11950 }
11951 function mapErrorCode(code) {
11952 switch (code) {
11953 case "rest_user_invalid_email":
11954 case "invalid_email":
11955 return __("Email address is not valid.");
11956 case "rest_user_email_exists":
11957 case "existing_user_email":
11958 return __("That email is already in use.");
11959 case "rest_user_invalid_role":
11960 return __("You are not allowed to assign that role.");
11961 default:
11962 return null;
11963 }
11964 }
11965 function applyColorSchemePreview(slug, info) {
11966 if (!info.url) {
11967 flipBodyClass(slug);
11968 flipShellScheme(slug);
11969 return;
11970 }
11971 let link = document.getElementById(
11972 "colors-css"
11973 );
11974 if (!link) {
11975 link = document.createElement("link");
11976 link.rel = "stylesheet";
11977 link.id = "colors-css";
11978 document.head.appendChild(link);
11979 }
11980 link.href = info.url;
11981 flipBodyClass(slug);
11982 flipShellScheme(slug);
11983 }
11984 function flipShellScheme(slug) {
11985 const shell = document.querySelector(".desktop-mode-shell");
11986 if (shell) {
11987 shell.setAttribute("data-desktop-mode-scheme", slug);
11988 }
11989 }
11990 function flipBodyClass(slug) {
11991 const body = document.body;
11992 const next = `admin-color-${slug}`;
11993 for (const cls of Array.from(body.classList)) {
11994 if (cls.startsWith("admin-color-") && cls !== next) {
11995 body.classList.remove(cls);
11996 }
11997 }
11998 body.classList.add(next);
11999 }
12000 function buildAdminColorPicker(schemes, current, opts = {}) {
12001 const wrap = document.createElement("div");
12002 wrap.setAttribute("full-width", "");
12003 wrap.style.cssText = "display:flex;flex-direction:column;gap:6px;";
12004 const label = document.createElement("span");
12005 label.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:var(--desktop-mode-muted, #50575e);font-weight:600;";
12006 label.textContent = __("Admin colour scheme");
12007 wrap.appendChild(label);
12008 const hidden = document.createElement("wpd-text-field");
12009 hidden.setAttribute("name", "meta.admin_color");
12010 hidden.setAttribute("value", current);
12011 hidden.value = current;
12012 hidden.style.display = "none";
12013 wrap.appendChild(hidden);
12014 const grid = document.createElement("div");
12015 grid.style.cssText = [
12016 "display:grid",
12017 "grid-template-columns:repeat(auto-fill, minmax(140px, 1fr))",
12018 "gap:8px"
12019 ].join(";");
12020 wrap.appendChild(grid);
12021 let selected = current;
12022 const updateSelected = (slug) => {
12023 selected = slug;
12024 hidden.value = slug;
12025 hidden.setAttribute("value", slug);
12026 for (const t of Array.from(grid.children)) {
12027 const tile = t;
12028 const v = tile.dataset.scheme;
12029 tile.style.borderColor = v === slug ? "var(--wp-admin-theme-color, #2271b1)" : "var(--desktop-mode-border, #dcdcde)";
12030 tile.style.boxShadow = v === slug ? "0 0 0 1px var(--wp-admin-theme-color, #2271b1) inset" : "none";
12031 tile.setAttribute("aria-checked", v === slug ? "true" : "false");
12032 }
12033 };
12034 for (const [slug, info] of Object.entries(schemes)) {
12035 const tile = document.createElement("button");
12036 tile.type = "button";
12037 tile.setAttribute("role", "radio");
12038 tile.setAttribute("aria-checked", slug === selected ? "true" : "false");
12039 tile.dataset.scheme = slug;
12040 tile.style.cssText = [
12041 "appearance:none",
12042 "border:1px solid var(--desktop-mode-border, #dcdcde)",
12043 "background:var(--wp-admin-theme-bg, #fff)",
12044 "color:inherit",
12045 "border-radius:8px",
12046 "padding:10px 10px 8px",
12047 "cursor:pointer",
12048 "display:flex",
12049 "flex-direction:column",
12050 "gap:6px",
12051 "text-align:left",
12052 "min-width:0",
12053 "transition:border-color 120ms ease, box-shadow 120ms ease"
12054 ].join(";");
12055 const swatchRow = document.createElement("span");
12056 swatchRow.style.cssText = "display:flex;height:18px;border-radius:4px;overflow:hidden;border:1px solid rgba(0,0,0,0.06);";
12057 const colors = (info.colors ?? []).slice(0, 4);
12058 if (colors.length === 0) {
12059 colors.push("#dcdcde", "#dcdcde", "#dcdcde");
12060 }
12061 for (const color of colors) {
12062 const swatch = document.createElement("span");
12063 swatch.style.cssText = `flex:1 1 auto;background:${color};`;
12064 swatchRow.appendChild(swatch);
12065 }
12066 tile.appendChild(swatchRow);
12067 const name = document.createElement("span");
12068 name.style.cssText = "font-size:12px;font-weight:500;";
12069 name.textContent = info.name;
12070 tile.appendChild(name);
12071 tile.addEventListener("click", () => {
12072 updateSelected(slug);
12073 if (opts.livePreview) {
12074 applyColorSchemePreview(slug, info);
12075 }
12076 });
12077 grid.appendChild(tile);
12078 }
12079 updateSelected(selected);
12080 return wrap;
12081 }
12082 function checkboxField(name, label, checked, opts = {}) {
12083 const trueValue = opts.trueValue ?? "true";
12084 const falseValue = opts.falseValue ?? "false";
12085 const wrap = document.createElement("span");
12086 if (opts.fullWidth) {
12087 wrap.setAttribute("full-width", "");
12088 }
12089 const cb = document.createElement("wpd-checkbox-label");
12090 cb.setAttribute("label", label);
12091 cb.setAttribute("name", name);
12092 cb.setAttribute("value", checked ? trueValue : falseValue);
12093 cb.value = checked ? trueValue : falseValue;
12094 if (checked) {
12095 cb.setAttribute("checked", "");
12096 }
12097 cb.addEventListener("wpd-checkbox-change", (e) => {
12098 const detail = e.detail;
12099 const v = detail?.checked ? trueValue : falseValue;
12100 cb.value = v;
12101 cb.setAttribute("value", v);
12102 });
12103 wrap.appendChild(cb);
12104 return wrap;
12105 }
12106 function buildSessionsRow(userId, isSelfEdit) {
12107 const wrap = document.createElement("div");
12108 wrap.setAttribute("full-width", "");
12109 wrap.style.cssText = "display:flex;align-items:center;gap:12px;flex-wrap:wrap;";
12110 const label = document.createElement("span");
12111 label.style.cssText = "font-size:13px;color:var(--desktop-mode-fg, inherit);";
12112 label.textContent = __("Active sessions");
12113 wrap.appendChild(label);
12114 const btn = document.createElement("wpd-button");
12115 btn.setAttribute("variant", "ghost");
12116 btn.setAttribute("type", "button");
12117 btn.textContent = isSelfEdit ? __("Log out everywhere else") : __("Log this user out everywhere");
12118 btn.addEventListener("click", async (e) => {
12119 e.preventDefault();
12120 try {
12121 const cfg = resolveUserEditClient().getConfig();
12122 const base = cfg.insightsUrlBase ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/");
12123 const res = await trackedFetch(
12124 joinRestUrl(base, `${userId}/destroy-sessions`),
12125 {
12126 method: "POST",
12127 credentials: "same-origin",
12128 headers: {
12129 "Content-Type": "application/json",
12130 "X-WP-Nonce": cfg.restNonce
12131 },
12132 body: JSON.stringify({
12133 scope: isSelfEdit ? "others" : "all"
12134 })
12135 },
12136 { source: "user-edit-window/destroy-sessions" }
12137 );
12138 if (!res.ok) {
12139 throw new Error(`http_${res.status}`);
12140 }
12141 notifyToast$1(__("Sessions destroyed."), "success");
12142 } catch (err) {
12143 notifyToast$1(
12144 sprintf(
12145 // translators: %s is an error message.
12146 __("Could not destroy sessions (%s)."),
12147 String(err.message ?? err)
12148 ),
12149 "error"
12150 );
12151 }
12152 });
12153 wrap.appendChild(btn);
12154 return wrap;
12155 }
12156 function buildAppPasswordsRow(userId) {
12157 const wrap = document.createElement("div");
12158 wrap.setAttribute("full-width", "");
12159 wrap.style.cssText = "display:flex;flex-direction:column;gap:8px;border:1px solid var(--desktop-mode-border, #dcdcde);border-radius:8px;padding:12px 14px;";
12160 const heading = document.createElement("div");
12161 heading.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:8px;";
12162 const headLabel = document.createElement("span");
12163 headLabel.textContent = __("Application passwords");
12164 headLabel.style.cssText = "font-size:13px;font-weight:600;";
12165 heading.appendChild(headLabel);
12166 wrap.appendChild(heading);
12167 const cfg = resolveUserEditClient().getConfig();
12168 const base = cfg.insightsUrlBase ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/");
12169 const list = document.createElement("ul");
12170 list.style.cssText = "list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px;";
12171 wrap.appendChild(list);
12172 const createRow = document.createElement("div");
12173 createRow.style.cssText = "display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-top:6px;";
12174 const nameInput = document.createElement("wpd-text-field");
12175 nameInput.setAttribute("label", __("New application password name"));
12176 nameInput.setAttribute(
12177 "placeholder",
12178 __("e.g. iPhone, WP-CLI, Backup tool")
12179 );
12180 nameInput.style.flex = "1 1 220px";
12181 createRow.appendChild(nameInput);
12182 const createBtn = document.createElement("wpd-button");
12183 createBtn.setAttribute("variant", "primary");
12184 createBtn.setAttribute("type", "button");
12185 createBtn.textContent = __("Create");
12186 createRow.appendChild(createBtn);
12187 wrap.appendChild(createRow);
12188 const renderItems = (items) => {
12189 list.replaceChildren();
12190 if (items.length === 0) {
12191 const empty = document.createElement("li");
12192 empty.style.cssText = "font-size:12px;color:var(--desktop-mode-muted, #50575e);";
12193 empty.textContent = __("No application passwords issued yet.");
12194 list.appendChild(empty);
12195 return;
12196 }
12197 for (const item of items) {
12198 const row = document.createElement("li");
12199 row.style.cssText = "display:flex;align-items:center;gap:8px;font-size:12px;";
12200 const nameSpan = document.createElement("span");
12201 nameSpan.style.cssText = "flex:1 1 auto;font-weight:500;";
12202 nameSpan.textContent = item.name;
12203 row.appendChild(nameSpan);
12204 const meta = document.createElement("span");
12205 meta.style.cssText = "color:var(--desktop-mode-muted, #8c8f94);";
12206 meta.textContent = item.last_used ? sprintf(
12207 // translators: %s is a relative time.
12208 __("last used %s"),
12209 relativeTime$1(item.last_used)
12210 ) : __("never used");
12211 row.appendChild(meta);
12212 const revoke = document.createElement("wpd-button");
12213 revoke.setAttribute("variant", "ghost");
12214 revoke.setAttribute("type", "button");
12215 revoke.textContent = __("Revoke");
12216 revoke.addEventListener("click", async (e) => {
12217 e.preventDefault();
12218 try {
12219 const res = await trackedFetch(
12220 joinRestUrl(base, `${userId}/application-passwords/${item.uuid}`),
12221 {
12222 method: "DELETE",
12223 credentials: "same-origin",
12224 headers: { "X-WP-Nonce": cfg.restNonce }
12225 },
12226 { source: "user-edit-window/app-pw-revoke" }
12227 );
12228 if (!res.ok) {
12229 throw new Error(`http_${res.status}`);
12230 }
12231 row.remove();
12232 notifyToast$1(__("Application password revoked."), "success");
12233 } catch (err) {
12234 notifyToast$1(
12235 String(err.message ?? err),
12236 "error"
12237 );
12238 }
12239 });
12240 row.appendChild(revoke);
12241 list.appendChild(row);
12242 }
12243 };
12244 const refresh = async () => {
12245 try {
12246 const res = await trackedFetch(
12247 joinRestUrl(base, `${userId}/application-passwords`),
12248 {
12249 credentials: "same-origin",
12250 headers: { "X-WP-Nonce": cfg.restNonce }
12251 },
12252 { source: "user-edit-window/app-pw-list", silent: true }
12253 );
12254 if (!res.ok) {
12255 return;
12256 }
12257 const data = await res.json();
12258 renderItems(data.items ?? []);
12259 } catch {
12260 }
12261 };
12262 void refresh();
12263 createBtn.addEventListener("click", async (e) => {
12264 e.preventDefault();
12265 const name = String(nameInput.value ?? "").trim();
12266 if (!name) {
12267 notifyToast$1(__("Application password name is required."), "error");
12268 return;
12269 }
12270 try {
12271 const res = await trackedFetch(
12272 joinRestUrl(base, `${userId}/application-passwords`),
12273 {
12274 method: "POST",
12275 credentials: "same-origin",
12276 headers: {
12277 "Content-Type": "application/json",
12278 "X-WP-Nonce": cfg.restNonce
12279 },
12280 body: JSON.stringify({ name })
12281 },
12282 { source: "user-edit-window/app-pw-create" }
12283 );
12284 if (!res.ok) {
12285 throw new Error(`http_${res.status}`);
12286 }
12287 const data = await res.json();
12288 notifyToast$1(
12289 sprintf(
12290 // translators: %s is an application password.
12291 __("Created. Copy the password now: %s"),
12292 data.password
12293 ),
12294 "success"
12295 );
12296 void navigator.clipboard?.writeText(data.password).catch(() => {
12297 });
12298 nameInput.value = "";
12299 nameInput.setAttribute("value", "");
12300 void refresh();
12301 } catch (err) {
12302 notifyToast$1(
12303 String(err.message ?? err),
12304 "error"
12305 );
12306 }
12307 });
12308 return wrap;
12309 }
12310 const userEditRender = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
12311 __proto__: null,
12312 mountProfileActivityAt,
12313 mountProfileAsideAt,
12314 mountProfileFormAt
12315 }, Symbol.toStringTag, { value: "Module" }));
12316 async function showPagesIntroDialog() {
12317 return new Promise((resolve) => {
12318 const backdrop = document.createElement("div");
12319 backdrop.className = "desktop-mode-pages-intro__backdrop";
12320 backdrop.setAttribute("role", "presentation");
12321 Object.assign(backdrop.style, {
12322 position: "fixed",
12323 inset: "0",
12324 background: "color-mix(in srgb, var(--wp-admin-theme-color, #1d2327) 60%, transparent)",
12325 backdropFilter: "blur(2px)",
12326 zIndex: "100000",
12327 display: "flex",
12328 alignItems: "center",
12329 justifyContent: "center",
12330 padding: "24px"
12331 });
12332 const dialog = document.createElement("div");
12333 dialog.setAttribute("role", "dialog");
12334 dialog.setAttribute("aria-modal", "true");
12335 dialog.setAttribute("aria-labelledby", "desktop-mode-pages-intro-title");
12336 dialog.className = "desktop-mode-pages-intro";
12337 Object.assign(dialog.style, {
12338 background: "var(--wp-admin-theme-bg, #fff)",
12339 color: "var(--wp-admin-theme-fg, #1d2327)",
12340 borderRadius: "14px",
12341 boxShadow: "0 24px 60px rgba(0,0,0,.28)",
12342 maxWidth: "520px",
12343 width: "100%",
12344 maxHeight: "90vh",
12345 overflow: "auto",
12346 padding: "28px 32px 24px",
12347 fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
12348 });
12349 dialog.innerHTML = renderDialogMarkup$1();
12350 backdrop.appendChild(dialog);
12351 document.body.appendChild(backdrop);
12352 const primaryBtn = dialog.querySelector(
12353 '[data-action="confirm"]'
12354 );
12355 const settingsBtn = dialog.querySelector(
12356 '[data-action="settings"]'
12357 );
12358 primaryBtn?.focus();
12359 let resolved = false;
12360 const cleanup = (result) => {
12361 if (resolved) {
12362 return;
12363 }
12364 resolved = true;
12365 document.removeEventListener("keydown", onKey, true);
12366 backdrop.remove();
12367 resolve(result);
12368 };
12369 const onKey = (e) => {
12370 if (e.key === "Escape") {
12371 e.preventDefault();
12372 cleanup("cancel");
12373 }
12374 };
12375 document.addEventListener("keydown", onKey, true);
12376 backdrop.addEventListener("click", (e) => {
12377 if (e.target === backdrop) {
12378 cleanup("cancel");
12379 }
12380 });
12381 primaryBtn?.addEventListener("click", () => cleanup("confirm"));
12382 settingsBtn?.addEventListener("click", () => cleanup("settings"));
12383 });
12384 }
12385 function renderDialogMarkup$1() {
12386 const title = __("Welcome to the new Pages window");
12387 const lede = __(
12388 "You're looking at the redesigned Pages list — same data you already manage, with a UX tuned for how Desktop Mode wants you to work."
12389 );
12390 const highlights = [
12391 __("Sticky header and sticky title column so long lists stay readable as you scroll."),
12392 __('Front page and Posts page badges right on the title — no more "wait, which one is the homepage?".'),
12393 __("Page Template column so you can spot which template each page uses at a glance."),
12394 __("Slug column with one-click copy — perfect when configuring redirects or sharing canonical URLs."),
12395 __("Comments column, Parent column, View link, lock indicator, multi-select bulk actions, inline search, status segments. All in one screen, no reloads.")
12396 ];
12397 const li = (arr) => arr.map(
12398 (s) => `<li><span class="dot" aria-hidden="true"></span>${escapeHtml$1(s)}</li>`
12399 ).join("");
12400 return `
12401 <style>
12402 .desktop-mode-pages-intro h2 {
12403 margin: 0 0 8px;
12404 font-size: 22px;
12405 font-weight: 600;
12406 letter-spacing: -0.01em;
12407 }
12408 .desktop-mode-pages-intro p.lede {
12409 margin: 0 0 20px;
12410 color: var(--wp-admin-theme-fg-muted, #50575e);
12411 font-size: 14px;
12412 line-height: 1.5;
12413 }
12414 .desktop-mode-pages-intro__list {
12415 list-style: none;
12416 margin: 0 0 22px;
12417 padding: 0;
12418 font-size: 14px;
12419 line-height: 1.5;
12420 }
12421 .desktop-mode-pages-intro__list li {
12422 display: flex;
12423 align-items: flex-start;
12424 gap: 10px;
12425 padding: 6px 0;
12426 }
12427 .desktop-mode-pages-intro__list .dot {
12428 flex: 0 0 auto;
12429 width: 6px;
12430 height: 6px;
12431 margin-top: 9px;
12432 border-radius: 50%;
12433 background: var(--wp-admin-theme-color, #2271b1);
12434 }
12435 .desktop-mode-pages-intro__footer {
12436 display: flex;
12437 justify-content: flex-end;
12438 gap: 8px;
12439 margin-top: 8px;
12440 }
12441 .desktop-mode-pages-intro__footer button {
12442 appearance: none;
12443 border: 1px solid var(--wp-admin-theme-border, #dcdcde);
12444 background: var(--wp-admin-theme-bg, #fff);
12445 color: inherit;
12446 padding: 8px 14px;
12447 border-radius: 6px;
12448 font-size: 13px;
12449 cursor: pointer;
12450 }
12451 .desktop-mode-pages-intro__footer button.primary {
12452 border-color: var(--wp-admin-theme-color, #2271b1);
12453 background: var(--wp-admin-theme-color, #2271b1);
12454 color: #fff;
12455 font-weight: 500;
12456 }
12457 .desktop-mode-pages-intro__footer button:hover { filter: brightness(1.05); }
12458 .desktop-mode-pages-intro__footer button:focus-visible {
12459 outline: 2px solid var(--wp-admin-theme-color, #2271b1);
12460 outline-offset: 2px;
12461 }
12462 </style>
12463 <h2 id="desktop-mode-pages-intro-title">${escapeHtml$1(title)}</h2>
12464 <p class="lede">${escapeHtml$1(lede)}</p>
12465 <ul class="desktop-mode-pages-intro__list">${li(highlights)}</ul>
12466 <div class="desktop-mode-pages-intro__footer">
12467 <button type="button" data-action="settings">${escapeHtml$1(
12468 __("Take me to settings")
12469 )}</button>
12470 <button type="button" class="primary" data-action="confirm">${escapeHtml$1(
12471 __("Got it")
12472 )}</button>
12473 </div>
12474 `;
12475 }
12476 function escapeHtml$1(s) {
12477 const t = document.createElement("div");
12478 t.textContent = s;
12479 return t.innerHTML;
12480 }
12481 const pagesIntroDialog = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
12482 __proto__: null,
12483 showPagesIntroDialog
12484 }, Symbol.toStringTag, { value: "Module" }));
12485 const REPULSION_K = 5500;
12486 const SPRING_K = 0.05;
12487 const SPRING_LEN = 130;
12488 const MIN_RADIUS = 22;
12489 const MAX_RADIUS = 48;
12490 const POST_PER_PAGE$1 = 10;
12491 const POST_RING_RADIUS$1 = 170;
12492 async function mountCategoriesMindmap(host, client) {
12493 const api = window.wp?.desktop;
12494 if (!api || typeof api.loadModules !== "function") {
12495 host.textContent = __("Mindmap unavailable: shell modules API missing.");
12496 return () => {
12497 };
12498 }
12499 try {
12500 await api.loadModules(["pixijs"]);
12501 } catch {
12502 host.textContent = __("Mindmap unavailable.");
12503 return () => {
12504 };
12505 }
12506 const pixiMaybe = window.PIXI;
12507 if (!pixiMaybe) {
12508 host.textContent = __("Mindmap unavailable.");
12509 return () => {
12510 };
12511 }
12512 const pixi = pixiMaybe;
12513 host.replaceChildren();
12514 host.classList.add("wpd-mindmap");
12515 const toolbar = document.createElement("div");
12516 toolbar.className = "wpd-mindmap__toolbar";
12517 const addRootBtn = document.createElement("button");
12518 addRootBtn.type = "button";
12519 addRootBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--primary";
12520 addRootBtn.innerHTML = '<span class="dashicons dashicons-plus" aria-hidden="true"></span>' + __("Add root category");
12521 const recenterBtn = document.createElement("button");
12522 recenterBtn.type = "button";
12523 recenterBtn.className = "wpd-mindmap__btn";
12524 recenterBtn.innerHTML = '<span class="dashicons dashicons-image-rotate" aria-hidden="true"></span>' + __("Recenter");
12525 const searchWrap = document.createElement("div");
12526 searchWrap.className = "wpd-mindmap__search";
12527 const searchInput = document.createElement("input");
12528 searchInput.type = "search";
12529 searchInput.className = "wpd-mindmap__search-input";
12530 searchInput.placeholder = __("Search categories…");
12531 searchInput.setAttribute(
12532 "aria-label",
12533 __("Search categories in the mindmap")
12534 );
12535 searchWrap.appendChild(searchInput);
12536 const searchResults = document.createElement("ul");
12537 searchResults.className = "wpd-mindmap__search-results";
12538 searchResults.hidden = true;
12539 searchWrap.appendChild(searchResults);
12540 const hint = document.createElement("span");
12541 hint.className = "wpd-mindmap__hint";
12542 hint.textContent = __(
12543 "Click a node to focus + edit · drag onto another to reparent · wheel to zoom"
12544 );
12545 toolbar.appendChild(addRootBtn);
12546 toolbar.appendChild(recenterBtn);
12547 toolbar.appendChild(searchWrap);
12548 toolbar.appendChild(hint);
12549 host.appendChild(toolbar);
12550 const layout = document.createElement("div");
12551 layout.className = "wpd-mindmap__layout";
12552 host.appendChild(layout);
12553 const stage = document.createElement("div");
12554 stage.className = "wpd-mindmap__stage";
12555 stage.classList.add("is-loading");
12556 layout.appendChild(stage);
12557 const sidebar = document.createElement("aside");
12558 sidebar.className = "wpd-mindmap__sidebar";
12559 layout.appendChild(sidebar);
12560 const app = new pixi.Application();
12561 await app.init({
12562 resizeTo: stage,
12563 backgroundAlpha: 0,
12564 antialias: true,
12565 autoDensity: true,
12566 resolution: Math.min(window.devicePixelRatio || 1, 2)
12567 });
12568 stage.appendChild(app.canvas);
12569 app.canvas.classList.add("wpd-mindmap__canvas");
12570 const world = new pixi.Container();
12571 world.x = stage.clientWidth / 2;
12572 world.y = stage.clientHeight / 2;
12573 app.stage.addChild(world);
12574 const edgeLayer = new pixi.Container();
12575 const nodeLayer = new pixi.Container();
12576 const postEdgeLayer = new pixi.Container();
12577 const postLayer = new pixi.Container();
12578 const chipLayer = new pixi.Container();
12579 const postChipLayer = new pixi.Container();
12580 world.addChild(edgeLayer);
12581 world.addChild(postEdgeLayer);
12582 world.addChild(postLayer);
12583 world.addChild(nodeLayer);
12584 world.addChild(chipLayer);
12585 world.addChild(postChipLayer);
12586 const edgeGfx = new pixi.Graphics();
12587 edgeLayer.addChild(edgeGfx);
12588 const postEdgeGfx = new pixi.Graphics();
12589 postEdgeLayer.addChild(postEdgeGfx);
12590 const CHIP_TEXT_RES2 = 4;
12591 const pager = new pixi.Container();
12592 pager.eventMode = "passive";
12593 pager.visible = false;
12594 postLayer.addChild(pager);
12595 const pagerPrev = new pixi.Graphics();
12596 const pagerNext = new pixi.Graphics();
12597 const pagerLabel = new pixi.Text({
12598 text: "1 / 1",
12599 style: {
12600 fill: 5265246,
12601 fontSize: 14,
12602 fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
12603 fontWeight: "600"
12604 },
12605 resolution: CHIP_TEXT_RES2
12606 });
12607 pagerLabel.anchor.set(0.5);
12608 pagerPrev.eventMode = "static";
12609 pagerPrev.cursor = "pointer";
12610 pagerNext.eventMode = "static";
12611 pagerNext.cursor = "pointer";
12612 pagerPrev.hitArea = new pixi.Circle(0, 0, 16);
12613 pagerNext.hitArea = new pixi.Circle(0, 0, 16);
12614 pager.addChild(pagerPrev);
12615 pager.addChild(pagerLabel);
12616 pager.addChild(pagerNext);
12617 const stopBubble = (e) => {
12618 e.stopPropagation?.();
12619 pixiInteractionAt = performance.now();
12620 };
12621 pagerPrev.on("pointerdown", stopBubble);
12622 pagerNext.on("pointerdown", stopBubble);
12623 pagerPrev.on("pointertap", (e) => {
12624 stopBubble(e);
12625 lastFocusChange = performance.now();
12626 if (focusPage <= 1) {
12627 return;
12628 }
12629 focusPage--;
12630 void loadPostsForFocus();
12631 });
12632 pagerNext.on("pointertap", (e) => {
12633 stopBubble(e);
12634 lastFocusChange = performance.now();
12635 if (focusPage >= focusTotalPages) {
12636 return;
12637 }
12638 focusPage++;
12639 void loadPostsForFocus();
12640 });
12641 const nodes = /* @__PURE__ */ new Map();
12642 const chips = /* @__PURE__ */ new Map();
12643 const postChips = /* @__PURE__ */ new Map();
12644 const postNodes = /* @__PURE__ */ new Map();
12645 let focusId = null;
12646 let focusPage = 1;
12647 let focusTotalPages = 1;
12648 let loadSeq = 0;
12649 let pixiInteractionAt = 0;
12650 let dragNode = null;
12651 let dragHover = null;
12652 let panActive = false;
12653 let panStart = null;
12654 let panMovedDist = 0;
12655 let raf = null;
12656 let lastTick = performance.now();
12657 let targetScale = world.scale.x;
12658 let targetWorldX = world.x;
12659 let targetWorldY = world.y;
12660 let nudgeAwayFrom = null;
12661 const pinnedTargetBackup = /* @__PURE__ */ new Map();
12662 let prevView = null;
12663 let draft = null;
12664 const themeHue = readAdminThemeHue$1();
12665 const clusterColor = (idx) => hslToInt$1((themeHue + idx * 47) % 360, 55, 52);
12666 let terms = [];
12667 try {
12668 const all = [];
12669 let page = 1;
12670 while (page <= 5) {
12671 const res = await client.fetchTerms("categories", { page, perPage: 100 });
12672 all.push(...res.items);
12673 if (page >= res.totalPages) {
12674 break;
12675 }
12676 page++;
12677 }
12678 terms = all;
12679 } catch (err) {
12680 showToast$1(__("Couldn’t load categories:"), err);
12681 }
12682 const showError = (title, err) => showToast$1(title, err);
12683 function isUncategorized(term) {
12684 if (term.isDefault) {
12685 return true;
12686 }
12687 return term.id === 1 || term.slug === "uncategorized" || term.name.toLowerCase() === "uncategorized";
12688 }
12689 function syncEmptyHint() {
12690 const existing = stage.querySelector(".wpd-mindmap__empty");
12691 if (terms.length <= 1) {
12692 if (!existing) {
12693 const empty = document.createElement("div");
12694 empty.className = "wpd-mindmap__empty";
12695 empty.textContent = __(
12696 'No custom categories yet. Click "Add root category" to start branching.'
12697 );
12698 stage.appendChild(empty);
12699 }
12700 } else if (existing) {
12701 existing.remove();
12702 }
12703 }
12704 function buildTree() {
12705 const childMap = /* @__PURE__ */ new Map();
12706 for (const t of terms) {
12707 const list = childMap.get(t.parent) ?? [];
12708 list.push(t);
12709 childMap.set(t.parent, list);
12710 }
12711 const allRoots = childMap.get(0) ?? [];
12712 const roots = allRoots.filter((r) => !isUncategorized(r));
12713 const uncategorized = allRoots.find(isUncategorized);
12714 const place = (term, depth, rootIdx, angle, angleSpan) => {
12715 const rootRingByCount = roots.length > 1 ? 110 + roots.length * 28 : 0;
12716 const rootRing = uncategorized ? Math.max(rootRingByCount, 140) : rootRingByCount;
12717 const baseRadius = depth === 0 ? rootRing : rootRing + 160 + (depth - 1) * 150;
12718 const tx = baseRadius * Math.cos(angle);
12719 const ty = baseRadius * Math.sin(angle);
12720 const radius = nodeRadius(term.count, terms);
12721 const color = depth === 0 ? clusterColor(rootIdx) : nodes.get(term.parent)?.color ?? clusterColor(rootIdx);
12722 let node = nodes.get(term.id);
12723 if (!node) {
12724 const gfx = new pixi.Graphics();
12725 gfx.eventMode = "static";
12726 gfx.cursor = "pointer";
12727 node = {
12728 id: term.id,
12729 parent: term.parent,
12730 name: term.name,
12731 description: term.description,
12732 count: term.count,
12733 x: tx,
12734 y: ty,
12735 tx,
12736 ty,
12737 radius,
12738 depth,
12739 color,
12740 gfx,
12741 pinned: depth === 0
12742 };
12743 nodeLayer.addChild(gfx);
12744 gfx.on("pointerdown", (e) => onNodePointerDown(e, node));
12745 nodes.set(term.id, node);
12746 } else {
12747 node.parent = term.parent;
12748 node.name = term.name;
12749 node.description = term.description;
12750 node.count = term.count;
12751 node.depth = depth;
12752 node.color = color;
12753 node.radius = radius;
12754 node.tx = tx;
12755 node.ty = ty;
12756 node.pinned = depth === 0;
12757 }
12758 drawNodeDisc(node, false);
12759 const kids = childMap.get(term.id) ?? [];
12760 if (kids.length > 0) {
12761 const sub = angleSpan / kids.length;
12762 kids.forEach((child, i) => {
12763 place(
12764 child,
12765 depth + 1,
12766 rootIdx,
12767 angle - angleSpan / 2 + sub * (i + 0.5),
12768 sub * 0.85
12769 );
12770 });
12771 }
12772 };
12773 const liveIds = new Set(terms.map((t) => t.id));
12774 for (const [id, node] of nodes) {
12775 if (!liveIds.has(id)) {
12776 nodeLayer.removeChild(node.gfx);
12777 node.gfx.destroy();
12778 nodes.delete(id);
12779 destroyChip(id);
12780 }
12781 }
12782 const rootCount = Math.max(1, roots.length);
12783 roots.forEach((root, idx) => {
12784 const angle = 2 * Math.PI / rootCount * idx;
12785 place(root, 0, idx, angle, 2 * Math.PI / rootCount);
12786 });
12787 if (uncategorized) {
12788 placeIsolated(uncategorized);
12789 }
12790 syncEmptyHint();
12791 }
12792 function placeIsolated(term) {
12793 const tx = 0;
12794 const ty = 0;
12795 const radius = nodeRadius(term.count, terms);
12796 const color = 9211796;
12797 let node = nodes.get(term.id);
12798 if (!node) {
12799 const gfx = new pixi.Graphics();
12800 gfx.eventMode = "static";
12801 gfx.cursor = "pointer";
12802 node = {
12803 id: term.id,
12804 parent: 0,
12805 name: term.name,
12806 description: term.description,
12807 count: term.count,
12808 x: tx,
12809 y: ty,
12810 tx,
12811 ty,
12812 radius,
12813 depth: 0,
12814 color,
12815 gfx,
12816 pinned: true
12817 };
12818 nodeLayer.addChild(gfx);
12819 gfx.on("pointerdown", (e) => onNodePointerDown(e, node));
12820 nodes.set(term.id, node);
12821 } else {
12822 node.parent = 0;
12823 node.name = term.name;
12824 node.description = term.description;
12825 node.count = term.count;
12826 node.depth = 0;
12827 node.color = color;
12828 node.radius = radius;
12829 node.tx = tx;
12830 node.ty = ty;
12831 node.pinned = true;
12832 }
12833 drawNodeDisc(node, false);
12834 }
12835 function drawCurvedEdge(g, x1, y1, x2, y2, color, opts = {}) {
12836 const dx = x2 - x1;
12837 const cp1x = x1 + dx * 0.5;
12838 const cp1y = y1;
12839 const cp2x = x2 - dx * 0.5;
12840 const cp2y = y2;
12841 const alpha = opts.alpha ?? 0.5;
12842 const width = opts.width ?? 1.5;
12843 if (!opts.dashed) {
12844 g.moveTo(x1, y1);
12845 g.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2);
12846 g.stroke({ color, width, alpha });
12847 return;
12848 }
12849 const sampleAt = (t) => {
12850 const omt = 1 - t;
12851 const px = omt * omt * omt * x1 + 3 * omt * omt * t * cp1x + 3 * omt * t * t * cp2x + t * t * t * x2;
12852 const py = omt * omt * omt * y1 + 3 * omt * omt * t * cp1y + 3 * omt * t * t * cp2y + t * t * t * y2;
12853 return { x: px, y: py };
12854 };
12855 const STEPS = 32;
12856 const phase = opts.dashPhase ?? 0;
12857 const stride = Math.max(1, opts.dashStride ?? 1);
12858 let lastX = x1;
12859 let lastY = y1;
12860 for (let i = 1; i <= STEPS; i++) {
12861 const p = sampleAt(i / STEPS);
12862 const groupIdx = Math.floor((i - 1 + phase) / stride);
12863 const visible = groupIdx % 2 === 0;
12864 if (visible) {
12865 g.moveTo(lastX, lastY);
12866 g.lineTo(p.x, p.y);
12867 g.stroke({ color, width, alpha });
12868 }
12869 lastX = p.x;
12870 lastY = p.y;
12871 }
12872 }
12873 function drawNodeDisc(node, highlighted) {
12874 const g = node.gfx;
12875 g.clear();
12876 const r = node.radius;
12877 if (!highlighted) {
12878 g.circle(0, 5, r);
12879 g.fill({ color: 0, alpha: 0.18 });
12880 }
12881 if (highlighted) {
12882 g.circle(0, 0, r + 10);
12883 g.fill({ color: node.color, alpha: 0.22 });
12884 }
12885 g.circle(0, 0, r);
12886 g.fill(shadeColor(node.color, -0.18));
12887 g.circle(0, -r * 0.06, r * 0.94);
12888 g.fill(node.color);
12889 g.circle(-r * 0.32, -r * 0.42, r * 0.3);
12890 g.fill({ color: 16777215, alpha: 0.32 });
12891 g.circle(0, 0, r);
12892 g.stroke({
12893 color: 16777215,
12894 width: highlighted ? 3 : 2,
12895 alignment: 0
12896 });
12897 g.x = node.x;
12898 g.y = node.y;
12899 g.zIndex = 10;
12900 g.hitArea = new pixi.Circle(0, 0, r + 4);
12901 }
12902 function drawDropTarget(hover, sourceColor) {
12903 drawNodeDisc(hover, false);
12904 const g = hover.gfx;
12905 const t = performance.now();
12906 const pulse = Math.sin(t / 280) * 0.5 + 0.5;
12907 const ringR = hover.radius + 6 + pulse * 5;
12908 g.circle(0, 0, ringR);
12909 g.stroke({
12910 color: sourceColor,
12911 width: 3,
12912 alpha: 0.6 + pulse * 0.35
12913 });
12914 g.circle(0, 0, hover.radius * 0.42);
12915 g.fill({ color: sourceColor, alpha: 0.85 });
12916 g.hitArea = new pixi.Circle(0, 0, hover.radius + 12);
12917 }
12918 function drawEdges() {
12919 edgeGfx.clear();
12920 for (const node of nodes.values()) {
12921 if (!node.parent) {
12922 continue;
12923 }
12924 const parent = nodes.get(node.parent);
12925 if (!parent) {
12926 continue;
12927 }
12928 const isOldLink = dragNode !== null && node === dragNode;
12929 const isFocusEdge = focusId !== null && (node.id === focusId || node.parent === focusId);
12930 const dimMul = focusId !== null && !isFocusEdge ? 0.35 : 1;
12931 drawCurvedEdge(
12932 edgeGfx,
12933 parent.x,
12934 parent.y,
12935 node.x,
12936 node.y,
12937 parent.color,
12938 isOldLink ? { dashed: true, alpha: 0.28 * dimMul } : { alpha: 0.5 * dimMul }
12939 );
12940 }
12941 if (dragNode && dragHover) {
12942 const x1 = dragNode.x;
12943 const y1 = dragNode.y;
12944 const x2 = dragHover.x;
12945 const y2 = dragHover.y;
12946 const targetColor = dragHover.color;
12947 drawCurvedEdge(edgeGfx, x1, y1, x2, y2, targetColor, {
12948 alpha: 0.22,
12949 width: 9
12950 });
12951 const dashPhase = Math.floor(performance.now() / 70);
12952 drawCurvedEdge(edgeGfx, x1, y1, x2, y2, targetColor, {
12953 alpha: 0.95,
12954 width: 2.5,
12955 dashed: true,
12956 dashStride: 2,
12957 dashPhase
12958 });
12959 const pt = performance.now() % 1300 / 1300;
12960 const omt = 1 - pt;
12961 const dx = x2 - x1;
12962 const cp1x = x1 + dx * 0.5;
12963 const cp1y = y1;
12964 const cp2x = x2 - dx * 0.5;
12965 const cp2y = y2;
12966 const px = omt * omt * omt * x1 + 3 * omt * omt * pt * cp1x + 3 * omt * pt * pt * cp2x + pt * pt * pt * x2;
12967 const py = omt * omt * omt * y1 + 3 * omt * omt * pt * cp1y + 3 * omt * pt * pt * cp2y + pt * pt * pt * y2;
12968 edgeGfx.circle(px, py, 5);
12969 edgeGfx.fill({ color: 16777215, alpha: 0.95 });
12970 edgeGfx.stroke({ color: targetColor, width: 2, alpha: 1 });
12971 }
12972 postEdgeGfx.clear();
12973 if (focusId !== null) {
12974 const center = nodes.get(focusId);
12975 if (center) {
12976 for (const post of postNodes.values()) {
12977 postEdgeGfx.moveTo(center.x, center.y);
12978 postEdgeGfx.lineTo(post.x, post.y);
12979 postEdgeGfx.stroke({
12980 color: center.color,
12981 width: 1,
12982 alpha: 0.35
12983 });
12984 }
12985 }
12986 }
12987 }
12988 const FONT_FAMILY2 = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
12989 const CHIP_NAME_MAX_CHARS2 = 18;
12990 const POST_TITLE_MAX_CHARS2 = 22;
12991 function truncateChipName2(name) {
12992 return name.length > CHIP_NAME_MAX_CHARS2 ? name.slice(0, CHIP_NAME_MAX_CHARS2 - 1) + "…" : name;
12993 }
12994 function ensureChip(node) {
12995 const existing = chips.get(node.id);
12996 if (existing) {
12997 return existing;
12998 }
12999 const container = new pixi.Container();
13000 container.eventMode = "static";
13001 container.cursor = "pointer";
13002 const bg = new pixi.Graphics();
13003 container.addChild(bg);
13004 const nameText = new pixi.Text({
13005 text: truncateChipName2(node.name),
13006 style: {
13007 fill: 1909543,
13008 fontSize: 14,
13009 fontFamily: FONT_FAMILY2,
13010 fontWeight: "600"
13011 },
13012 resolution: CHIP_TEXT_RES2
13013 });
13014 container.addChild(nameText);
13015 const countBg = new pixi.Graphics();
13016 container.addChild(countBg);
13017 const countText = new pixi.Text({
13018 text: String(node.count),
13019 style: {
13020 fill: 16777215,
13021 fontSize: 12,
13022 fontFamily: FONT_FAMILY2,
13023 fontWeight: "700"
13024 },
13025 resolution: CHIP_TEXT_RES2
13026 });
13027 container.addChild(countText);
13028 const chip = {
13029 container,
13030 bg,
13031 nameText,
13032 countBg,
13033 countText,
13034 width: 0,
13035 height: 0,
13036 cachedName: "",
13037 cachedCount: -1,
13038 cachedFocused: false,
13039 cachedHover: false,
13040 cachedColor: -1
13041 };
13042 chips.set(node.id, chip);
13043 chipLayer.addChild(container);
13044 container.on("pointerdown", (e) => {
13045 e.stopPropagation?.();
13046 pixiInteractionAt = performance.now();
13047 });
13048 container.on("pointertap", () => {
13049 void focusNode(node.id);
13050 });
13051 container.on("pointerover", () => {
13052 chip.cachedHover = true;
13053 layoutChip(chip, node);
13054 });
13055 container.on("pointerout", () => {
13056 chip.cachedHover = false;
13057 layoutChip(chip, node);
13058 });
13059 return chip;
13060 }
13061 function layoutChip(chip, node) {
13062 const focused = focusId === node.id;
13063 const displayName = truncateChipName2(node.name);
13064 const countStr = String(node.count);
13065 if (chip.nameText.text !== displayName) {
13066 chip.nameText.text = displayName;
13067 }
13068 if (chip.countText.text !== countStr) {
13069 chip.countText.text = countStr;
13070 }
13071 chip.cachedName = displayName;
13072 chip.cachedCount = node.count;
13073 chip.cachedFocused = focused;
13074 chip.cachedColor = node.color;
13075 const padX = 9;
13076 const padY = 3;
13077 const gap = 5;
13078 const countPadX = 5;
13079 const countPadY = 2;
13080 const minBadgeW = 18;
13081 const nameW = chip.nameText.width;
13082 const nameH = chip.nameText.height;
13083 const countW = chip.countText.width;
13084 const countH = chip.countText.height;
13085 const badgeW = Math.max(minBadgeW, countW + countPadX * 2);
13086 const badgeH = countH + countPadY * 2;
13087 const totalW = padX + nameW + gap + badgeW + padX;
13088 const totalH = Math.max(nameH, badgeH) + padY * 2;
13089 chip.width = totalW;
13090 chip.height = totalH;
13091 const left = -totalW / 2;
13092 chip.bg.clear();
13093 chip.bg.roundRect(left, 0, totalW, totalH, totalH / 2);
13094 if (focused) {
13095 chip.bg.fill(node.color);
13096 } else if (chip.cachedHover) {
13097 chip.bg.fill({ color: 16777215, alpha: 0.96 });
13098 chip.bg.stroke({
13099 color: node.color,
13100 width: 1.5,
13101 alpha: 1
13102 });
13103 } else {
13104 chip.bg.fill({ color: 16777215, alpha: 0.88 });
13105 chip.bg.stroke({
13106 color: 0,
13107 width: 1,
13108 alpha: 0.06
13109 });
13110 }
13111 chip.nameText.x = left + padX;
13112 chip.nameText.y = (totalH - nameH) / 2;
13113 chip.nameText.style.fill = focused ? 16777215 : 1909543;
13114 const badgeX = left + padX + nameW + gap;
13115 const badgeY = (totalH - badgeH) / 2;
13116 chip.countBg.clear();
13117 chip.countBg.roundRect(
13118 badgeX,
13119 badgeY,
13120 badgeW,
13121 badgeH,
13122 badgeH / 2
13123 );
13124 chip.countBg.fill(
13125 focused ? { color: 16777215, alpha: 0.25 } : node.color
13126 );
13127 chip.countText.x = badgeX + (badgeW - countW) / 2;
13128 chip.countText.y = badgeY + (badgeH - countH) / 2;
13129 }
13130 function destroyChip(id) {
13131 const chip = chips.get(id);
13132 if (!chip) {
13133 return;
13134 }
13135 chipLayer.removeChild(chip.container);
13136 chip.container.destroy({ children: true });
13137 chips.delete(id);
13138 }
13139 function syncChipPositions() {
13140 const activeIds = new Set(nodes.keys());
13141 for (const id of [...chips.keys()]) {
13142 if (!activeIds.has(id)) {
13143 destroyChip(id);
13144 }
13145 }
13146 const chipCounterScale = 1 / Math.max(0.01, world.scale.x);
13147 const anyFocus = focusId !== null;
13148 for (const node of nodes.values()) {
13149 const chip = ensureChip(node);
13150 chip.container.x = node.x;
13151 chip.container.y = node.y + node.radius + 6;
13152 chip.container.scale.set(chipCounterScale);
13153 const focused = focusId === node.id;
13154 const targetAlpha = !anyFocus || focused ? 1 : 0.4;
13155 if (Math.abs(chip.container.alpha - targetAlpha) > 5e-3) {
13156 chip.container.alpha += (targetAlpha - chip.container.alpha) * 0.18;
13157 } else {
13158 chip.container.alpha = targetAlpha;
13159 }
13160 if (Math.abs(node.gfx.alpha - targetAlpha) > 5e-3) {
13161 node.gfx.alpha += (targetAlpha - node.gfx.alpha) * 0.18;
13162 } else {
13163 node.gfx.alpha = targetAlpha;
13164 }
13165 const displayName = truncateChipName2(node.name);
13166 if (chip.cachedName !== displayName || chip.cachedCount !== node.count || chip.cachedFocused !== focused || chip.cachedColor !== node.color) {
13167 layoutChip(chip, node);
13168 }
13169 }
13170 for (const post of postNodes.values()) {
13171 const chip = postChips.get(post.id);
13172 if (!chip) {
13173 continue;
13174 }
13175 chip.container.x = post.x;
13176 chip.container.y = post.y;
13177 chip.container.scale.set(chipCounterScale);
13178 if (chip.container.alpha < 1) {
13179 chip.container.alpha = Math.min(
13180 1,
13181 chip.container.alpha + 0.18
13182 );
13183 }
13184 }
13185 }
13186 function physicsStep(dt) {
13187 const list = Array.from(nodes.values());
13188 for (const a of list) {
13189 if (a.pinned) {
13190 a.x += (a.tx - a.x) * 0.12;
13191 a.y += (a.ty - a.y) * 0.12;
13192 a.gfx.x = a.x;
13193 a.gfx.y = a.y;
13194 continue;
13195 }
13196 let fx = 0;
13197 let fy = 0;
13198 for (const b of list) {
13199 if (a === b) {
13200 continue;
13201 }
13202 const dx = a.x - b.x;
13203 const dy = a.y - b.y;
13204 const d2 = dx * dx + dy * dy + 1;
13205 const f = REPULSION_K / d2;
13206 const d = Math.sqrt(d2);
13207 fx += dx / d * f;
13208 fy += dy / d * f;
13209 }
13210 const parent = nodes.get(a.parent);
13211 if (parent) {
13212 const dx = parent.x - a.x;
13213 const dy = parent.y - a.y;
13214 const d = Math.sqrt(dx * dx + dy * dy) || 1;
13215 const stretch = d - SPRING_LEN;
13216 fx += dx / d * stretch * SPRING_K;
13217 fy += dy / d * stretch * SPRING_K;
13218 } else {
13219 fx += -a.x * 8e-4;
13220 fy += -a.y * 8e-4;
13221 }
13222 if (nudgeAwayFrom && a.id !== focusId) {
13223 const ndx = a.x - nudgeAwayFrom.x;
13224 const ndy = a.y - nudgeAwayFrom.y;
13225 const nd = Math.sqrt(ndx * ndx + ndy * ndy) || 1;
13226 const limit = nudgeAwayFrom.radius + a.radius;
13227 if (nd < limit) {
13228 const pushK = 18;
13229 fx += ndx / nd * pushK * (limit - nd);
13230 fy += ndy / nd * pushK * (limit - nd);
13231 }
13232 }
13233 if (a !== dragNode) {
13234 a.x += fx * dt * 1e-3 + (a.tx - a.x) * 0.02;
13235 a.y += fy * dt * 1e-3 + (a.ty - a.y) * 0.02;
13236 }
13237 a.gfx.x = a.x;
13238 a.gfx.y = a.y;
13239 }
13240 }
13241 function preSettlePhysics(iterations) {
13242 for (let i = 0; i < iterations; i++) {
13243 physicsStep(16);
13244 }
13245 for (const n of nodes.values()) {
13246 n.tx = n.x;
13247 n.ty = n.y;
13248 }
13249 }
13250 function tick() {
13251 const now = performance.now();
13252 const dt = Math.min(50, now - lastTick);
13253 lastTick = now;
13254 const ZOOM_EASE = 0.22;
13255 const ds = targetScale - world.scale.x;
13256 const dwx = targetWorldX - world.x;
13257 const dwy = targetWorldY - world.y;
13258 if (Math.abs(ds) > 5e-4 || Math.abs(dwx) > 0.5 || Math.abs(dwy) > 0.5) {
13259 world.scale.set(world.scale.x + ds * ZOOM_EASE);
13260 world.x += dwx * ZOOM_EASE;
13261 world.y += dwy * ZOOM_EASE;
13262 }
13263 physicsStep(dt);
13264 for (const p of postNodes.values()) {
13265 p.x += (p.tx - p.x) * 0.18;
13266 p.y += (p.ty - p.y) * 0.18;
13267 p.gfx.x = p.x;
13268 p.gfx.y = p.y;
13269 }
13270 drawEdges();
13271 if (dragNode && dragHover) {
13272 drawDropTarget(dragHover, dragNode.color);
13273 }
13274 syncChipPositions();
13275 raf = requestAnimationFrame(tick);
13276 }
13277 let dragStartPos = null;
13278 let dragOffset = { x: 0, y: 0 };
13279 function onNodePointerDown(e, node) {
13280 const ev = e;
13281 ev.stopPropagation?.();
13282 pixiInteractionAt = performance.now();
13283 dragNode = node;
13284 node.pinned = true;
13285 node.tx = node.x;
13286 node.ty = node.y;
13287 dragStartPos = { x: ev.global.x, y: ev.global.y };
13288 const local = stageToWorld({ x: ev.global.x, y: ev.global.y });
13289 dragOffset = { x: node.x - local.x, y: node.y - local.y };
13290 }
13291 function stageToWorld(global) {
13292 return {
13293 x: (global.x - world.x) / world.scale.x,
13294 y: (global.y - world.y) / world.scale.y
13295 };
13296 }
13297 function onStagePointerDown(e) {
13298 const ev = e;
13299 panActive = true;
13300 panStart = { x: ev.global.x, y: ev.global.y };
13301 panMovedDist = 0;
13302 }
13303 function onStagePointerMove(e) {
13304 const ev = e;
13305 if (dragNode) {
13306 const cursorWorld = stageToWorld(ev.global);
13307 const nx = cursorWorld.x + dragOffset.x;
13308 const ny = cursorWorld.y + dragOffset.y;
13309 dragNode.x = nx;
13310 dragNode.y = ny;
13311 dragNode.tx = nx;
13312 dragNode.ty = ny;
13313 dragNode.gfx.x = nx;
13314 dragNode.gfx.y = ny;
13315 let hover = null;
13316 for (const c of nodes.values()) {
13317 if (c === dragNode) {
13318 continue;
13319 }
13320 const dx = c.x - cursorWorld.x;
13321 const dy = c.y - cursorWorld.y;
13322 if (dx * dx + dy * dy < c.radius * c.radius) {
13323 hover = c;
13324 break;
13325 }
13326 }
13327 if (hover !== dragHover) {
13328 if (dragHover) {
13329 drawNodeDisc(dragHover, focusId === dragHover.id);
13330 }
13331 dragHover = hover;
13332 if (hover && dragNode) {
13333 drawDropTarget(hover, dragNode.color);
13334 }
13335 }
13336 return;
13337 }
13338 if (panActive && panStart) {
13339 const dx = ev.global.x - panStart.x;
13340 const dy = ev.global.y - panStart.y;
13341 world.x += dx;
13342 world.y += dy;
13343 targetWorldX += dx;
13344 targetWorldY += dy;
13345 panMovedDist += Math.sqrt(dx * dx + dy * dy);
13346 panStart = { x: ev.global.x, y: ev.global.y };
13347 }
13348 }
13349 async function onStagePointerUp(e) {
13350 if (dragNode) {
13351 const node = dragNode;
13352 const target = dragHover;
13353 const startPos = dragStartPos;
13354 dragNode = null;
13355 dragHover = null;
13356 dragStartPos = null;
13357 node.pinned = node.depth === 0;
13358 let movement = Infinity;
13359 const ev = e;
13360 if (startPos && ev && ev.global) {
13361 const dx = ev.global.x - startPos.x;
13362 const dy = ev.global.y - startPos.y;
13363 movement = Math.sqrt(dx * dx + dy * dy);
13364 }
13365 if (!target && movement < 2) {
13366 focusNode(node.id);
13367 panActive = false;
13368 panStart = null;
13369 return;
13370 }
13371 if (target && target.id !== node.parent && !isAncestor(node.id, target.id)) {
13372 try {
13373 await client.updateTerm("categories", node.id, {
13374 parent: target.id
13375 });
13376 node.parent = target.id;
13377 terms = terms.map(
13378 (t) => t.id === node.id ? { ...t, parent: target.id } : t
13379 );
13380 buildTree();
13381 } catch (err) {
13382 showError(__("Reparent failed:"), err);
13383 }
13384 } else {
13385 drawNodeDisc(node, focusId === node.id);
13386 if (target) {
13387 drawNodeDisc(target, focusId === target.id);
13388 }
13389 }
13390 }
13391 panActive = false;
13392 panStart = null;
13393 }
13394 app.stage.eventMode = "static";
13395 app.stage.hitArea = new pixi.Rectangle(
13396 0,
13397 0,
13398 stage.clientWidth,
13399 stage.clientHeight
13400 );
13401 app.stage.on("pointerdown", onStagePointerDown);
13402 app.stage.on("pointermove", onStagePointerMove);
13403 app.stage.on("pointerup", (e) => void onStagePointerUp(e));
13404 app.stage.on("pointerupoutside", (e) => void onStagePointerUp(e));
13405 function onWheel(e) {
13406 e.preventDefault();
13407 const SENSITIVITY = 8e-4;
13408 const factor = Math.exp(-e.deltaY * SENSITIVITY);
13409 const prev = targetScale;
13410 const next = Math.max(0.3, Math.min(2.5, prev * factor));
13411 if (Math.abs(next - prev) < 5e-4) {
13412 return;
13413 }
13414 const r = stage.getBoundingClientRect();
13415 const sx = e.clientX - r.left;
13416 const sy = e.clientY - r.top;
13417 const wx = (sx - targetWorldX) / prev;
13418 const wy = (sy - targetWorldY) / prev;
13419 targetScale = next;
13420 targetWorldX = sx - wx * next;
13421 targetWorldY = sy - wy * next;
13422 }
13423 stage.addEventListener("wheel", onWheel, { passive: false });
13424 let firstFitDone = false;
13425 let settledW = 0;
13426 let settledH = 0;
13427 const SETTLE_THRESHOLD_PX = 24;
13428 const SETTLE_DEBOUNCE_MS = 80;
13429 let settleTimer = null;
13430 function onResize() {
13431 const r = stage.getBoundingClientRect();
13432 app.renderer.resize(r.width, r.height);
13433 app.stage.hitArea = new pixi.Rectangle(0, 0, r.width, r.height);
13434 if (!firstFitDone && r.width > 0 && r.height > 0) {
13435 firstFitDone = true;
13436 settledW = r.width;
13437 settledH = r.height;
13438 fitToView();
13439 stage.classList.remove("is-loading");
13440 }
13441 if (settleTimer !== null) {
13442 window.clearTimeout(settleTimer);
13443 }
13444 settleTimer = window.setTimeout(() => {
13445 settleTimer = null;
13446 const cur = stage.getBoundingClientRect();
13447 const dw = Math.abs(cur.width - settledW);
13448 const dh = Math.abs(cur.height - settledH);
13449 if (dw >= SETTLE_THRESHOLD_PX || dh >= SETTLE_THRESHOLD_PX) {
13450 settledW = cur.width;
13451 settledH = cur.height;
13452 recenterCamera();
13453 }
13454 }, SETTLE_DEBOUNCE_MS);
13455 app.render();
13456 }
13457 const ro = new ResizeObserver(onResize);
13458 ro.observe(stage);
13459 function isAncestor(ancestor, descendant) {
13460 let cur = nodes.get(descendant);
13461 let safety = 32;
13462 while (cur && safety-- > 0) {
13463 if (cur.id === ancestor) {
13464 return true;
13465 }
13466 if (!cur.parent) {
13467 return false;
13468 }
13469 cur = nodes.get(cur.parent);
13470 }
13471 return false;
13472 }
13473 let lastFocusChange = 0;
13474 const SPOTLIGHT_RADIUS2 = POST_RING_RADIUS$1 + 130;
13475 async function focusNode(id) {
13476 if (focusId === id) {
13477 closeFocus();
13478 return;
13479 }
13480 const wasFocused = focusId !== null;
13481 focusId = id;
13482 focusPage = 1;
13483 lastFocusChange = performance.now();
13484 const focused = nodes.get(id);
13485 if (focused) {
13486 if (!wasFocused) {
13487 prevView = {
13488 scale: targetScale,
13489 x: targetWorldX,
13490 y: targetWorldY
13491 };
13492 }
13493 const r = stage.getBoundingClientRect();
13494 if (r.width > 0 && r.height > 0) {
13495 const half = POST_RING_RADIUS$1 + 70;
13496 const sx = r.width * 0.85 / (2 * half);
13497 const sy = r.height * 0.85 / (2 * half);
13498 const newScale = Math.max(
13499 0.5,
13500 Math.min(1.6, Math.min(sx, sy))
13501 );
13502 targetScale = newScale;
13503 targetWorldX = r.width / 2 - focused.x * newScale;
13504 targetWorldY = r.height / 2 - focused.y * newScale;
13505 }
13506 nudgeAwayFrom = {
13507 x: focused.x,
13508 y: focused.y,
13509 radius: SPOTLIGHT_RADIUS2
13510 };
13511 pinnedTargetBackup.clear();
13512 for (const n of nodes.values()) {
13513 if (n.id === id || !n.pinned) {
13514 continue;
13515 }
13516 const dx = n.x - focused.x;
13517 const dy = n.y - focused.y;
13518 const d = Math.sqrt(dx * dx + dy * dy) || 1;
13519 if (d >= SPOTLIGHT_RADIUS2 + n.radius) {
13520 continue;
13521 }
13522 pinnedTargetBackup.set(n.id, { tx: n.tx, ty: n.ty });
13523 const push = SPOTLIGHT_RADIUS2 + n.radius + 20;
13524 n.tx = focused.x + dx / d * push;
13525 n.ty = focused.y + dy / d * push;
13526 }
13527 }
13528 for (const n of nodes.values()) {
13529 drawNodeDisc(n, focusId === n.id);
13530 }
13531 paintSidebar();
13532 await loadPostsForFocus();
13533 }
13534 function closeFocus() {
13535 focusId = null;
13536 lastFocusChange = performance.now();
13537 loadSeq++;
13538 nudgeAwayFrom = null;
13539 for (const [id, t] of pinnedTargetBackup) {
13540 const n = nodes.get(id);
13541 if (n) {
13542 n.tx = t.tx;
13543 n.ty = t.ty;
13544 }
13545 }
13546 pinnedTargetBackup.clear();
13547 if (prevView) {
13548 targetScale = prevView.scale;
13549 targetWorldX = prevView.x;
13550 targetWorldY = prevView.y;
13551 prevView = null;
13552 }
13553 paintSidebar();
13554 clearPosts();
13555 for (const n of nodes.values()) {
13556 drawNodeDisc(n, false);
13557 }
13558 }
13559 function clearPosts() {
13560 for (const post of postNodes.values()) {
13561 postLayer.removeChild(post.gfx);
13562 post.gfx.destroy();
13563 }
13564 postNodes.clear();
13565 for (const chip of postChips.values()) {
13566 postChipLayer.removeChild(chip.container);
13567 chip.container.destroy({ children: true });
13568 }
13569 postChips.clear();
13570 postEdgeGfx.clear();
13571 pager.visible = false;
13572 }
13573 function ensurePostChip(post) {
13574 const existing = postChips.get(post.id);
13575 if (existing) {
13576 return existing;
13577 }
13578 const container = new pixi.Container();
13579 container.eventMode = "static";
13580 container.cursor = "pointer";
13581 container.alpha = 0;
13582 const bg = new pixi.Graphics();
13583 container.addChild(bg);
13584 const dot = new pixi.Graphics();
13585 container.addChild(dot);
13586 const titleText = new pixi.Text({
13587 text: post.title,
13588 style: {
13589 fill: 1909543,
13590 // Matches category chip fontSize so the two read at
13591 // the same weight when both are deployed. Base size
13592 // is the on-screen size since the post chip's
13593 // container counter-scales with `1/world.scale.x`
13594 // in `syncChipPositions`.
13595 fontSize: 14,
13596 fontFamily: FONT_FAMILY2,
13597 fontWeight: "500"
13598 },
13599 resolution: CHIP_TEXT_RES2
13600 });
13601 container.addChild(titleText);
13602 const chip = {
13603 container,
13604 bg,
13605 dot,
13606 titleText,
13607 width: 0,
13608 height: 0,
13609 cachedTitle: "",
13610 cachedHover: false
13611 };
13612 postChips.set(post.id, chip);
13613 postChipLayer.addChild(container);
13614 container.on("pointerdown", (e) => {
13615 e.stopPropagation?.();
13616 pixiInteractionAt = performance.now();
13617 });
13618 container.on("pointertap", () => {
13619 openInPostsTab(post.id, post.editUrl, post.title);
13620 closeFocus();
13621 });
13622 container.on("pointerover", () => {
13623 chip.cachedHover = true;
13624 layoutPostChip(chip, post);
13625 });
13626 container.on("pointerout", () => {
13627 chip.cachedHover = false;
13628 layoutPostChip(chip, post);
13629 });
13630 layoutPostChip(chip, post);
13631 return chip;
13632 }
13633 function layoutPostChip(chip, post) {
13634 const displayTitle = post.title.length > POST_TITLE_MAX_CHARS2 ? post.title.slice(0, POST_TITLE_MAX_CHARS2 - 1) + "…" : post.title;
13635 if (chip.titleText.text !== displayTitle) {
13636 chip.titleText.text = displayTitle;
13637 }
13638 chip.cachedTitle = displayTitle;
13639 const padX = 9;
13640 const padY = 3;
13641 const dotR = 4;
13642 const gap = 6;
13643 const titleW = chip.titleText.width;
13644 const titleH = chip.titleText.height;
13645 const totalW = padX + dotR * 2 + gap + titleW + padX;
13646 const totalH = Math.max(titleH, dotR * 2) + padY * 2;
13647 chip.width = totalW;
13648 chip.height = totalH;
13649 const left = -totalW / 2;
13650 const top = -totalH / 2;
13651 chip.bg.clear();
13652 chip.bg.roundRect(left, top, totalW, totalH, totalH / 2);
13653 if (chip.cachedHover) {
13654 chip.bg.fill({ color: 16777215, alpha: 1 });
13655 chip.bg.stroke({
13656 color: post.tone,
13657 width: 1.5,
13658 alpha: 1
13659 });
13660 } else {
13661 chip.bg.fill({ color: 16777215, alpha: 0.95 });
13662 chip.bg.stroke({
13663 color: 0,
13664 width: 1,
13665 alpha: 0.12
13666 });
13667 }
13668 chip.dot.clear();
13669 chip.dot.circle(left + padX + dotR, 0, dotR);
13670 chip.dot.fill({ color: post.tone, alpha: 0.85 });
13671 chip.dot.stroke({ color: 16777215, width: 1 });
13672 chip.titleText.x = left + padX + dotR * 2 + gap;
13673 chip.titleText.y = -titleH / 2;
13674 }
13675 const POSTS_CACHE_TTL_MS = 6e4;
13676 const postsCache = /* @__PURE__ */ new Map();
13677 function applyPostsResult(entry, focusedNodeId) {
13678 focusTotalPages = entry.totalPages;
13679 if (Number.isFinite(entry.realTotal)) {
13680 const node = nodes.get(focusedNodeId);
13681 if (node && node.count !== entry.realTotal) {
13682 node.count = entry.realTotal;
13683 terms = terms.map(
13684 (t) => t.id === node.id ? { ...t, count: entry.realTotal } : t
13685 );
13686 layoutChip(ensureChip(node), node);
13687 }
13688 }
13689 renderPosts(entry.items);
13690 }
13691 async function loadPostsForFocus() {
13692 if (focusId === null) {
13693 return;
13694 }
13695 const mySeq = ++loadSeq;
13696 const myFocusId = focusId;
13697 const cacheKey2 = `${focusId}:${focusPage}`;
13698 const cached = postsCache.get(cacheKey2);
13699 if (cached && performance.now() - cached.fetchedAt < POSTS_CACHE_TTL_MS) {
13700 applyPostsResult(cached, myFocusId);
13701 return;
13702 }
13703 const cfg = client.getConfig();
13704 const url = new URL(cfg.postsUrl);
13705 url.searchParams.set("categories", String(focusId));
13706 url.searchParams.set("per_page", String(POST_PER_PAGE$1));
13707 url.searchParams.set("page", String(focusPage));
13708 url.searchParams.set("status", "any");
13709 url.searchParams.set("_fields", "id,title,status");
13710 try {
13711 const response = await fetchShellJson$1(client, url.toString());
13712 if (mySeq !== loadSeq || focusId !== myFocusId) {
13713 return;
13714 }
13715 const raw = response.json ?? [];
13716 const totalPages = Math.max(
13717 1,
13718 parseInt(response.headers.get("X-WP-TotalPages") ?? "1", 10) || 1
13719 );
13720 const realTotalParsed = parseInt(response.headers.get("X-WP-Total") ?? "", 10);
13721 const realTotal = Number.isFinite(realTotalParsed) ? realTotalParsed : -1;
13722 const items = raw.map((p) => ({
13723 id: p.id,
13724 title: stripTags$1(p.title?.rendered || `#${p.id}`),
13725 editUrl: `${cfg.editPostUrlBase}?post=${p.id}&action=edit`
13726 }));
13727 const entry = {
13728 items,
13729 totalPages,
13730 realTotal,
13731 fetchedAt: performance.now()
13732 };
13733 postsCache.set(cacheKey2, entry);
13734 applyPostsResult(entry, myFocusId);
13735 } catch (err) {
13736 showError(__("Couldn’t load posts:"), err);
13737 }
13738 }
13739 function renderPosts(items) {
13740 clearPosts();
13741 if (focusId === null) {
13742 return;
13743 }
13744 const center = nodes.get(focusId);
13745 if (!center) {
13746 return;
13747 }
13748 const count = items.length;
13749 const ringR = POST_RING_RADIUS$1 + Math.max(0, count - 8) * 6;
13750 items.forEach((item, idx) => {
13751 const angle = 2 * Math.PI / Math.max(1, count) * idx - Math.PI / 2;
13752 const tx = center.x + Math.cos(angle) * ringR;
13753 const ty = center.y + Math.sin(angle) * ringR;
13754 const tone = center.color;
13755 const gfx = new pixi.Graphics();
13756 postLayer.addChild(gfx);
13757 const post = {
13758 id: item.id,
13759 title: item.title,
13760 editUrl: item.editUrl,
13761 angle,
13762 r: ringR,
13763 x: center.x,
13764 y: center.y,
13765 tx,
13766 ty,
13767 gfx,
13768 tone
13769 };
13770 postNodes.set(item.id, post);
13771 ensurePostChip(post);
13772 });
13773 repaintPager();
13774 }
13775 function repaintPager() {
13776 if (focusId === null || focusTotalPages <= 1) {
13777 pager.visible = false;
13778 return;
13779 }
13780 pager.visible = true;
13781 const center = nodes.get(focusId);
13782 if (!center) {
13783 pager.visible = false;
13784 return;
13785 }
13786 const prevDisabled = focusPage <= 1;
13787 const nextDisabled = focusPage >= focusTotalPages;
13788 drawPagerButton(pagerPrev, "◀", prevDisabled);
13789 drawPagerButton(pagerNext, "▶", nextDisabled);
13790 pagerPrev.cursor = prevDisabled ? "default" : "pointer";
13791 pagerNext.cursor = nextDisabled ? "default" : "pointer";
13792 pagerLabel.text = `${focusPage} / ${focusTotalPages}`;
13793 pagerPrev.x = -38;
13794 pagerPrev.y = 0;
13795 pagerNext.x = 38;
13796 pagerNext.y = 0;
13797 pagerLabel.x = 0;
13798 pagerLabel.y = 0;
13799 pager.x = center.x;
13800 pager.y = center.y + POST_RING_RADIUS$1 + 60;
13801 }
13802 function drawPagerButton(gfx, glyph, disabled) {
13803 gfx.clear();
13804 gfx.circle(0, 0, 14);
13805 gfx.fill({
13806 color: disabled ? 15921906 : 16777215,
13807 alpha: disabled ? 0.7 : 1
13808 });
13809 gfx.stroke({
13810 color: 0,
13811 width: 1,
13812 alpha: 0.12
13813 });
13814 const children = gfx.children;
13815 const label = children?.[0] ?? null;
13816 if (!label) {
13817 const t = new pixi.Text({
13818 text: glyph,
13819 style: {
13820 fill: disabled ? 11580344 : 5265246,
13821 fontSize: 16,
13822 fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
13823 fontWeight: "600"
13824 },
13825 resolution: CHIP_TEXT_RES2
13826 });
13827 t.anchor.set(0.5);
13828 gfx.addChild(t);
13829 } else {
13830 label.text = glyph;
13831 label.style.fill = disabled ? 11580344 : 5265246;
13832 }
13833 }
13834 function openInPostsTab(_id, editUrl, title) {
13835 const wm = api?.windowManager;
13836 const derive = api?.deriveWindowId;
13837 const postsWin = wm && typeof wm.getById === "function" ? wm.getById("desktop-mode-posts") : void 0;
13838 if (postsWin && typeof postsWin.isFullscreen === "function" && typeof postsWin.toggleFullscreen === "function" && postsWin.isFullscreen()) {
13839 postsWin.toggleFullscreen();
13840 }
13841 if (wm && typeof derive === "function") {
13842 const id = derive(editUrl);
13843 wm.open({
13844 id,
13845 baseId: id,
13846 url: editUrl,
13847 title: title ?? editUrl,
13848 icon: "dashicons-admin-post"
13849 });
13850 return;
13851 }
13852 try {
13853 window.open(editUrl, "_blank");
13854 } catch {
13855 window.location.assign(editUrl);
13856 }
13857 }
13858 function paintDraftSidebar(d) {
13859 const parentNode = d.parent !== 0 ? nodes.get(d.parent) : null;
13860 const header = document.createElement("div");
13861 header.className = "wpd-mindmap__sidebar-header";
13862 const dot = document.createElement("span");
13863 dot.className = "wpd-mindmap__sidebar-dot";
13864 const color = parentNode ? parentNode.color : clusterColor(terms.length);
13865 dot.style.background = `#${color.toString(16).padStart(6, "0")}`;
13866 const label = document.createElement("code");
13867 label.className = "wpd-mindmap__sidebar-slug";
13868 label.textContent = parentNode ? sprintf(
13869 /* translators: %s: parent category name. */
13870 __("New child of %s"),
13871 parentNode.name
13872 ) : __("New root category");
13873 header.appendChild(dot);
13874 header.appendChild(label);
13875 sidebar.appendChild(header);
13876 const nameLabel = document.createElement("label");
13877 nameLabel.className = "wpd-mindmap__sidebar-label";
13878 nameLabel.textContent = __("Name");
13879 sidebar.appendChild(nameLabel);
13880 const nameInput = document.createElement("input");
13881 nameInput.type = "text";
13882 nameInput.className = "wpd-mindmap__editor-name";
13883 nameInput.placeholder = __("e.g. Recipes");
13884 sidebar.appendChild(nameInput);
13885 requestAnimationFrame(() => nameInput.focus());
13886 const slugLabel = document.createElement("label");
13887 slugLabel.className = "wpd-mindmap__sidebar-label";
13888 slugLabel.textContent = __("Slug");
13889 sidebar.appendChild(slugLabel);
13890 const slugInput = document.createElement("input");
13891 slugInput.type = "text";
13892 slugInput.className = "wpd-mindmap__editor-name";
13893 slugInput.placeholder = __("auto-from-name");
13894 slugInput.spellcheck = false;
13895 slugInput.autocapitalize = "off";
13896 slugInput.addEventListener("input", () => {
13897 const v = slugInput.value;
13898 const norm = v.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
13899 if (v !== norm) {
13900 const sel = slugInput.selectionStart ?? norm.length;
13901 slugInput.value = norm;
13902 slugInput.setSelectionRange(sel, sel);
13903 }
13904 });
13905 sidebar.appendChild(slugInput);
13906 const descLabel = document.createElement("label");
13907 descLabel.className = "wpd-mindmap__sidebar-label";
13908 descLabel.textContent = __("Description");
13909 sidebar.appendChild(descLabel);
13910 const descInput = document.createElement("textarea");
13911 descInput.className = "wpd-mindmap__editor-desc";
13912 descInput.placeholder = __("Description (optional)");
13913 descInput.rows = 4;
13914 sidebar.appendChild(descInput);
13915 const actions = document.createElement("div");
13916 actions.className = "wpd-mindmap__editor-actions";
13917 const createBtn = document.createElement("button");
13918 createBtn.type = "button";
13919 createBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--primary";
13920 createBtn.textContent = __("Create");
13921 const cancelBtn = document.createElement("button");
13922 cancelBtn.type = "button";
13923 cancelBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--danger";
13924 cancelBtn.textContent = __("Cancel");
13925 const handleCreate = async () => {
13926 const name = nameInput.value.trim();
13927 if (!name) {
13928 nameInput.focus();
13929 return;
13930 }
13931 createBtn.disabled = true;
13932 try {
13933 const created = await client.createCategory(name, d.parent, {
13934 slug: slugInput.value.trim() || void 0,
13935 description: descInput.value || void 0
13936 });
13937 const next = {
13938 id: created.id,
13939 name: created.name,
13940 slug: created.slug || "",
13941 parent: created.parent,
13942 count: 0,
13943 description: created.description || "",
13944 isDefault: false
13945 };
13946 if (!terms.some((t) => t.id === next.id)) {
13947 terms = terms.concat(next);
13948 }
13949 draft = null;
13950 buildTree();
13951 focusId = created.id;
13952 paintSidebar();
13953 await loadPostsForFocus();
13954 } catch (err) {
13955 createBtn.disabled = false;
13956 showError(__("Couldn’t create:"), err);
13957 }
13958 };
13959 createBtn.addEventListener("click", () => {
13960 void handleCreate();
13961 });
13962 cancelBtn.addEventListener("click", () => {
13963 draft = null;
13964 paintSidebar();
13965 });
13966 nameInput.addEventListener("keydown", (e) => {
13967 if (e.key === "Enter") {
13968 e.preventDefault();
13969 void handleCreate();
13970 } else if (e.key === "Escape") {
13971 draft = null;
13972 paintSidebar();
13973 }
13974 });
13975 actions.appendChild(createBtn);
13976 actions.appendChild(cancelBtn);
13977 sidebar.appendChild(actions);
13978 }
13979 function paintSidebar() {
13980 sidebar.replaceChildren();
13981 if (draft !== null) {
13982 paintDraftSidebar(draft);
13983 return;
13984 }
13985 if (focusId === null) {
13986 const empty = document.createElement("div");
13987 empty.className = "wpd-mindmap__sidebar-empty";
13988 const icon = document.createElement("span");
13989 icon.className = "dashicons dashicons-admin-tools";
13990 icon.setAttribute("aria-hidden", "true");
13991 empty.appendChild(icon);
13992 const title = document.createElement("h3");
13993 title.textContent = __("No category selected");
13994 empty.appendChild(title);
13995 const help = document.createElement("p");
13996 help.textContent = __(
13997 "Click a node on the mindmap to edit its name, description, and posts."
13998 );
13999 empty.appendChild(help);
14000 sidebar.appendChild(empty);
14001 return;
14002 }
14003 const node = nodes.get(focusId);
14004 if (!node) {
14005 focusId = null;
14006 paintSidebar();
14007 return;
14008 }
14009 const id = node.id;
14010 const header = document.createElement("div");
14011 header.className = "wpd-mindmap__sidebar-header";
14012 const dot = document.createElement("span");
14013 dot.className = "wpd-mindmap__sidebar-dot";
14014 dot.style.background = `#${node.color.toString(16).padStart(6, "0")}`;
14015 const term = terms.find((t) => t.id === id);
14016 const idLabel = document.createElement("code");
14017 idLabel.className = "wpd-mindmap__sidebar-slug";
14018 idLabel.textContent = `#${id}`;
14019 header.appendChild(dot);
14020 header.appendChild(idLabel);
14021 sidebar.appendChild(header);
14022 const nameLabel = document.createElement("label");
14023 nameLabel.className = "wpd-mindmap__sidebar-label";
14024 nameLabel.textContent = __("Name");
14025 sidebar.appendChild(nameLabel);
14026 const nameInput = document.createElement("input");
14027 nameInput.type = "text";
14028 nameInput.className = "wpd-mindmap__editor-name";
14029 nameInput.value = node.name;
14030 nameInput.placeholder = __("Name");
14031 sidebar.appendChild(nameInput);
14032 const slugLabel = document.createElement("label");
14033 slugLabel.className = "wpd-mindmap__sidebar-label";
14034 slugLabel.textContent = __("Slug");
14035 sidebar.appendChild(slugLabel);
14036 const slugInput = document.createElement("input");
14037 slugInput.type = "text";
14038 slugInput.className = "wpd-mindmap__editor-name";
14039 slugInput.value = term?.slug || "";
14040 slugInput.placeholder = __("auto-from-name");
14041 slugInput.spellcheck = false;
14042 slugInput.autocapitalize = "off";
14043 slugInput.addEventListener("input", () => {
14044 const v = slugInput.value;
14045 const norm = v.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
14046 if (v !== norm) {
14047 const sel = slugInput.selectionStart ?? norm.length;
14048 slugInput.value = norm;
14049 slugInput.setSelectionRange(sel, sel);
14050 }
14051 });
14052 sidebar.appendChild(slugInput);
14053 const descLabel = document.createElement("label");
14054 descLabel.className = "wpd-mindmap__sidebar-label";
14055 descLabel.textContent = __("Description");
14056 sidebar.appendChild(descLabel);
14057 const descInput = document.createElement("textarea");
14058 descInput.className = "wpd-mindmap__editor-desc";
14059 descInput.value = node.description || "";
14060 descInput.placeholder = __("Description (optional)");
14061 descInput.rows = 4;
14062 sidebar.appendChild(descInput);
14063 const meta = document.createElement("p");
14064 meta.className = "wpd-mindmap__sidebar-meta";
14065 meta.textContent = sprintf(
14066 /* translators: %d: post count. */
14067 _n(
14068 "%d post in this category.",
14069 "%d posts in this category.",
14070 node.count
14071 ),
14072 node.count
14073 );
14074 sidebar.appendChild(meta);
14075 const actions = document.createElement("div");
14076 actions.className = "wpd-mindmap__editor-actions";
14077 const addChildBtn = document.createElement("button");
14078 addChildBtn.type = "button";
14079 addChildBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--secondary";
14080 addChildBtn.textContent = __("+ Child");
14081 addChildBtn.addEventListener("click", () => {
14082 startDraft(id);
14083 });
14084 const makeRootBtn = node.parent && node.parent !== 0 ? document.createElement("button") : null;
14085 if (makeRootBtn) {
14086 makeRootBtn.type = "button";
14087 makeRootBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--secondary";
14088 makeRootBtn.textContent = __("Make root");
14089 makeRootBtn.title = __(
14090 "Promote this category to a top-level root (no parent)."
14091 );
14092 makeRootBtn.addEventListener("click", async () => {
14093 try {
14094 await client.updateTerm("categories", node.id, { parent: 0 });
14095 node.parent = 0;
14096 terms = terms.map(
14097 (t) => t.id === node.id ? { ...t, parent: 0 } : t
14098 );
14099 buildTree();
14100 paintSidebar();
14101 } catch (err) {
14102 showError(__("Couldn’t reparent:"), err);
14103 }
14104 });
14105 }
14106 const saveBtn = document.createElement("button");
14107 saveBtn.type = "button";
14108 saveBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--primary";
14109 saveBtn.textContent = __("Save");
14110 saveBtn.addEventListener("click", async () => {
14111 const name = nameInput.value.trim();
14112 if (!name) {
14113 return;
14114 }
14115 const description = descInput.value;
14116 const slugRaw = slugInput.value.trim();
14117 const currentSlug = term?.slug ?? "";
14118 if (name === node.name && description === (node.description || "") && slugRaw === currentSlug) {
14119 return;
14120 }
14121 const patch = { name, description };
14122 if (slugRaw !== currentSlug) {
14123 patch.slug = slugRaw;
14124 }
14125 try {
14126 const updated = await client.updateTerm(
14127 "categories",
14128 node.id,
14129 patch
14130 );
14131 node.name = updated.name;
14132 node.description = updated.description;
14133 terms = terms.map(
14134 (t) => t.id === node.id ? {
14135 ...t,
14136 name: updated.name,
14137 description: updated.description,
14138 slug: updated.slug ?? t.slug
14139 } : t
14140 );
14141 layoutChip(ensureChip(node), node);
14142 paintSidebar();
14143 } catch (err) {
14144 showError(__("Couldn’t save:"), err);
14145 }
14146 });
14147 const delBtn = document.createElement("button");
14148 delBtn.type = "button";
14149 delBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--danger";
14150 delBtn.textContent = __("Delete");
14151 let armResetTimer = null;
14152 const armDelete = () => {
14153 delBtn.textContent = __("Click again to delete");
14154 delBtn.classList.add("is-armed");
14155 if (armResetTimer !== null) {
14156 window.clearTimeout(armResetTimer);
14157 }
14158 armResetTimer = window.setTimeout(() => {
14159 delBtn.textContent = __("Delete");
14160 delBtn.classList.remove("is-armed");
14161 armResetTimer = null;
14162 }, 2500);
14163 };
14164 delBtn.addEventListener("click", async () => {
14165 if (!delBtn.classList.contains("is-armed")) {
14166 armDelete();
14167 return;
14168 }
14169 if (armResetTimer !== null) {
14170 window.clearTimeout(armResetTimer);
14171 armResetTimer = null;
14172 }
14173 try {
14174 await client.deleteTerm("categories", node.id);
14175 terms = terms.filter((t) => t.id !== node.id);
14176 focusId = null;
14177 clearPosts();
14178 buildTree();
14179 paintSidebar();
14180 } catch (err) {
14181 showError(__("Couldn’t delete:"), err);
14182 }
14183 });
14184 actions.appendChild(addChildBtn);
14185 if (makeRootBtn) {
14186 actions.appendChild(makeRootBtn);
14187 }
14188 actions.appendChild(saveBtn);
14189 actions.appendChild(delBtn);
14190 sidebar.appendChild(actions);
14191 }
14192 function startDraft(parent) {
14193 if (parent !== 0 && !nodes.get(parent)) {
14194 return;
14195 }
14196 draft = { parent };
14197 paintSidebar();
14198 }
14199 addRootBtn.addEventListener("click", () => {
14200 startDraft(0);
14201 });
14202 function fitToView(opts = {}) {
14203 const padding = opts.padding ?? 90;
14204 const animate = opts.animate ?? false;
14205 const r = stage.getBoundingClientRect();
14206 if (nodes.size === 0 || r.width === 0 || r.height === 0) {
14207 const cx2 = r.width / 2;
14208 const cy2 = r.height / 2;
14209 targetScale = 1;
14210 targetWorldX = cx2;
14211 targetWorldY = cy2;
14212 if (!animate) {
14213 world.x = cx2;
14214 world.y = cy2;
14215 world.scale.set(1);
14216 }
14217 return;
14218 }
14219 let minX = Infinity;
14220 let minY = Infinity;
14221 let maxX = -Infinity;
14222 let maxY = -Infinity;
14223 const LABEL_OVERHANG = 30;
14224 for (const n of nodes.values()) {
14225 const rad = n.radius;
14226 minX = Math.min(minX, n.tx - rad);
14227 minY = Math.min(minY, n.ty - rad);
14228 maxX = Math.max(maxX, n.tx + rad);
14229 maxY = Math.max(maxY, n.ty + rad + LABEL_OVERHANG);
14230 }
14231 const w = Math.max(1, maxX - minX);
14232 const h = Math.max(1, maxY - minY);
14233 const sx = (r.width - padding * 2) / w;
14234 const sy = (r.height - padding * 2) / h;
14235 const scale = Math.max(0.2, Math.min(1.5, Math.min(sx, sy)));
14236 const cx = (minX + maxX) / 2;
14237 const cy = (minY + maxY) / 2;
14238 const newWorldX = r.width / 2 - cx * scale;
14239 const newWorldY = r.height / 2 - cy * scale;
14240 targetScale = scale;
14241 targetWorldX = newWorldX;
14242 targetWorldY = newWorldY;
14243 if (!animate) {
14244 world.scale.set(scale);
14245 world.x = newWorldX;
14246 world.y = newWorldY;
14247 }
14248 }
14249 function recenterCamera() {
14250 if (focusId !== null) {
14251 const focused = nodes.get(focusId);
14252 const r = stage.getBoundingClientRect();
14253 if (focused && r.width > 0 && r.height > 0) {
14254 const half = POST_RING_RADIUS$1 + 70;
14255 const sx = r.width * 0.85 / (2 * half);
14256 const sy = r.height * 0.85 / (2 * half);
14257 const newScale = Math.max(
14258 0.5,
14259 Math.min(1.6, Math.min(sx, sy))
14260 );
14261 targetScale = newScale;
14262 targetWorldX = r.width / 2 - focused.x * newScale;
14263 targetWorldY = r.height / 2 - focused.y * newScale;
14264 return;
14265 }
14266 }
14267 fitToView({ animate: true });
14268 }
14269 recenterBtn.addEventListener("click", () => recenterCamera());
14270 app.canvas.addEventListener("click", (e) => {
14271 const now = performance.now();
14272 if (now - lastFocusChange < 250 || now - pixiInteractionAt < 250) {
14273 return;
14274 }
14275 if (panMovedDist > 4) {
14276 return;
14277 }
14278 const target = e.target;
14279 if (target === app.canvas && !dragNode && focusId !== null) {
14280 closeFocus();
14281 }
14282 });
14283 async function refreshCountsViaBulk() {
14284 if (terms.length === 0) {
14285 return;
14286 }
14287 const cfg = client.getConfig();
14288 const url = new URL(
14289 joinRestUrl(cfg.restRoot, "desktop-mode/v1/term-counts")
14290 );
14291 url.searchParams.set("taxonomy", "category");
14292 url.searchParams.set(
14293 "ids",
14294 terms.map((t) => t.id).join(",")
14295 );
14296 try {
14297 const response = await fetchShellJson$1(client, url.toString());
14298 const map = response.json;
14299 let dirty = false;
14300 terms = terms.map((t) => {
14301 const fresh = map[String(t.id)];
14302 if (typeof fresh === "number" && fresh !== t.count) {
14303 dirty = true;
14304 const node = nodes.get(t.id);
14305 if (node) {
14306 node.count = fresh;
14307 layoutChip(ensureChip(node), node);
14308 }
14309 return { ...t, count: fresh };
14310 }
14311 return t;
14312 });
14313 if (dirty) {
14314 buildTree();
14315 fitToView({ animate: true });
14316 }
14317 } catch {
14318 }
14319 }
14320 buildTree();
14321 paintSidebar();
14322 preSettlePhysics(80);
14323 raf = requestAnimationFrame(tick);
14324 void refreshCountsViaBulk();
14325 let currentMatches = [];
14326 let selectedIndex = 0;
14327 const repaintHighlight = () => {
14328 const items = searchResults.querySelectorAll(
14329 ".wpd-mindmap__search-result"
14330 );
14331 items.forEach((el, i) => {
14332 const active = i === selectedIndex;
14333 el.classList.toggle("is-active", active);
14334 if (active) {
14335 el.scrollIntoView({ block: "nearest" });
14336 }
14337 });
14338 };
14339 const selectMatch = (n) => {
14340 searchInput.value = "";
14341 searchResults.hidden = true;
14342 searchResults.replaceChildren();
14343 currentMatches = [];
14344 selectedIndex = 0;
14345 void focusNode(n.id);
14346 };
14347 const renderSearchResults = () => {
14348 const q = searchInput.value.trim().toLowerCase();
14349 if (q.length === 0) {
14350 searchResults.hidden = true;
14351 searchResults.replaceChildren();
14352 currentMatches = [];
14353 selectedIndex = 0;
14354 return;
14355 }
14356 currentMatches = Array.from(nodes.values()).filter((n) => n.name.toLowerCase().includes(q)).sort((a, b) => b.count - a.count).slice(0, 10);
14357 selectedIndex = 0;
14358 searchResults.replaceChildren();
14359 currentMatches.forEach((n, i) => {
14360 const li = document.createElement("li");
14361 const btn = document.createElement("button");
14362 btn.type = "button";
14363 btn.className = "wpd-mindmap__search-result";
14364 if (i === 0) {
14365 btn.classList.add("is-active");
14366 }
14367 const nameEl = document.createElement("span");
14368 nameEl.className = "wpd-mindmap__search-title";
14369 nameEl.textContent = n.name || `#${n.id}`;
14370 const countEl = document.createElement("span");
14371 countEl.className = "wpd-mindmap__search-meta";
14372 countEl.textContent = sprintf(
14373 /* translators: %d: number of posts assigned to a category. */
14374 _n("%d post", "%d posts", n.count),
14375 n.count
14376 );
14377 btn.appendChild(nameEl);
14378 btn.appendChild(countEl);
14379 btn.addEventListener("mousedown", (ev) => {
14380 ev.preventDefault();
14381 selectMatch(n);
14382 });
14383 btn.addEventListener("mouseenter", () => {
14384 selectedIndex = i;
14385 repaintHighlight();
14386 });
14387 li.appendChild(btn);
14388 searchResults.appendChild(li);
14389 });
14390 searchResults.hidden = currentMatches.length === 0;
14391 };
14392 searchInput.addEventListener("input", renderSearchResults);
14393 searchInput.addEventListener("focus", renderSearchResults);
14394 searchInput.addEventListener("keydown", (ev) => {
14395 if (ev.key === "ArrowDown") {
14396 if (currentMatches.length === 0) {
14397 return;
14398 }
14399 ev.preventDefault();
14400 selectedIndex = Math.min(
14401 selectedIndex + 1,
14402 currentMatches.length - 1
14403 );
14404 repaintHighlight();
14405 } else if (ev.key === "ArrowUp") {
14406 if (currentMatches.length === 0) {
14407 return;
14408 }
14409 ev.preventDefault();
14410 selectedIndex = Math.max(selectedIndex - 1, 0);
14411 repaintHighlight();
14412 } else if (ev.key === "Enter") {
14413 if (currentMatches.length === 0) {
14414 return;
14415 }
14416 ev.preventDefault();
14417 selectMatch(currentMatches[selectedIndex]);
14418 } else if (ev.key === "Escape") {
14419 searchInput.value = "";
14420 searchResults.hidden = true;
14421 searchResults.replaceChildren();
14422 currentMatches = [];
14423 selectedIndex = 0;
14424 }
14425 });
14426 searchInput.addEventListener("blur", () => {
14427 setTimeout(() => {
14428 searchResults.hidden = true;
14429 }, 120);
14430 });
14431 const onDocClickSearch = (ev) => {
14432 if (!searchWrap.contains(ev.target)) {
14433 searchResults.hidden = true;
14434 }
14435 };
14436 document.addEventListener("click", onDocClickSearch);
14437 return () => {
14438 if (raf !== null) {
14439 cancelAnimationFrame(raf);
14440 raf = null;
14441 }
14442 if (settleTimer !== null) {
14443 window.clearTimeout(settleTimer);
14444 settleTimer = null;
14445 }
14446 ro.disconnect();
14447 stage.removeEventListener("wheel", onWheel);
14448 document.removeEventListener("click", onDocClickSearch);
14449 try {
14450 app.ticker?.stop();
14451 } catch {
14452 }
14453 try {
14454 app.destroy({ removeView: true }, { children: true });
14455 } catch {
14456 }
14457 host.replaceChildren();
14458 host.classList.remove("wpd-mindmap");
14459 };
14460 }
14461 function nodeRadius(count, all) {
14462 const max = Math.max(1, ...all.map((t) => t.count));
14463 const ratio = Math.sqrt(count / max);
14464 return MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * ratio;
14465 }
14466 function readAdminThemeHue$1() {
14467 try {
14468 const value = getComputedStyle(document.documentElement).getPropertyValue("--wp-admin-theme-color").trim();
14469 if (!value) {
14470 return 210;
14471 }
14472 const c = document.createElement("span");
14473 c.style.color = value;
14474 document.body.appendChild(c);
14475 const rgb = getComputedStyle(c).color;
14476 c.remove();
14477 const m = rgb.match(/\d+/g);
14478 if (!m || m.length < 3) {
14479 return 210;
14480 }
14481 return rgbToHue$1(
14482 parseInt(m[0], 10),
14483 parseInt(m[1], 10),
14484 parseInt(m[2], 10)
14485 );
14486 } catch {
14487 return 210;
14488 }
14489 }
14490 function rgbToHue$1(r, g, b) {
14491 const rn = r / 255;
14492 const gn = g / 255;
14493 const bn = b / 255;
14494 const max = Math.max(rn, gn, bn);
14495 const min = Math.min(rn, gn, bn);
14496 const d = max - min;
14497 if (d === 0) {
14498 return 210;
14499 }
14500 let h;
14501 switch (max) {
14502 case rn:
14503 h = (gn - bn) / d + (gn < bn ? 6 : 0);
14504 break;
14505 case gn:
14506 h = (bn - rn) / d + 2;
14507 break;
14508 default:
14509 h = (rn - gn) / d + 4;
14510 break;
14511 }
14512 return Math.round(h * 60);
14513 }
14514 function hslToInt$1(h, s, l) {
14515 const sn = s / 100;
14516 const ln = l / 100;
14517 const c = (1 - Math.abs(2 * ln - 1)) * sn;
14518 const hp = h / 60;
14519 const x = c * (1 - Math.abs(hp % 2 - 1));
14520 let r = 0;
14521 let g = 0;
14522 let b = 0;
14523 if (hp < 1) {
14524 r = c;
14525 g = x;
14526 } else if (hp < 2) {
14527 r = x;
14528 g = c;
14529 } else if (hp < 3) {
14530 g = c;
14531 b = x;
14532 } else if (hp < 4) {
14533 g = x;
14534 b = c;
14535 } else if (hp < 5) {
14536 r = x;
14537 b = c;
14538 } else {
14539 r = c;
14540 b = x;
14541 }
14542 const m = ln - c / 2;
14543 const ri = Math.round((r + m) * 255);
14544 const gi = Math.round((g + m) * 255);
14545 const bi = Math.round((b + m) * 255);
14546 return ri * 65536 + gi * 256 + bi;
14547 }
14548 function shadeColor(color, delta) {
14549 const r = Math.floor(color / 65536) % 256;
14550 const g = Math.floor(color / 256) % 256;
14551 const b = color % 256;
14552 const adj = (ch) => {
14553 return Math.round(ch * (1 + delta));
14554 };
14555 return adj(r) * 65536 + adj(g) * 256 + adj(b);
14556 }
14557 function stripTags$1(html2) {
14558 const tmp = document.createElement("div");
14559 tmp.innerHTML = html2;
14560 return tmp.textContent || tmp.innerText || "";
14561 }
14562 function showToast$1(title, err) {
14563 const reason = err instanceof Error ? err.message : String(err);
14564 const api = window.wp?.desktop;
14565 if (api && typeof api.showToast === "function") {
14566 api.showToast({
14567 message: `${title} ${reason}`.trim(),
14568 duration: 6e3
14569 });
14570 return;
14571 }
14572 console.error(title, err);
14573 }
14574 async function fetchShellJson$1(client, url) {
14575 const cfg = client.getConfig();
14576 const init = {
14577 method: "GET",
14578 credentials: "same-origin",
14579 headers: {
14580 "X-WP-Nonce": cfg.restNonce,
14581 Accept: "application/json"
14582 }
14583 };
14584 const response = await trackedFetch(url, init, {
14585 windowId: "desktop-mode-posts"
14586 });
14587 if (!response.ok) {
14588 throw new Error(`${response.status} ${response.statusText}`);
14589 }
14590 const json = await response.json();
14591 return { json, headers: response.headers };
14592 }
14593 const categoriesMindmap = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
14594 __proto__: null,
14595 mountCategoriesMindmap
14596 }, Symbol.toStringTag, { value: "Module" }));
14597 const POST_PER_PAGE = 10;
14598 const POST_RING_RADIUS = 170;
14599 const MIN_FONT_SIZE = 11;
14600 const MAX_FONT_SIZE = 28;
14601 const FONT_FAMILY = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
14602 const CHIP_TEXT_RES = 3;
14603 const CHIP_NAME_MAX_CHARS = 22;
14604 const POST_TITLE_MAX_CHARS = 22;
14605 const CHIP_PAD_X = 11;
14606 const CHIP_PAD_Y = 6;
14607 const CHIP_GAP_HASH = 4;
14608 const CHIP_GAP_COUNT = 8;
14609 const SPIRAL_PADDING = 14;
14610 const SPOTLIGHT_RADIUS = POST_RING_RADIUS + 130;
14611 async function mountTagsCloud(host, client) {
14612 const api = window.wp?.desktop;
14613 if (!api || typeof api.loadModules !== "function") {
14614 host.textContent = __("Tag cloud unavailable: shell modules API missing.");
14615 return () => {
14616 };
14617 }
14618 try {
14619 await api.loadModules(["pixijs"]);
14620 } catch {
14621 host.textContent = __("Tag cloud unavailable.");
14622 return () => {
14623 };
14624 }
14625 const pixiMaybe = window.PIXI;
14626 if (!pixiMaybe) {
14627 host.textContent = __("Tag cloud unavailable.");
14628 return () => {
14629 };
14630 }
14631 const pixi = pixiMaybe;
14632 host.replaceChildren();
14633 host.classList.add("wpd-tagcloud");
14634 const toolbar = document.createElement("div");
14635 toolbar.className = "wpd-tagcloud__toolbar";
14636 const addTagBtn = document.createElement("button");
14637 addTagBtn.type = "button";
14638 addTagBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--primary";
14639 addTagBtn.innerHTML = '<span class="dashicons dashicons-plus" aria-hidden="true"></span>' + __("Add tag");
14640 const recenterBtn = document.createElement("button");
14641 recenterBtn.type = "button";
14642 recenterBtn.className = "wpd-tagcloud__btn";
14643 recenterBtn.innerHTML = '<span class="dashicons dashicons-image-rotate" aria-hidden="true"></span>' + __("Recenter");
14644 const reflowBtn = document.createElement("button");
14645 reflowBtn.type = "button";
14646 reflowBtn.className = "wpd-tagcloud__btn";
14647 reflowBtn.innerHTML = '<span class="dashicons dashicons-grid-view" aria-hidden="true"></span>' + __("Reflow");
14648 reflowBtn.title = __(
14649 "Recompute the chip layout from scratch — discards manual repositioning."
14650 );
14651 const searchWrap = document.createElement("div");
14652 searchWrap.className = "wpd-tagcloud__search";
14653 const searchInput = document.createElement("input");
14654 searchInput.type = "search";
14655 searchInput.className = "wpd-tagcloud__search-input";
14656 searchInput.placeholder = __("Search tags…");
14657 searchInput.setAttribute(
14658 "aria-label",
14659 __("Search tags in the cloud")
14660 );
14661 searchWrap.appendChild(searchInput);
14662 const searchResults = document.createElement("ul");
14663 searchResults.className = "wpd-tagcloud__search-results";
14664 searchResults.hidden = true;
14665 searchWrap.appendChild(searchResults);
14666 const hint = document.createElement("span");
14667 hint.className = "wpd-tagcloud__hint";
14668 hint.textContent = __(
14669 "Click a tag to focus + edit · drag to reposition · wheel to zoom"
14670 );
14671 toolbar.appendChild(addTagBtn);
14672 toolbar.appendChild(recenterBtn);
14673 toolbar.appendChild(reflowBtn);
14674 toolbar.appendChild(searchWrap);
14675 toolbar.appendChild(hint);
14676 host.appendChild(toolbar);
14677 const layout = document.createElement("div");
14678 layout.className = "wpd-tagcloud__layout";
14679 host.appendChild(layout);
14680 const stage = document.createElement("div");
14681 stage.className = "wpd-tagcloud__stage";
14682 stage.classList.add("is-loading");
14683 layout.appendChild(stage);
14684 const sidebar = document.createElement("aside");
14685 sidebar.className = "wpd-tagcloud__sidebar";
14686 layout.appendChild(sidebar);
14687 const app = new pixi.Application();
14688 await app.init({
14689 resizeTo: stage,
14690 backgroundAlpha: 0,
14691 antialias: true,
14692 autoDensity: true,
14693 resolution: Math.min(window.devicePixelRatio || 1, 2)
14694 });
14695 stage.appendChild(app.canvas);
14696 app.canvas.classList.add("wpd-tagcloud__canvas");
14697 const world = new pixi.Container();
14698 world.x = stage.clientWidth / 2;
14699 world.y = stage.clientHeight / 2;
14700 app.stage.addChild(world);
14701 const chipLayer = new pixi.Container();
14702 const postEdgeLayer = new pixi.Container();
14703 const postLayer = new pixi.Container();
14704 const postChipLayer = new pixi.Container();
14705 world.addChild(postEdgeLayer);
14706 world.addChild(chipLayer);
14707 world.addChild(postLayer);
14708 world.addChild(postChipLayer);
14709 const postEdgeGfx = new pixi.Graphics();
14710 postEdgeLayer.addChild(postEdgeGfx);
14711 const pager = new pixi.Container();
14712 pager.eventMode = "passive";
14713 pager.visible = false;
14714 postLayer.addChild(pager);
14715 const pagerPrev = new pixi.Graphics();
14716 const pagerNext = new pixi.Graphics();
14717 const pagerLabel = new pixi.Text({
14718 text: "1 / 1",
14719 style: {
14720 fill: 5265246,
14721 fontSize: 12,
14722 fontFamily: FONT_FAMILY,
14723 fontWeight: "600"
14724 }
14725 });
14726 pagerLabel.anchor.set(0.5);
14727 pagerPrev.eventMode = "static";
14728 pagerPrev.cursor = "pointer";
14729 pagerNext.eventMode = "static";
14730 pagerNext.cursor = "pointer";
14731 pagerPrev.hitArea = new pixi.Circle(0, 0, 16);
14732 pagerNext.hitArea = new pixi.Circle(0, 0, 16);
14733 pager.addChild(pagerPrev);
14734 pager.addChild(pagerLabel);
14735 pager.addChild(pagerNext);
14736 const stopBubble = (e) => {
14737 e.stopPropagation?.();
14738 pixiInteractionAt = performance.now();
14739 };
14740 pagerPrev.on("pointerdown", stopBubble);
14741 pagerNext.on("pointerdown", stopBubble);
14742 pagerPrev.on("pointertap", (e) => {
14743 stopBubble(e);
14744 lastFocusChange = performance.now();
14745 if (focusPage <= 1) {
14746 return;
14747 }
14748 focusPage--;
14749 void loadPostsForFocus();
14750 });
14751 pagerNext.on("pointertap", (e) => {
14752 stopBubble(e);
14753 lastFocusChange = performance.now();
14754 if (focusPage >= focusTotalPages) {
14755 return;
14756 }
14757 focusPage++;
14758 void loadPostsForFocus();
14759 });
14760 const tags = /* @__PURE__ */ new Map();
14761 const postChips = /* @__PURE__ */ new Map();
14762 const postNodes = /* @__PURE__ */ new Map();
14763 let focusId = null;
14764 let focusPage = 1;
14765 let focusTotalPages = 1;
14766 let loadSeq = 0;
14767 let pixiInteractionAt = 0;
14768 let dragChip = null;
14769 let dragOffset = { x: 0, y: 0 };
14770 let dragStart = null;
14771 let panActive = false;
14772 let panStart = null;
14773 let panMovedDist = 0;
14774 let raf = null;
14775 let lastTick = performance.now();
14776 let targetScale = world.scale.x;
14777 let targetWorldX = world.x;
14778 let targetWorldY = world.y;
14779 let nudgeAwayFrom = null;
14780 let prevView = null;
14781 let lastFocusChange = 0;
14782 let draft = null;
14783 let terms = [];
14784 const positionsKey = computePositionsKey();
14785 const persistedPositions = readPersistedPositions(positionsKey);
14786 let cooccurrenceMap = /* @__PURE__ */ new Map();
14787 const themeHue = readAdminThemeHue();
14788 try {
14789 const all = [];
14790 let page = 1;
14791 while (page <= 5) {
14792 const res = await client.fetchTerms("tags", { page, perPage: 100 });
14793 all.push(...res.items);
14794 if (page >= res.totalPages) {
14795 break;
14796 }
14797 page++;
14798 }
14799 terms = all;
14800 } catch (err) {
14801 showToast(__("Couldn’t load tags:"), err);
14802 }
14803 const showError = (title, err) => showToast(title, err);
14804 function buildCloud() {
14805 const liveIds = new Set(terms.map((t) => t.id));
14806 for (const [id, box] of tags) {
14807 if (!liveIds.has(id)) {
14808 chipLayer.removeChild(box.chip.container);
14809 box.chip.container.destroy({ children: true });
14810 tags.delete(id);
14811 }
14812 }
14813 const maxCount = Math.max(1, ...terms.map((t) => t.count));
14814 const fresh = [];
14815 for (const term of terms) {
14816 const fontSize = fontSizeFor(term.count, maxCount);
14817 const hue = tagHue(term.slug || term.name, themeHue);
14818 const rotation = tagRotation(term.slug || term.name);
14819 const existing = tags.get(term.id);
14820 if (existing) {
14821 existing.name = term.name;
14822 existing.slug = term.slug;
14823 existing.description = term.description;
14824 existing.count = term.count;
14825 existing.fontSize = fontSize;
14826 existing.hue = hue;
14827 existing.rotation = rotation;
14828 layoutChip(existing);
14829 } else {
14830 const chip = createTagChip(pixi, chipLayer, term, fontSize, hue);
14831 const persisted = persistedPositions.get(term.id);
14832 const box = {
14833 id: term.id,
14834 name: term.name,
14835 slug: term.slug,
14836 description: term.description,
14837 count: term.count,
14838 fontSize,
14839 hue,
14840 rotation,
14841 x: persisted ? persisted.x : 0,
14842 y: persisted ? persisted.y : 0,
14843 tx: persisted ? persisted.x : 0,
14844 ty: persisted ? persisted.y : 0,
14845 width: 0,
14846 height: 0,
14847 chip
14848 };
14849 tags.set(term.id, box);
14850 layoutChip(box);
14851 wireChipPointer(box);
14852 if (!persisted) {
14853 fresh.push(box);
14854 }
14855 }
14856 }
14857 const placed = [];
14858 const placedById = /* @__PURE__ */ new Map();
14859 for (const box of tags.values()) {
14860 if (!fresh.includes(box)) {
14861 placed.push({
14862 x: box.tx - box.width / 2,
14863 y: box.ty - box.height / 2,
14864 w: box.width,
14865 h: box.height
14866 });
14867 placedById.set(box.id, { x: box.tx, y: box.ty });
14868 }
14869 }
14870 fresh.sort((a, b) => b.count - a.count);
14871 packBoxesWithClusters(fresh, placed, placedById, cooccurrenceMap);
14872 for (const box of fresh) {
14873 box.x = box.tx;
14874 box.y = box.ty;
14875 }
14876 }
14877 function wireChipPointer(box) {
14878 const c = box.chip.container;
14879 c.on("pointerdown", (e) => {
14880 const ev = e;
14881 ev.stopPropagation?.();
14882 pixiInteractionAt = performance.now();
14883 dragChip = box;
14884 dragStart = { x: ev.global.x, y: ev.global.y };
14885 const local = stageToWorld({ x: ev.global.x, y: ev.global.y });
14886 dragOffset = { x: box.x - local.x, y: box.y - local.y };
14887 });
14888 c.on("pointerover", () => {
14889 box.chip.cachedHover = true;
14890 paintChip(box);
14891 });
14892 c.on("pointerout", () => {
14893 box.chip.cachedHover = false;
14894 paintChip(box);
14895 });
14896 }
14897 function layoutChip(box) {
14898 const chip = box.chip;
14899 const displayName = truncateChipName(box.name);
14900 const countStr = String(box.count);
14901 if (chip.nameText.text !== displayName) {
14902 chip.nameText.text = displayName;
14903 }
14904 if (chip.countText.text !== countStr) {
14905 chip.countText.text = countStr;
14906 }
14907 chip.nameText.style.fontSize = box.fontSize;
14908 chip.hashText.style.fontSize = box.fontSize;
14909 chip.countText.style.fontSize = Math.max(
14910 10,
14911 Math.round(box.fontSize * 0.55)
14912 );
14913 chip.cachedName = displayName;
14914 chip.cachedCount = box.count;
14915 chip.cachedHue = box.hue;
14916 const hashW = chip.hashText.width;
14917 const nameW = chip.nameText.width;
14918 const nameH = chip.nameText.height;
14919 const countW = chip.countText.width;
14920 const countH = chip.countText.height;
14921 const countBadgeW = Math.max(18, countW + 10);
14922 const countBadgeH = Math.max(14, countH + 4);
14923 const totalW = CHIP_PAD_X + hashW + CHIP_GAP_HASH + nameW + CHIP_GAP_COUNT + countBadgeW + CHIP_PAD_X;
14924 const totalH = Math.max(nameH, countBadgeH) + CHIP_PAD_Y * 2;
14925 box.width = totalW;
14926 box.height = totalH;
14927 paintChip(box);
14928 }
14929 function paintChip(box) {
14930 const chip = box.chip;
14931 const focused = focusId === box.id;
14932 chip.cachedFocused = focused;
14933 const totalW = box.width;
14934 const totalH = box.height;
14935 const left = -totalW / 2;
14936 const top = -totalH / 2;
14937 const radius = totalH / 2;
14938 let fillBg;
14939 if (focused) {
14940 fillBg = hslToInt(box.hue, 70, 48);
14941 } else if (chip.cachedHover) {
14942 fillBg = hslToInt(box.hue, 70, 92);
14943 } else {
14944 fillBg = hslToInt(box.hue, 60, 95);
14945 }
14946 const borderColor = focused ? hslToInt(box.hue, 70, 38) : hslToInt(box.hue, 50, 70);
14947 const textColor = focused ? 16777215 : 1909543;
14948 const hashColor = focused ? 16777215 : hslToInt(box.hue, 65, 42);
14949 const countBg = focused ? hslToInt(box.hue, 80, 30) : hslToInt(box.hue, 70, 50);
14950 chip.shadow.clear();
14951 chip.shadow.roundRect(
14952 left - 1,
14953 top + 3,
14954 totalW + 2,
14955 totalH + 2,
14956 radius + 1
14957 );
14958 let shadowAlpha = 0.1;
14959 if (focused) {
14960 shadowAlpha = 0.18;
14961 } else if (chip.cachedHover) {
14962 shadowAlpha = 0.16;
14963 }
14964 chip.shadow.fill({
14965 color: 0,
14966 alpha: shadowAlpha
14967 });
14968 chip.bg.clear();
14969 chip.bg.roundRect(left, top, totalW, totalH, radius);
14970 chip.bg.fill(fillBg);
14971 chip.bg.stroke({
14972 color: borderColor,
14973 width: focused ? 2 : 1.25,
14974 alpha: focused ? 1 : 0.85
14975 });
14976 const hashW = chip.hashText.width;
14977 const nameW = chip.nameText.width;
14978 const nameH = chip.nameText.height;
14979 const countW = chip.countText.width;
14980 const countH = chip.countText.height;
14981 const countBadgeW = Math.max(18, countW + 10);
14982 const countBadgeH = Math.max(14, countH + 4);
14983 chip.hashText.x = left + CHIP_PAD_X;
14984 chip.hashText.y = (totalH - nameH) / 2 + top;
14985 chip.hashText.style.fill = hashColor;
14986 chip.nameText.x = left + CHIP_PAD_X + hashW + CHIP_GAP_HASH;
14987 chip.nameText.y = (totalH - nameH) / 2 + top;
14988 chip.nameText.style.fill = textColor;
14989 const badgeX = left + CHIP_PAD_X + hashW + CHIP_GAP_HASH + nameW + CHIP_GAP_COUNT;
14990 const badgeY = (totalH - countBadgeH) / 2 + top;
14991 chip.bg.roundRect(
14992 badgeX,
14993 badgeY,
14994 countBadgeW,
14995 countBadgeH,
14996 countBadgeH / 2
14997 );
14998 chip.bg.fill(countBg);
14999 chip.countText.x = badgeX + (countBadgeW - countW) / 2;
15000 chip.countText.y = badgeY + (countBadgeH - countH) / 2;
15001 chip.countText.style.fill = 16777215;
15002 }
15003 function findSpiralSlot(w, h, placed, anchorX = 0, anchorY = 0) {
15004 if (placed.length === 0) {
15005 return { x: anchorX, y: anchorY };
15006 }
15007 const padding = SPIRAL_PADDING;
15008 {
15009 const aabb = {
15010 x: anchorX - w / 2 - padding,
15011 y: anchorY - h / 2 - padding,
15012 w: w + padding * 2,
15013 h: h + padding * 2
15014 };
15015 let overlap = false;
15016 for (const p of placed) {
15017 if (aabbIntersect(aabb, p)) {
15018 overlap = true;
15019 break;
15020 }
15021 }
15022 if (!overlap) {
15023 return { x: anchorX, y: anchorY };
15024 }
15025 }
15026 let theta = 0;
15027 const maxIter = 1e4;
15028 for (let i = 0; i < maxIter; i++) {
15029 theta += 0.18;
15030 const r = theta * 5;
15031 const cx = anchorX + r * Math.cos(theta);
15032 const cy = anchorY + r * Math.sin(theta) * 0.7;
15033 const aabb = {
15034 x: cx - w / 2 - padding,
15035 y: cy - h / 2 - padding,
15036 w: w + padding * 2,
15037 h: h + padding * 2
15038 };
15039 let overlap = false;
15040 for (const p of placed) {
15041 if (aabbIntersect(aabb, p)) {
15042 overlap = true;
15043 break;
15044 }
15045 }
15046 if (!overlap) {
15047 return { x: cx, y: cy };
15048 }
15049 }
15050 return {
15051 x: anchorX,
15052 y: anchorY + (placed.length + 1) * (h + padding)
15053 };
15054 }
15055 function packBoxesWithClusters(boxesInOrder, placed, placedById, cooccurrence) {
15056 let clusterCounter = 0;
15057 const allocateClusterAnchor = () => {
15058 const idx = clusterCounter++;
15059 if (idx === 0) {
15060 return { x: 0, y: 0 };
15061 }
15062 const theta = idx * 2.4;
15063 const radius = 120 + idx * 70;
15064 return {
15065 x: radius * Math.cos(theta),
15066 y: radius * Math.sin(theta) * 0.8
15067 };
15068 };
15069 for (const box of boxesInOrder) {
15070 let anchorX = 0;
15071 let anchorY = 0;
15072 let usedCentroid = false;
15073 const neighbors = cooccurrence.get(box.id);
15074 if (neighbors && neighbors.length > 0) {
15075 let sumX = 0;
15076 let sumY = 0;
15077 let sumW = 0;
15078 for (const n of neighbors) {
15079 const pos = placedById.get(n.id);
15080 if (!pos) {
15081 continue;
15082 }
15083 sumX += pos.x * n.shared;
15084 sumY += pos.y * n.shared;
15085 sumW += n.shared;
15086 }
15087 if (sumW > 0) {
15088 anchorX = sumX / sumW;
15089 anchorY = sumY / sumW;
15090 usedCentroid = true;
15091 }
15092 }
15093 if (!usedCentroid) {
15094 const anchor = allocateClusterAnchor();
15095 anchorX = anchor.x;
15096 anchorY = anchor.y;
15097 }
15098 const slot = findSpiralSlot(
15099 box.width,
15100 box.height,
15101 placed,
15102 anchorX,
15103 anchorY
15104 );
15105 box.tx = slot.x;
15106 box.ty = slot.y;
15107 placedById.set(box.id, { x: slot.x, y: slot.y });
15108 placed.push({
15109 x: slot.x - box.width / 2,
15110 y: slot.y - box.height / 2,
15111 w: box.width,
15112 h: box.height
15113 });
15114 }
15115 }
15116 function syncChipPositions() {
15117 const chipCounterScale = 1 / Math.max(0.01, world.scale.x);
15118 const anyFocus = focusId !== null;
15119 for (const box of tags.values()) {
15120 const c = box.chip.container;
15121 c.x = box.x;
15122 c.y = box.y;
15123 const counter = Math.max(1, chipCounterScale);
15124 c.scale.set(counter);
15125 c.rotation = box.rotation;
15126 const focused = focusId === box.id;
15127 const targetAlpha = !anyFocus || focused ? 1 : 0.32;
15128 if (Math.abs(c.alpha - targetAlpha) > 5e-3) {
15129 c.alpha += (targetAlpha - c.alpha) * 0.18;
15130 } else {
15131 c.alpha = targetAlpha;
15132 }
15133 }
15134 for (const post of postNodes.values()) {
15135 const chip = postChips.get(post.id);
15136 if (!chip) {
15137 continue;
15138 }
15139 chip.container.x = post.x;
15140 chip.container.y = post.y;
15141 chip.container.scale.set(chipCounterScale);
15142 if (chip.container.alpha < 1) {
15143 chip.container.alpha = Math.min(
15144 1,
15145 chip.container.alpha + 0.18
15146 );
15147 }
15148 }
15149 }
15150 function tick() {
15151 const now = performance.now();
15152 const dt = Math.min(50, now - lastTick);
15153 lastTick = now;
15154 const ZOOM_EASE = 0.22;
15155 const ds = targetScale - world.scale.x;
15156 const dwx = targetWorldX - world.x;
15157 const dwy = targetWorldY - world.y;
15158 if (Math.abs(ds) > 5e-4 || Math.abs(dwx) > 0.5 || Math.abs(dwy) > 0.5) {
15159 world.scale.set(world.scale.x + ds * ZOOM_EASE);
15160 world.x += dwx * ZOOM_EASE;
15161 world.y += dwy * ZOOM_EASE;
15162 }
15163 for (const box of tags.values()) {
15164 if (box === dragChip) {
15165 continue;
15166 }
15167 let tx = box.tx;
15168 let ty = box.ty;
15169 if (nudgeAwayFrom && box.id !== focusId) {
15170 const dx = box.tx - nudgeAwayFrom.x;
15171 const dy = box.ty - nudgeAwayFrom.y;
15172 const d = Math.sqrt(dx * dx + dy * dy) || 1;
15173 const limit = nudgeAwayFrom.radius + Math.max(box.width, box.height) / 2;
15174 if (d < limit) {
15175 const push = limit + 12;
15176 tx = nudgeAwayFrom.x + dx / d * push;
15177 ty = nudgeAwayFrom.y + dy / d * push;
15178 }
15179 }
15180 const ease = 1 - Math.exp(-dt * 0.012);
15181 box.x += (tx - box.x) * ease;
15182 box.y += (ty - box.y) * ease;
15183 }
15184 for (const p of postNodes.values()) {
15185 p.x += (p.tx - p.x) * 0.18;
15186 p.y += (p.ty - p.y) * 0.18;
15187 p.gfx.x = p.x;
15188 p.gfx.y = p.y;
15189 }
15190 drawPostEdges();
15191 syncChipPositions();
15192 raf = requestAnimationFrame(tick);
15193 }
15194 function drawPostEdges() {
15195 postEdgeGfx.clear();
15196 if (focusId === null) {
15197 return;
15198 }
15199 const center = tags.get(focusId);
15200 if (!center) {
15201 return;
15202 }
15203 for (const post of postNodes.values()) {
15204 postEdgeGfx.moveTo(center.x, center.y);
15205 postEdgeGfx.lineTo(post.x, post.y);
15206 postEdgeGfx.stroke({
15207 color: hslToInt(center.hue, 60, 50),
15208 width: 1,
15209 alpha: 0.35
15210 });
15211 }
15212 }
15213 function stageToWorld(global) {
15214 return {
15215 x: (global.x - world.x) / world.scale.x,
15216 y: (global.y - world.y) / world.scale.y
15217 };
15218 }
15219 function onStagePointerDown(e) {
15220 const ev = e;
15221 panActive = true;
15222 panStart = { x: ev.global.x, y: ev.global.y };
15223 panMovedDist = 0;
15224 }
15225 function onStagePointerMove(e) {
15226 const ev = e;
15227 if (dragChip) {
15228 const cursorWorld = stageToWorld(ev.global);
15229 const nx = cursorWorld.x + dragOffset.x;
15230 const ny = cursorWorld.y + dragOffset.y;
15231 dragChip.x = nx;
15232 dragChip.y = ny;
15233 dragChip.tx = nx;
15234 dragChip.ty = ny;
15235 return;
15236 }
15237 if (panActive && panStart) {
15238 const dx = ev.global.x - panStart.x;
15239 const dy = ev.global.y - panStart.y;
15240 world.x += dx;
15241 world.y += dy;
15242 targetWorldX += dx;
15243 targetWorldY += dy;
15244 panMovedDist += Math.sqrt(dx * dx + dy * dy);
15245 panStart = { x: ev.global.x, y: ev.global.y };
15246 }
15247 }
15248 function onStagePointerUp(e) {
15249 if (dragChip) {
15250 const box = dragChip;
15251 const startPos = dragStart;
15252 dragChip = null;
15253 dragStart = null;
15254 let movement = Infinity;
15255 const ev = e;
15256 if (startPos && ev && ev.global) {
15257 const dx = ev.global.x - startPos.x;
15258 const dy = ev.global.y - startPos.y;
15259 movement = Math.sqrt(dx * dx + dy * dy);
15260 }
15261 if (movement < 3) {
15262 void focusTag(box.id);
15263 } else {
15264 persistedPositions.set(box.id, { x: box.tx, y: box.ty });
15265 writePersistedPositions(positionsKey, persistedPositions);
15266 }
15267 }
15268 panActive = false;
15269 panStart = null;
15270 }
15271 app.stage.eventMode = "static";
15272 app.stage.hitArea = new pixi.Rectangle(
15273 0,
15274 0,
15275 stage.clientWidth,
15276 stage.clientHeight
15277 );
15278 app.stage.on("pointerdown", onStagePointerDown);
15279 app.stage.on("pointermove", onStagePointerMove);
15280 app.stage.on("pointerup", (e) => onStagePointerUp(e));
15281 app.stage.on("pointerupoutside", (e) => onStagePointerUp(e));
15282 function onWheel(e) {
15283 e.preventDefault();
15284 const SENSITIVITY = 8e-4;
15285 const factor = Math.exp(-e.deltaY * SENSITIVITY);
15286 const prev = targetScale;
15287 const next = Math.max(0.3, Math.min(2.5, prev * factor));
15288 if (Math.abs(next - prev) < 5e-4) {
15289 return;
15290 }
15291 const r = stage.getBoundingClientRect();
15292 const sx = e.clientX - r.left;
15293 const sy = e.clientY - r.top;
15294 const wx = (sx - targetWorldX) / prev;
15295 const wy = (sy - targetWorldY) / prev;
15296 targetScale = next;
15297 targetWorldX = sx - wx * next;
15298 targetWorldY = sy - wy * next;
15299 }
15300 stage.addEventListener("wheel", onWheel, { passive: false });
15301 let firstFitDone = false;
15302 let settledW = 0;
15303 let settledH = 0;
15304 const SETTLE_THRESHOLD_PX = 24;
15305 const SETTLE_DEBOUNCE_MS = 80;
15306 let settleTimer = null;
15307 function onResize() {
15308 const r = stage.getBoundingClientRect();
15309 app.renderer.resize(r.width, r.height);
15310 app.stage.hitArea = new pixi.Rectangle(0, 0, r.width, r.height);
15311 if (!firstFitDone && r.width > 0 && r.height > 0) {
15312 firstFitDone = true;
15313 settledW = r.width;
15314 settledH = r.height;
15315 fitToView();
15316 stage.classList.remove("is-loading");
15317 }
15318 if (settleTimer !== null) {
15319 window.clearTimeout(settleTimer);
15320 }
15321 settleTimer = window.setTimeout(() => {
15322 settleTimer = null;
15323 const cur = stage.getBoundingClientRect();
15324 const dw = Math.abs(cur.width - settledW);
15325 const dh = Math.abs(cur.height - settledH);
15326 if (dw >= SETTLE_THRESHOLD_PX || dh >= SETTLE_THRESHOLD_PX) {
15327 settledW = cur.width;
15328 settledH = cur.height;
15329 recenterCamera();
15330 }
15331 }, SETTLE_DEBOUNCE_MS);
15332 app.render();
15333 }
15334 const ro = new ResizeObserver(onResize);
15335 ro.observe(stage);
15336 async function focusTag(id) {
15337 if (focusId === id) {
15338 closeFocus();
15339 return;
15340 }
15341 const wasFocused = focusId !== null;
15342 focusId = id;
15343 focusPage = 1;
15344 lastFocusChange = performance.now();
15345 const focused = tags.get(id);
15346 if (focused) {
15347 if (!wasFocused) {
15348 prevView = {
15349 scale: targetScale,
15350 x: targetWorldX,
15351 y: targetWorldY
15352 };
15353 }
15354 const r = stage.getBoundingClientRect();
15355 if (r.width > 0 && r.height > 0) {
15356 const half = POST_RING_RADIUS + 70;
15357 const sx = r.width * 0.85 / (2 * half);
15358 const sy = r.height * 0.85 / (2 * half);
15359 const newScale = Math.max(
15360 0.5,
15361 Math.min(1.6, Math.min(sx, sy))
15362 );
15363 targetScale = newScale;
15364 targetWorldX = r.width / 2 - focused.x * newScale;
15365 targetWorldY = r.height / 2 - focused.y * newScale;
15366 }
15367 nudgeAwayFrom = {
15368 x: focused.x,
15369 y: focused.y,
15370 radius: SPOTLIGHT_RADIUS
15371 };
15372 }
15373 for (const box of tags.values()) {
15374 paintChip(box);
15375 }
15376 paintSidebar();
15377 await loadPostsForFocus();
15378 }
15379 function closeFocus() {
15380 focusId = null;
15381 lastFocusChange = performance.now();
15382 loadSeq++;
15383 nudgeAwayFrom = null;
15384 if (prevView) {
15385 targetScale = prevView.scale;
15386 targetWorldX = prevView.x;
15387 targetWorldY = prevView.y;
15388 prevView = null;
15389 }
15390 paintSidebar();
15391 clearPosts();
15392 for (const box of tags.values()) {
15393 paintChip(box);
15394 }
15395 }
15396 function clearPosts() {
15397 for (const post of postNodes.values()) {
15398 postLayer.removeChild(post.gfx);
15399 post.gfx.destroy();
15400 }
15401 postNodes.clear();
15402 for (const chip of postChips.values()) {
15403 postChipLayer.removeChild(chip.container);
15404 chip.container.destroy({ children: true });
15405 }
15406 postChips.clear();
15407 postEdgeGfx.clear();
15408 pager.visible = false;
15409 }
15410 function ensurePostChip(post) {
15411 const existing = postChips.get(post.id);
15412 if (existing) {
15413 return existing;
15414 }
15415 const container = new pixi.Container();
15416 container.eventMode = "static";
15417 container.cursor = "pointer";
15418 container.alpha = 0;
15419 const bg = new pixi.Graphics();
15420 container.addChild(bg);
15421 const dot = new pixi.Graphics();
15422 container.addChild(dot);
15423 const titleText = new pixi.Text({
15424 text: post.title,
15425 style: {
15426 fill: 1909543,
15427 fontSize: 12,
15428 fontFamily: FONT_FAMILY,
15429 fontWeight: "500"
15430 },
15431 resolution: CHIP_TEXT_RES
15432 });
15433 container.addChild(titleText);
15434 const chip = {
15435 container,
15436 bg,
15437 dot,
15438 titleText,
15439 width: 0,
15440 height: 0,
15441 cachedTitle: "",
15442 cachedHover: false
15443 };
15444 postChips.set(post.id, chip);
15445 postChipLayer.addChild(container);
15446 container.on("pointerdown", (e) => {
15447 e.stopPropagation?.();
15448 pixiInteractionAt = performance.now();
15449 });
15450 container.on("pointertap", () => {
15451 openInPostsTab(post.id, post.editUrl, post.title);
15452 closeFocus();
15453 });
15454 container.on("pointerover", () => {
15455 chip.cachedHover = true;
15456 layoutPostChip(chip, post);
15457 });
15458 container.on("pointerout", () => {
15459 chip.cachedHover = false;
15460 layoutPostChip(chip, post);
15461 });
15462 layoutPostChip(chip, post);
15463 return chip;
15464 }
15465 function layoutPostChip(chip, post) {
15466 const displayTitle = post.title.length > POST_TITLE_MAX_CHARS ? post.title.slice(0, POST_TITLE_MAX_CHARS - 1) + "…" : post.title;
15467 if (chip.titleText.text !== displayTitle) {
15468 chip.titleText.text = displayTitle;
15469 }
15470 chip.cachedTitle = displayTitle;
15471 const padX = 9;
15472 const padY = 3;
15473 const dotR = 4;
15474 const gap = 6;
15475 const titleW = chip.titleText.width;
15476 const titleH = chip.titleText.height;
15477 const totalW = padX + dotR * 2 + gap + titleW + padX;
15478 const totalH = Math.max(titleH, dotR * 2) + padY * 2;
15479 chip.width = totalW;
15480 chip.height = totalH;
15481 const left = -totalW / 2;
15482 const top = -totalH / 2;
15483 chip.bg.clear();
15484 chip.bg.roundRect(left, top, totalW, totalH, totalH / 2);
15485 if (chip.cachedHover) {
15486 chip.bg.fill({ color: 16777215, alpha: 1 });
15487 chip.bg.stroke({
15488 color: post.tone,
15489 width: 1.5,
15490 alpha: 1
15491 });
15492 } else {
15493 chip.bg.fill({ color: 16777215, alpha: 0.95 });
15494 chip.bg.stroke({
15495 color: 0,
15496 width: 1,
15497 alpha: 0.12
15498 });
15499 }
15500 chip.dot.clear();
15501 chip.dot.circle(left + padX + dotR, 0, dotR);
15502 chip.dot.fill({ color: post.tone, alpha: 0.85 });
15503 chip.dot.stroke({ color: 16777215, width: 1 });
15504 chip.titleText.x = left + padX + dotR * 2 + gap;
15505 chip.titleText.y = -titleH / 2;
15506 }
15507 const POSTS_CACHE_TTL_MS = 6e4;
15508 const postsCache = /* @__PURE__ */ new Map();
15509 function applyPostsResult(entry, focusedTagId) {
15510 focusTotalPages = entry.totalPages;
15511 if (Number.isFinite(entry.realTotal)) {
15512 const box = tags.get(focusedTagId);
15513 if (box && box.count !== entry.realTotal) {
15514 box.count = entry.realTotal;
15515 terms = terms.map(
15516 (t) => t.id === box.id ? { ...t, count: entry.realTotal } : t
15517 );
15518 layoutChip(box);
15519 }
15520 }
15521 renderPosts(entry.items);
15522 }
15523 async function loadPostsForFocus() {
15524 if (focusId === null) {
15525 return;
15526 }
15527 const mySeq = ++loadSeq;
15528 const myFocusId = focusId;
15529 const cacheKey2 = `${focusId}:${focusPage}`;
15530 const cached = postsCache.get(cacheKey2);
15531 if (cached && performance.now() - cached.fetchedAt < POSTS_CACHE_TTL_MS) {
15532 applyPostsResult(cached, myFocusId);
15533 return;
15534 }
15535 const cfg = client.getConfig();
15536 const url = new URL(cfg.postsUrl);
15537 url.searchParams.set("tags", String(focusId));
15538 url.searchParams.set("per_page", String(POST_PER_PAGE));
15539 url.searchParams.set("page", String(focusPage));
15540 url.searchParams.set("status", "any");
15541 url.searchParams.set("_fields", "id,title,status");
15542 try {
15543 const response = await fetchShellJson(client, url.toString());
15544 if (mySeq !== loadSeq || focusId !== myFocusId) {
15545 return;
15546 }
15547 const raw = response.json ?? [];
15548 const totalPages = Math.max(
15549 1,
15550 parseInt(response.headers.get("X-WP-TotalPages") ?? "1", 10) || 1
15551 );
15552 const realTotalParsed = parseInt(response.headers.get("X-WP-Total") ?? "", 10);
15553 const realTotal = Number.isFinite(realTotalParsed) ? realTotalParsed : -1;
15554 const items = raw.map((p) => ({
15555 id: p.id,
15556 title: stripTags(p.title?.rendered || `#${p.id}`),
15557 editUrl: `${cfg.editPostUrlBase}?post=${p.id}&action=edit`
15558 }));
15559 const entry = {
15560 items,
15561 totalPages,
15562 realTotal,
15563 fetchedAt: performance.now()
15564 };
15565 postsCache.set(cacheKey2, entry);
15566 applyPostsResult(entry, myFocusId);
15567 } catch (err) {
15568 showError(__("Couldn’t load posts:"), err);
15569 }
15570 }
15571 function renderPosts(items) {
15572 clearPosts();
15573 if (focusId === null) {
15574 return;
15575 }
15576 const center = tags.get(focusId);
15577 if (!center) {
15578 return;
15579 }
15580 const count = items.length;
15581 const ringR = POST_RING_RADIUS + Math.max(0, count - 8) * 6;
15582 const tone = hslToInt(center.hue, 70, 48);
15583 items.forEach((item, idx) => {
15584 const angle = 2 * Math.PI / Math.max(1, count) * idx - Math.PI / 2;
15585 const tx = center.x + Math.cos(angle) * ringR;
15586 const ty = center.y + Math.sin(angle) * ringR;
15587 const gfx = new pixi.Graphics();
15588 postLayer.addChild(gfx);
15589 const post = {
15590 id: item.id,
15591 title: item.title,
15592 editUrl: item.editUrl,
15593 angle,
15594 r: ringR,
15595 x: center.x,
15596 y: center.y,
15597 tx,
15598 ty,
15599 gfx,
15600 tone
15601 };
15602 postNodes.set(item.id, post);
15603 ensurePostChip(post);
15604 });
15605 repaintPager();
15606 }
15607 function repaintPager() {
15608 if (focusId === null || focusTotalPages <= 1) {
15609 pager.visible = false;
15610 return;
15611 }
15612 pager.visible = true;
15613 const center = tags.get(focusId);
15614 if (!center) {
15615 pager.visible = false;
15616 return;
15617 }
15618 const prevDisabled = focusPage <= 1;
15619 const nextDisabled = focusPage >= focusTotalPages;
15620 drawPagerButton(pagerPrev, "◀", prevDisabled);
15621 drawPagerButton(pagerNext, "▶", nextDisabled);
15622 pagerPrev.cursor = prevDisabled ? "default" : "pointer";
15623 pagerNext.cursor = nextDisabled ? "default" : "pointer";
15624 pagerLabel.text = `${focusPage} / ${focusTotalPages}`;
15625 pagerPrev.x = -38;
15626 pagerPrev.y = 0;
15627 pagerNext.x = 38;
15628 pagerNext.y = 0;
15629 pagerLabel.x = 0;
15630 pagerLabel.y = 0;
15631 pager.x = center.x;
15632 pager.y = center.y + POST_RING_RADIUS + 60;
15633 }
15634 function drawPagerButton(gfx, glyph, disabled) {
15635 gfx.clear();
15636 gfx.circle(0, 0, 14);
15637 gfx.fill({
15638 color: disabled ? 15921906 : 16777215,
15639 alpha: disabled ? 0.7 : 1
15640 });
15641 gfx.stroke({
15642 color: 0,
15643 width: 1,
15644 alpha: 0.12
15645 });
15646 const children = gfx.children;
15647 const label = children?.[0] ?? null;
15648 if (!label) {
15649 const t = new pixi.Text({
15650 text: glyph,
15651 style: {
15652 fill: disabled ? 11580344 : 5265246,
15653 fontSize: 14,
15654 fontFamily: FONT_FAMILY,
15655 fontWeight: "600"
15656 }
15657 });
15658 t.anchor.set(0.5);
15659 gfx.addChild(t);
15660 } else {
15661 label.text = glyph;
15662 label.style.fill = disabled ? 11580344 : 5265246;
15663 }
15664 }
15665 function openInPostsTab(_id, editUrl, title) {
15666 const wm = api?.windowManager;
15667 const derive = api?.deriveWindowId;
15668 const postsWin = wm && typeof wm.getById === "function" ? wm.getById("desktop-mode-posts") : void 0;
15669 if (postsWin && typeof postsWin.isFullscreen === "function" && typeof postsWin.toggleFullscreen === "function" && postsWin.isFullscreen()) {
15670 postsWin.toggleFullscreen();
15671 }
15672 if (wm && typeof derive === "function") {
15673 const id = derive(editUrl);
15674 wm.open({
15675 id,
15676 baseId: id,
15677 url: editUrl,
15678 title: title ?? editUrl,
15679 icon: "dashicons-admin-post"
15680 });
15681 return;
15682 }
15683 try {
15684 window.open(editUrl, "_blank");
15685 } catch {
15686 window.location.assign(editUrl);
15687 }
15688 }
15689 function paintDraftSidebar() {
15690 const header = document.createElement("div");
15691 header.className = "wpd-tagcloud__sidebar-header";
15692 const dot = document.createElement("span");
15693 dot.className = "wpd-tagcloud__sidebar-dot";
15694 dot.style.background = `hsl( ${themeHue}deg 60% 55% )`;
15695 const label = document.createElement("code");
15696 label.className = "wpd-tagcloud__sidebar-slug";
15697 label.textContent = __("New tag");
15698 header.appendChild(dot);
15699 header.appendChild(label);
15700 sidebar.appendChild(header);
15701 const nameLabel = document.createElement("label");
15702 nameLabel.className = "wpd-tagcloud__sidebar-label";
15703 nameLabel.textContent = __("Name");
15704 sidebar.appendChild(nameLabel);
15705 const nameInput = document.createElement("input");
15706 nameInput.type = "text";
15707 nameInput.className = "wpd-tagcloud__editor-name";
15708 nameInput.placeholder = __("e.g. featured");
15709 sidebar.appendChild(nameInput);
15710 requestAnimationFrame(() => nameInput.focus());
15711 const descLabel = document.createElement("label");
15712 descLabel.className = "wpd-tagcloud__sidebar-label";
15713 descLabel.textContent = __("Description");
15714 sidebar.appendChild(descLabel);
15715 const descInput = document.createElement("textarea");
15716 descInput.className = "wpd-tagcloud__editor-desc";
15717 descInput.placeholder = __("Description (optional)");
15718 descInput.rows = 4;
15719 sidebar.appendChild(descInput);
15720 const actions = document.createElement("div");
15721 actions.className = "wpd-tagcloud__editor-actions";
15722 const createBtn = document.createElement("button");
15723 createBtn.type = "button";
15724 createBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--primary";
15725 createBtn.textContent = __("Create");
15726 const cancelBtn = document.createElement("button");
15727 cancelBtn.type = "button";
15728 cancelBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--danger";
15729 cancelBtn.textContent = __("Cancel");
15730 const handleCreate = async () => {
15731 const name = nameInput.value.trim();
15732 if (!name) {
15733 nameInput.focus();
15734 return;
15735 }
15736 createBtn.disabled = true;
15737 try {
15738 const created = await client.createTag(name);
15739 const next = {
15740 id: created.id,
15741 name: created.name,
15742 slug: created.slug || "",
15743 parent: 0,
15744 count: 0,
15745 description: created.description || "",
15746 isDefault: false
15747 };
15748 if (!terms.some((t) => t.id === next.id)) {
15749 terms = terms.concat(next);
15750 }
15751 const desc = descInput.value.trim();
15752 if (desc) {
15753 try {
15754 const updated = await client.updateTerm(
15755 "tags",
15756 created.id,
15757 { description: desc }
15758 );
15759 terms = terms.map(
15760 (t) => t.id === updated.id ? {
15761 ...t,
15762 description: updated.description ?? desc
15763 } : t
15764 );
15765 } catch {
15766 showError(
15767 __("Tag created but description failed:"),
15768 null
15769 );
15770 }
15771 }
15772 draft = null;
15773 buildCloud();
15774 focusId = created.id;
15775 paintSidebar();
15776 await loadPostsForFocus();
15777 } catch (err) {
15778 createBtn.disabled = false;
15779 showError(__("Couldn’t create:"), err);
15780 }
15781 };
15782 createBtn.addEventListener("click", () => {
15783 void handleCreate();
15784 });
15785 cancelBtn.addEventListener("click", () => {
15786 draft = null;
15787 paintSidebar();
15788 });
15789 nameInput.addEventListener("keydown", (e) => {
15790 if (e.key === "Enter") {
15791 e.preventDefault();
15792 void handleCreate();
15793 } else if (e.key === "Escape") {
15794 draft = null;
15795 paintSidebar();
15796 }
15797 });
15798 actions.appendChild(createBtn);
15799 actions.appendChild(cancelBtn);
15800 sidebar.appendChild(actions);
15801 }
15802 function paintSidebar() {
15803 sidebar.replaceChildren();
15804 if (draft !== null) {
15805 paintDraftSidebar();
15806 return;
15807 }
15808 if (focusId === null) {
15809 const empty = document.createElement("div");
15810 empty.className = "wpd-tagcloud__sidebar-empty";
15811 const icon = document.createElement("span");
15812 icon.className = "dashicons dashicons-tag";
15813 icon.setAttribute("aria-hidden", "true");
15814 empty.appendChild(icon);
15815 const title = document.createElement("h3");
15816 title.className = "wpd-tagcloud__sidebar-empty-title";
15817 title.textContent = __("No tag selected");
15818 empty.appendChild(title);
15819 const help = document.createElement("p");
15820 help.className = "wpd-tagcloud__sidebar-empty-hint";
15821 help.textContent = __(
15822 "Click a tag on the cloud to edit it, or click + Add tag to create a new one."
15823 );
15824 empty.appendChild(help);
15825 sidebar.appendChild(empty);
15826 return;
15827 }
15828 const box = tags.get(focusId);
15829 if (!box) {
15830 focusId = null;
15831 paintSidebar();
15832 return;
15833 }
15834 const id = box.id;
15835 const header = document.createElement("div");
15836 header.className = "wpd-tagcloud__sidebar-header";
15837 const dot = document.createElement("span");
15838 dot.className = "wpd-tagcloud__sidebar-dot";
15839 dot.style.background = `hsl( ${box.hue}deg 60% 55% )`;
15840 const term = terms.find((t) => t.id === id);
15841 const idLabel = document.createElement("code");
15842 idLabel.className = "wpd-tagcloud__sidebar-slug";
15843 idLabel.textContent = `#${id}`;
15844 header.appendChild(dot);
15845 header.appendChild(idLabel);
15846 sidebar.appendChild(header);
15847 const nameLabel = document.createElement("label");
15848 nameLabel.className = "wpd-tagcloud__sidebar-label";
15849 nameLabel.textContent = __("Name");
15850 sidebar.appendChild(nameLabel);
15851 const nameInput = document.createElement("input");
15852 nameInput.type = "text";
15853 nameInput.className = "wpd-tagcloud__editor-name";
15854 nameInput.value = box.name;
15855 nameInput.placeholder = __("Name");
15856 sidebar.appendChild(nameInput);
15857 const slugLabel = document.createElement("label");
15858 slugLabel.className = "wpd-tagcloud__sidebar-label";
15859 slugLabel.textContent = __("Slug");
15860 sidebar.appendChild(slugLabel);
15861 const slugInput = document.createElement("input");
15862 slugInput.type = "text";
15863 slugInput.className = "wpd-tagcloud__editor-name";
15864 slugInput.value = term?.slug || "";
15865 slugInput.placeholder = __("auto-from-name");
15866 slugInput.spellcheck = false;
15867 slugInput.autocapitalize = "off";
15868 slugInput.addEventListener("input", () => {
15869 const v = slugInput.value;
15870 const norm = v.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
15871 if (v !== norm) {
15872 const sel = slugInput.selectionStart ?? norm.length;
15873 slugInput.value = norm;
15874 slugInput.setSelectionRange(sel, sel);
15875 }
15876 });
15877 sidebar.appendChild(slugInput);
15878 const descLabel = document.createElement("label");
15879 descLabel.className = "wpd-tagcloud__sidebar-label";
15880 descLabel.textContent = __("Description");
15881 sidebar.appendChild(descLabel);
15882 const descInput = document.createElement("textarea");
15883 descInput.className = "wpd-tagcloud__editor-desc";
15884 descInput.value = box.description || "";
15885 descInput.placeholder = __("Description (optional)");
15886 descInput.rows = 4;
15887 sidebar.appendChild(descInput);
15888 const meta = document.createElement("p");
15889 meta.className = "wpd-tagcloud__sidebar-meta";
15890 meta.textContent = sprintf(
15891 /* translators: %d: post count. */
15892 _n(
15893 "%d post tagged with this.",
15894 "%d posts tagged with this.",
15895 box.count
15896 ),
15897 box.count
15898 );
15899 sidebar.appendChild(meta);
15900 const actions = document.createElement("div");
15901 actions.className = "wpd-tagcloud__editor-actions";
15902 const saveBtn = document.createElement("button");
15903 saveBtn.type = "button";
15904 saveBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--primary";
15905 saveBtn.textContent = __("Save");
15906 saveBtn.addEventListener("click", async () => {
15907 const name = nameInput.value.trim();
15908 if (!name) {
15909 return;
15910 }
15911 const description = descInput.value;
15912 const slugRaw = slugInput.value.trim();
15913 const currentSlug = term?.slug ?? "";
15914 if (name === box.name && description === (box.description || "") && slugRaw === currentSlug) {
15915 return;
15916 }
15917 const patch = { name, description };
15918 if (slugRaw !== currentSlug) {
15919 patch.slug = slugRaw;
15920 }
15921 try {
15922 const updated = await client.updateTerm("tags", box.id, patch);
15923 box.name = updated.name;
15924 box.description = updated.description;
15925 box.slug = updated.slug ?? box.slug;
15926 box.hue = tagHue(box.slug || box.name, themeHue);
15927 box.rotation = tagRotation(box.slug || box.name);
15928 terms = terms.map(
15929 (t) => t.id === box.id ? {
15930 ...t,
15931 name: updated.name,
15932 description: updated.description,
15933 slug: updated.slug ?? t.slug
15934 } : t
15935 );
15936 layoutChip(box);
15937 paintSidebar();
15938 } catch (err) {
15939 showError(__("Couldn’t save:"), err);
15940 }
15941 });
15942 const delBtn = document.createElement("button");
15943 delBtn.type = "button";
15944 delBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--danger";
15945 delBtn.textContent = __("Delete");
15946 let armResetTimer = null;
15947 const armDelete = () => {
15948 delBtn.textContent = __("Click again to delete");
15949 delBtn.classList.add("is-armed");
15950 if (armResetTimer !== null) {
15951 window.clearTimeout(armResetTimer);
15952 }
15953 armResetTimer = window.setTimeout(() => {
15954 delBtn.textContent = __("Delete");
15955 delBtn.classList.remove("is-armed");
15956 armResetTimer = null;
15957 }, 2500);
15958 };
15959 delBtn.addEventListener("click", async () => {
15960 if (!delBtn.classList.contains("is-armed")) {
15961 armDelete();
15962 return;
15963 }
15964 if (armResetTimer !== null) {
15965 window.clearTimeout(armResetTimer);
15966 armResetTimer = null;
15967 }
15968 try {
15969 await client.deleteTerm("tags", box.id);
15970 terms = terms.filter((t) => t.id !== box.id);
15971 persistedPositions.delete(box.id);
15972 writePersistedPositions(positionsKey, persistedPositions);
15973 focusId = null;
15974 clearPosts();
15975 buildCloud();
15976 paintSidebar();
15977 } catch (err) {
15978 showError(__("Couldn’t delete:"), err);
15979 }
15980 });
15981 actions.appendChild(saveBtn);
15982 actions.appendChild(delBtn);
15983 sidebar.appendChild(actions);
15984 }
15985 function startDraft() {
15986 draft = true;
15987 paintSidebar();
15988 }
15989 addTagBtn.addEventListener("click", () => {
15990 startDraft();
15991 });
15992 function fitToView(opts = {}) {
15993 const padding = opts.padding ?? 90;
15994 const animate = opts.animate ?? false;
15995 const r = stage.getBoundingClientRect();
15996 if (tags.size === 0 || r.width === 0 || r.height === 0) {
15997 const cx2 = r.width / 2;
15998 const cy2 = r.height / 2;
15999 targetScale = 1;
16000 targetWorldX = cx2;
16001 targetWorldY = cy2;
16002 if (!animate) {
16003 world.x = cx2;
16004 world.y = cy2;
16005 world.scale.set(1);
16006 }
16007 return;
16008 }
16009 let minX = Infinity;
16010 let minY = Infinity;
16011 let maxX = -Infinity;
16012 let maxY = -Infinity;
16013 for (const box of tags.values()) {
16014 minX = Math.min(minX, box.tx - box.width / 2);
16015 minY = Math.min(minY, box.ty - box.height / 2);
16016 maxX = Math.max(maxX, box.tx + box.width / 2);
16017 maxY = Math.max(maxY, box.ty + box.height / 2);
16018 }
16019 const w = Math.max(1, maxX - minX);
16020 const h = Math.max(1, maxY - minY);
16021 const sx = (r.width - padding * 2) / w;
16022 const sy = (r.height - padding * 2) / h;
16023 const scale = Math.max(0.2, Math.min(1.5, Math.min(sx, sy)));
16024 const cx = (minX + maxX) / 2;
16025 const cy = (minY + maxY) / 2;
16026 const newWorldX = r.width / 2 - cx * scale;
16027 const newWorldY = r.height / 2 - cy * scale;
16028 targetScale = scale;
16029 targetWorldX = newWorldX;
16030 targetWorldY = newWorldY;
16031 if (!animate) {
16032 world.scale.set(scale);
16033 world.x = newWorldX;
16034 world.y = newWorldY;
16035 }
16036 }
16037 function recenterCamera() {
16038 if (focusId !== null) {
16039 const focused = tags.get(focusId);
16040 const r = stage.getBoundingClientRect();
16041 if (focused && r.width > 0 && r.height > 0) {
16042 const half = POST_RING_RADIUS + 70;
16043 const sx = r.width * 0.85 / (2 * half);
16044 const sy = r.height * 0.85 / (2 * half);
16045 const newScale = Math.max(
16046 0.5,
16047 Math.min(1.6, Math.min(sx, sy))
16048 );
16049 targetScale = newScale;
16050 targetWorldX = r.width / 2 - focused.x * newScale;
16051 targetWorldY = r.height / 2 - focused.y * newScale;
16052 return;
16053 }
16054 }
16055 fitToView({ animate: true });
16056 }
16057 recenterBtn.addEventListener("click", () => recenterCamera());
16058 reflowBtn.addEventListener("click", () => {
16059 persistedPositions.clear();
16060 writePersistedPositions(positionsKey, persistedPositions);
16061 for (const box of tags.values()) {
16062 box.tx = 0;
16063 box.ty = 0;
16064 }
16065 const allBoxes = Array.from(tags.values());
16066 allBoxes.sort((a, b) => b.count - a.count);
16067 packBoxesWithClusters(
16068 allBoxes,
16069 [],
16070 /* @__PURE__ */ new Map(),
16071 cooccurrenceMap
16072 );
16073 fitToView({ animate: true });
16074 void refreshCooccurrence();
16075 });
16076 app.canvas.addEventListener("click", (e) => {
16077 const now = performance.now();
16078 if (now - lastFocusChange < 250 || now - pixiInteractionAt < 250) {
16079 return;
16080 }
16081 if (panMovedDist > 4) {
16082 return;
16083 }
16084 const target = e.target;
16085 if (target === app.canvas && !dragChip && focusId !== null) {
16086 closeFocus();
16087 }
16088 });
16089 async function refreshCountsViaBulk() {
16090 if (terms.length === 0) {
16091 return;
16092 }
16093 const cfg = client.getConfig();
16094 const url = new URL(
16095 joinRestUrl(cfg.restRoot, "desktop-mode/v1/term-counts")
16096 );
16097 url.searchParams.set("taxonomy", "post_tag");
16098 url.searchParams.set(
16099 "ids",
16100 terms.map((t) => t.id).join(",")
16101 );
16102 try {
16103 const response = await fetchShellJson(client, url.toString());
16104 const map = response.json;
16105 let dirty = false;
16106 terms = terms.map((t) => {
16107 const fresh = map[String(t.id)];
16108 if (typeof fresh === "number" && fresh !== t.count) {
16109 dirty = true;
16110 const box = tags.get(t.id);
16111 if (box) {
16112 box.count = fresh;
16113 }
16114 return { ...t, count: fresh };
16115 }
16116 return t;
16117 });
16118 if (dirty) {
16119 const maxCount = Math.max(
16120 1,
16121 ...terms.map((t) => t.count)
16122 );
16123 for (const t of terms) {
16124 const box = tags.get(t.id);
16125 if (!box) {
16126 continue;
16127 }
16128 box.count = t.count;
16129 box.fontSize = fontSizeFor(t.count, maxCount);
16130 layoutChip(box);
16131 }
16132 if (focusId !== null) {
16133 paintSidebar();
16134 }
16135 }
16136 } catch {
16137 }
16138 }
16139 function relayoutWithCooccurrence() {
16140 const placed = [];
16141 const placedById = /* @__PURE__ */ new Map();
16142 const toRepack = [];
16143 for (const box of tags.values()) {
16144 if (persistedPositions.has(box.id)) {
16145 placed.push({
16146 x: box.tx - box.width / 2,
16147 y: box.ty - box.height / 2,
16148 w: box.width,
16149 h: box.height
16150 });
16151 placedById.set(box.id, { x: box.tx, y: box.ty });
16152 } else {
16153 toRepack.push(box);
16154 }
16155 }
16156 toRepack.sort((a, b) => b.count - a.count);
16157 packBoxesWithClusters(toRepack, placed, placedById, cooccurrenceMap);
16158 }
16159 async function refreshCooccurrence() {
16160 try {
16161 const fetched = await client.fetchTagCooccurrence("tags", 8);
16162 cooccurrenceMap = fetched;
16163 if (cooccurrenceMap.size > 0) {
16164 relayoutWithCooccurrence();
16165 }
16166 } catch {
16167 }
16168 }
16169 buildCloud();
16170 paintSidebar();
16171 raf = requestAnimationFrame(tick);
16172 void refreshCountsViaBulk();
16173 void refreshCooccurrence();
16174 if (terms.length === 0) {
16175 const empty = document.createElement("div");
16176 empty.className = "wpd-tagcloud__empty";
16177 empty.textContent = __(
16178 'No tags yet. Click "Add tag" to start building the cloud.'
16179 );
16180 stage.appendChild(empty);
16181 }
16182 let currentMatches = [];
16183 let selectedIndex = 0;
16184 const repaintHighlight = () => {
16185 const items = searchResults.querySelectorAll(
16186 ".wpd-tagcloud__search-result"
16187 );
16188 items.forEach((el, i) => {
16189 const active = i === selectedIndex;
16190 el.classList.toggle("is-active", active);
16191 if (active) {
16192 el.scrollIntoView({ block: "nearest" });
16193 }
16194 });
16195 };
16196 const selectMatch = (t) => {
16197 searchInput.value = "";
16198 searchResults.hidden = true;
16199 searchResults.replaceChildren();
16200 currentMatches = [];
16201 selectedIndex = 0;
16202 void focusTag(t.id);
16203 };
16204 const renderSearchResults = () => {
16205 const q = searchInput.value.trim().toLowerCase();
16206 if (q.length === 0) {
16207 searchResults.hidden = true;
16208 searchResults.replaceChildren();
16209 currentMatches = [];
16210 selectedIndex = 0;
16211 return;
16212 }
16213 currentMatches = Array.from(tags.values()).filter(
16214 (t) => t.name.toLowerCase().includes(q) || t.slug.toLowerCase().includes(q)
16215 ).sort((a, b) => b.count - a.count).slice(0, 10);
16216 selectedIndex = 0;
16217 searchResults.replaceChildren();
16218 currentMatches.forEach((t, i) => {
16219 const li = document.createElement("li");
16220 const btn = document.createElement("button");
16221 btn.type = "button";
16222 btn.className = "wpd-tagcloud__search-result";
16223 if (i === 0) {
16224 btn.classList.add("is-active");
16225 }
16226 const nameEl = document.createElement("span");
16227 nameEl.className = "wpd-tagcloud__search-title";
16228 nameEl.textContent = t.name || `#${t.id}`;
16229 const countEl = document.createElement("span");
16230 countEl.className = "wpd-tagcloud__search-meta";
16231 countEl.textContent = sprintf(
16232 /* translators: %d: number of posts assigned to a tag. */
16233 _n("%d post", "%d posts", t.count),
16234 t.count
16235 );
16236 btn.appendChild(nameEl);
16237 btn.appendChild(countEl);
16238 btn.addEventListener("mousedown", (ev) => {
16239 ev.preventDefault();
16240 selectMatch(t);
16241 });
16242 btn.addEventListener("mouseenter", () => {
16243 selectedIndex = i;
16244 repaintHighlight();
16245 });
16246 li.appendChild(btn);
16247 searchResults.appendChild(li);
16248 });
16249 searchResults.hidden = currentMatches.length === 0;
16250 };
16251 searchInput.addEventListener("input", renderSearchResults);
16252 searchInput.addEventListener("focus", renderSearchResults);
16253 searchInput.addEventListener("keydown", (ev) => {
16254 if (ev.key === "ArrowDown") {
16255 if (currentMatches.length === 0) {
16256 return;
16257 }
16258 ev.preventDefault();
16259 selectedIndex = Math.min(
16260 selectedIndex + 1,
16261 currentMatches.length - 1
16262 );
16263 repaintHighlight();
16264 } else if (ev.key === "ArrowUp") {
16265 if (currentMatches.length === 0) {
16266 return;
16267 }
16268 ev.preventDefault();
16269 selectedIndex = Math.max(selectedIndex - 1, 0);
16270 repaintHighlight();
16271 } else if (ev.key === "Enter") {
16272 if (currentMatches.length === 0) {
16273 return;
16274 }
16275 ev.preventDefault();
16276 selectMatch(currentMatches[selectedIndex]);
16277 } else if (ev.key === "Escape") {
16278 searchInput.value = "";
16279 searchResults.hidden = true;
16280 searchResults.replaceChildren();
16281 currentMatches = [];
16282 selectedIndex = 0;
16283 }
16284 });
16285 searchInput.addEventListener("blur", () => {
16286 setTimeout(() => {
16287 searchResults.hidden = true;
16288 }, 120);
16289 });
16290 const onDocClickSearch = (ev) => {
16291 if (!searchWrap.contains(ev.target)) {
16292 searchResults.hidden = true;
16293 }
16294 };
16295 document.addEventListener("click", onDocClickSearch);
16296 return () => {
16297 if (raf !== null) {
16298 cancelAnimationFrame(raf);
16299 raf = null;
16300 }
16301 if (settleTimer !== null) {
16302 window.clearTimeout(settleTimer);
16303 settleTimer = null;
16304 }
16305 ro.disconnect();
16306 stage.removeEventListener("wheel", onWheel);
16307 document.removeEventListener("click", onDocClickSearch);
16308 try {
16309 app.ticker?.stop();
16310 } catch {
16311 }
16312 try {
16313 app.destroy({ removeView: true }, { children: true });
16314 } catch {
16315 }
16316 host.replaceChildren();
16317 host.classList.remove("wpd-tagcloud");
16318 };
16319 }
16320 function fontSizeFor(count, max) {
16321 const ratio = Math.sqrt(count / Math.max(1, max));
16322 return Math.round(
16323 MIN_FONT_SIZE + (MAX_FONT_SIZE - MIN_FONT_SIZE) * ratio
16324 );
16325 }
16326 function truncateChipName(name) {
16327 return name.length > CHIP_NAME_MAX_CHARS ? name.slice(0, CHIP_NAME_MAX_CHARS - 1) + "…" : name;
16328 }
16329 function aabbIntersect(a, b) {
16330 return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
16331 }
16332 function slugHash(slug) {
16333 let h = 0;
16334 for (let i = 0; i < slug.length; i++) {
16335 h = (h * 31 + slug.charCodeAt(i)) % 2147483647;
16336 }
16337 return h;
16338 }
16339 function tagHue(slug, baseHue) {
16340 const h = slugHash(slug);
16341 return ((baseHue + h % 256 * 1.4) % 360 + 360) % 360;
16342 }
16343 function tagRotation(slug) {
16344 const h = slugHash(slug);
16345 const sign = h % 2 === 0 ? -1 : 1;
16346 const mag = Math.floor(h / 2) % 4 * 0.011;
16347 return sign * mag;
16348 }
16349 function readAdminThemeHue() {
16350 try {
16351 const value = getComputedStyle(document.documentElement).getPropertyValue("--wp-admin-theme-color").trim();
16352 if (!value) {
16353 return 210;
16354 }
16355 const c = document.createElement("span");
16356 c.style.color = value;
16357 document.body.appendChild(c);
16358 const rgb = getComputedStyle(c).color;
16359 c.remove();
16360 const m = rgb.match(/\d+/g);
16361 if (!m || m.length < 3) {
16362 return 210;
16363 }
16364 return rgbToHue(
16365 parseInt(m[0], 10),
16366 parseInt(m[1], 10),
16367 parseInt(m[2], 10)
16368 );
16369 } catch {
16370 return 210;
16371 }
16372 }
16373 function rgbToHue(r, g, b) {
16374 const rn = r / 255;
16375 const gn = g / 255;
16376 const bn = b / 255;
16377 const max = Math.max(rn, gn, bn);
16378 const min = Math.min(rn, gn, bn);
16379 const d = max - min;
16380 if (d === 0) {
16381 return 210;
16382 }
16383 let h;
16384 switch (max) {
16385 case rn:
16386 h = (gn - bn) / d + (gn < bn ? 6 : 0);
16387 break;
16388 case gn:
16389 h = (bn - rn) / d + 2;
16390 break;
16391 default:
16392 h = (rn - gn) / d + 4;
16393 break;
16394 }
16395 return Math.round(h * 60);
16396 }
16397 function hslToInt(h, s, l) {
16398 const sn = s / 100;
16399 const ln = l / 100;
16400 const c = (1 - Math.abs(2 * ln - 1)) * sn;
16401 const hp = h / 60;
16402 const x = c * (1 - Math.abs(hp % 2 - 1));
16403 let r = 0;
16404 let g = 0;
16405 let b = 0;
16406 if (hp < 1) {
16407 r = c;
16408 g = x;
16409 } else if (hp < 2) {
16410 r = x;
16411 g = c;
16412 } else if (hp < 3) {
16413 g = c;
16414 b = x;
16415 } else if (hp < 4) {
16416 g = x;
16417 b = c;
16418 } else if (hp < 5) {
16419 r = x;
16420 b = c;
16421 } else {
16422 r = c;
16423 b = x;
16424 }
16425 const m = ln - c / 2;
16426 const ri = Math.round((r + m) * 255);
16427 const gi = Math.round((g + m) * 255);
16428 const bi = Math.round((b + m) * 255);
16429 return ri * 65536 + gi * 256 + bi;
16430 }
16431 function stripTags(html2) {
16432 const tmp = document.createElement("div");
16433 tmp.innerHTML = html2;
16434 return tmp.textContent || tmp.innerText || "";
16435 }
16436 function showToast(title, err) {
16437 const reason = err instanceof Error ? err.message : String(err);
16438 const api = window.wp?.desktop;
16439 if (api && typeof api.showToast === "function") {
16440 api.showToast({
16441 message: `${title} ${reason}`.trim(),
16442 duration: 6e3
16443 });
16444 return;
16445 }
16446 console.error(title, err);
16447 }
16448 async function fetchShellJson(client, url) {
16449 const cfg = client.getConfig();
16450 const init = {
16451 method: "GET",
16452 credentials: "same-origin",
16453 headers: {
16454 "X-WP-Nonce": cfg.restNonce,
16455 Accept: "application/json"
16456 }
16457 };
16458 const response = await trackedFetch(url, init, {
16459 windowId: "desktop-mode-posts"
16460 });
16461 if (!response.ok) {
16462 throw new Error(`${response.status} ${response.statusText}`);
16463 }
16464 const json = await response.json();
16465 return { json, headers: response.headers };
16466 }
16467 function computePositionsKey() {
16468 try {
16469 const host = window.location.host || "unknown";
16470 const path = window.location.pathname.replace(/\/?wp-admin\/?.*$/, "");
16471 return `wpd-tagcloud-positions:${host}${path}`;
16472 } catch {
16473 return "wpd-tagcloud-positions:fallback";
16474 }
16475 }
16476 function readPersistedPositions(key) {
16477 try {
16478 const raw = window.localStorage.getItem(key);
16479 if (!raw) {
16480 return /* @__PURE__ */ new Map();
16481 }
16482 const parsed = JSON.parse(raw);
16483 if (!parsed || typeof parsed !== "object") {
16484 return /* @__PURE__ */ new Map();
16485 }
16486 const out = /* @__PURE__ */ new Map();
16487 for (const [k, v] of Object.entries(
16488 parsed
16489 )) {
16490 const id = parseInt(k, 10);
16491 if (!Number.isFinite(id)) {
16492 continue;
16493 }
16494 const pos = v;
16495 if (typeof pos?.x === "number" && typeof pos?.y === "number") {
16496 out.set(id, { x: pos.x, y: pos.y });
16497 }
16498 }
16499 return out;
16500 } catch {
16501 return /* @__PURE__ */ new Map();
16502 }
16503 }
16504 function writePersistedPositions(key, positions) {
16505 try {
16506 const obj = {};
16507 for (const [id, pos] of positions) {
16508 obj[String(id)] = pos;
16509 }
16510 window.localStorage.setItem(key, JSON.stringify(obj));
16511 } catch {
16512 }
16513 }
16514 function createTagChip(pixi, chipLayer, term, fontSize, hue) {
16515 const container = new pixi.Container();
16516 container.eventMode = "static";
16517 container.cursor = "pointer";
16518 const shadow = new pixi.Graphics();
16519 container.addChild(shadow);
16520 const bg = new pixi.Graphics();
16521 container.addChild(bg);
16522 const hashText = new pixi.Text({
16523 text: "#",
16524 style: {
16525 fill: hslToInt(hue, 65, 42),
16526 fontSize,
16527 fontFamily: FONT_FAMILY,
16528 fontWeight: "700"
16529 },
16530 resolution: CHIP_TEXT_RES
16531 });
16532 container.addChild(hashText);
16533 const nameText = new pixi.Text({
16534 text: truncateChipName(term.name),
16535 style: {
16536 fill: 1909543,
16537 fontSize,
16538 fontFamily: FONT_FAMILY,
16539 fontWeight: "600"
16540 },
16541 resolution: CHIP_TEXT_RES
16542 });
16543 container.addChild(nameText);
16544 const countText = new pixi.Text({
16545 text: String(term.count),
16546 style: {
16547 fill: 16777215,
16548 fontSize: Math.max(10, Math.round(fontSize * 0.55)),
16549 fontFamily: FONT_FAMILY,
16550 fontWeight: "700"
16551 },
16552 resolution: CHIP_TEXT_RES
16553 });
16554 container.addChild(countText);
16555 chipLayer.addChild(container);
16556 return {
16557 container,
16558 shadow,
16559 bg,
16560 hashText,
16561 nameText,
16562 countText,
16563 width: 0,
16564 height: 0,
16565 cachedName: "",
16566 cachedCount: -1,
16567 cachedFocused: false,
16568 cachedHover: false,
16569 cachedHue: -1
16570 };
16571 }
16572 const tagsCloud = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
16573 __proto__: null,
16574 mountTagsCloud
16575 }, Symbol.toStringTag, { value: "Module" }));
16576 async function showUsersIntroDialog() {
16577 return new Promise((resolve) => {
16578 const backdrop = document.createElement("div");
16579 backdrop.className = "desktop-mode-users-intro__backdrop";
16580 backdrop.setAttribute("role", "presentation");
16581 Object.assign(backdrop.style, {
16582 position: "fixed",
16583 inset: "0",
16584 background: "color-mix(in srgb, var(--wp-admin-theme-color, #1d2327) 60%, transparent)",
16585 backdropFilter: "blur(2px)",
16586 zIndex: "100000",
16587 display: "flex",
16588 alignItems: "center",
16589 justifyContent: "center",
16590 padding: "24px"
16591 });
16592 const dialog = document.createElement("div");
16593 dialog.setAttribute("role", "dialog");
16594 dialog.setAttribute("aria-modal", "true");
16595 dialog.setAttribute(
16596 "aria-labelledby",
16597 "desktop-mode-users-intro-title"
16598 );
16599 dialog.className = "desktop-mode-users-intro";
16600 Object.assign(dialog.style, {
16601 background: "var(--wp-admin-theme-bg, #fff)",
16602 color: "var(--wp-admin-theme-fg, #1d2327)",
16603 borderRadius: "14px",
16604 boxShadow: "0 24px 60px rgba(0,0,0,.28)",
16605 maxWidth: "520px",
16606 width: "100%",
16607 maxHeight: "90vh",
16608 overflow: "auto",
16609 padding: "28px 32px 24px",
16610 fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
16611 });
16612 dialog.innerHTML = renderDialogMarkup();
16613 backdrop.appendChild(dialog);
16614 document.body.appendChild(backdrop);
16615 const primaryBtn = dialog.querySelector(
16616 '[data-action="confirm"]'
16617 );
16618 const settingsBtn = dialog.querySelector(
16619 '[data-action="settings"]'
16620 );
16621 primaryBtn?.focus();
16622 let resolved = false;
16623 const cleanup = (result) => {
16624 if (resolved) {
16625 return;
16626 }
16627 resolved = true;
16628 document.removeEventListener("keydown", onKey, true);
16629 backdrop.remove();
16630 resolve(result);
16631 };
16632 const onKey = (e) => {
16633 if (e.key === "Escape") {
16634 e.preventDefault();
16635 cleanup("cancel");
16636 }
16637 };
16638 document.addEventListener("keydown", onKey, true);
16639 backdrop.addEventListener("click", (e) => {
16640 if (e.target === backdrop) {
16641 cleanup("cancel");
16642 }
16643 });
16644 primaryBtn?.addEventListener("click", () => cleanup("confirm"));
16645 settingsBtn?.addEventListener("click", () => cleanup("settings"));
16646 });
16647 }
16648 function renderDialogMarkup() {
16649 const title = __("Welcome to the new Users window");
16650 const lede = __(
16651 "Same data you already manage, with the polish the Users list has been waiting for."
16652 );
16653 const highlights = [
16654 __("Live online indicator on every row — see who is around right now."),
16655 __("Last-login column so you finally know who is actually using the site."),
16656 __("Bulk role change with strict role-permission enforcement — never accidentally promote anyone above your own level."),
16657 __("One-click password reset and resend-welcome buttons, with sensible rate-limiting."),
16658 __("Click-to-copy email and a long-overdue search that matches name, username, AND email."),
16659 __("Per-user content stats: posts, pages, comments at a glance.")
16660 ];
16661 const li = (arr) => arr.map(
16662 (s) => `<li><span class="dot" aria-hidden="true"></span>${escapeHtml(s)}</li>`
16663 ).join("");
16664 return `
16665 <style>
16666 .desktop-mode-users-intro h2 {
16667 margin: 0 0 8px;
16668 font-size: 22px;
16669 font-weight: 600;
16670 letter-spacing: -0.01em;
16671 }
16672 .desktop-mode-users-intro p.lede {
16673 margin: 0 0 20px;
16674 color: var(--wp-admin-theme-fg-muted, #50575e);
16675 font-size: 14px;
16676 line-height: 1.5;
16677 }
16678 .desktop-mode-users-intro__list {
16679 list-style: none;
16680 margin: 0 0 22px;
16681 padding: 0;
16682 font-size: 14px;
16683 line-height: 1.5;
16684 }
16685 .desktop-mode-users-intro__list li {
16686 display: flex;
16687 align-items: flex-start;
16688 gap: 10px;
16689 padding: 6px 0;
16690 }
16691 .desktop-mode-users-intro__list .dot {
16692 flex: 0 0 auto;
16693 width: 6px;
16694 height: 6px;
16695 margin-top: 9px;
16696 border-radius: 50%;
16697 background: var(--wp-admin-theme-color, #2271b1);
16698 }
16699 .desktop-mode-users-intro__footer {
16700 display: flex;
16701 justify-content: flex-end;
16702 gap: 8px;
16703 margin-top: 8px;
16704 }
16705 .desktop-mode-users-intro__footer button {
16706 appearance: none;
16707 border: 1px solid var(--wp-admin-theme-border, #dcdcde);
16708 background: var(--wp-admin-theme-bg, #fff);
16709 color: inherit;
16710 padding: 8px 14px;
16711 border-radius: 6px;
16712 font-size: 13px;
16713 cursor: pointer;
16714 }
16715 .desktop-mode-users-intro__footer button.primary {
16716 border-color: var(--wp-admin-theme-color, #2271b1);
16717 background: var(--wp-admin-theme-color, #2271b1);
16718 color: #fff;
16719 font-weight: 500;
16720 }
16721 .desktop-mode-users-intro__footer button:hover { filter: brightness(1.05); }
16722 .desktop-mode-users-intro__footer button:focus-visible {
16723 outline: 2px solid var(--wp-admin-theme-color, #2271b1);
16724 outline-offset: 2px;
16725 }
16726 </style>
16727 <h2 id="desktop-mode-users-intro-title">${escapeHtml(title)}</h2>
16728 <p class="lede">${escapeHtml(lede)}</p>
16729 <ul class="desktop-mode-users-intro__list">${li(highlights)}</ul>
16730 <div class="desktop-mode-users-intro__footer">
16731 <button type="button" data-action="settings">${escapeHtml(
16732 __("Take me to settings")
16733 )}</button>
16734 <button type="button" class="primary" data-action="confirm">${escapeHtml(
16735 __("Got it")
16736 )}</button>
16737 </div>
16738 `;
16739 }
16740 function escapeHtml(s) {
16741 const t = document.createElement("div");
16742 t.textContent = s;
16743 return t.innerHTML;
16744 }
16745 const _initial = {
16746 userId: null,
16747 requestedAt: 0,
16748 tabRequested: false
16749 };
16750 let _store = null;
16751 function getStore() {
16752 if (_store) {
16753 return _store;
16754 }
16755 const w = window;
16756 const factory = w.wp?.desktop?.createSharedStore;
16757 if (typeof factory !== "function") {
16758 return null;
16759 }
16760 _store = factory(
16761 "desktop-mode/user-edit/target",
16762 () => ({ ..._initial })
16763 );
16764 return _store;
16765 }
16766 function setUserEditTarget(userId) {
16767 const store = getStore();
16768 if (store) {
16769 store.state.userId = userId;
16770 store.state.requestedAt = Date.now();
16771 store.state.tabRequested = true;
16772 store.notify();
16773 return;
16774 }
16775 const w = window;
16776 w._wpdUserEditTarget = {
16777 userId,
16778 requestedAt: Date.now(),
16779 tabRequested: true
16780 };
16781 }
16782 function readUserEditTarget() {
16783 const store = getStore();
16784 if (store) {
16785 return { ...store.state };
16786 }
16787 const w = window;
16788 return w._wpdUserEditTarget ?? { ..._initial };
16789 }
16790 function clearUserEditTarget() {
16791 const store = getStore();
16792 if (store) {
16793 store.state.userId = null;
16794 store.state.requestedAt = 0;
16795 store.state.tabRequested = false;
16796 store.notify();
16797 }
16798 const w = window;
16799 if (w._wpdUserEditTarget) {
16800 w._wpdUserEditTarget = {
16801 userId: null,
16802 requestedAt: 0,
16803 tabRequested: false
16804 };
16805 }
16806 }
16807 function setUserEditTabRequested(requested) {
16808 const store = getStore();
16809 if (store) {
16810 store.state.tabRequested = requested;
16811 store.notify();
16812 return;
16813 }
16814 const w = window;
16815 const prev = w._wpdUserEditTarget ?? { ..._initial };
16816 w._wpdUserEditTarget = { ...prev, tabRequested: requested };
16817 }
16818 function subscribeUserEditTarget(cb) {
16819 const store = getStore();
16820 if (!store) {
16821 return () => {
16822 };
16823 }
16824 return store.subscribe((state) => cb({ ...state }));
16825 }
16826 const userEditTarget = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
16827 __proto__: null,
16828 clearUserEditTarget,
16829 readUserEditTarget,
16830 setUserEditTabRequested,
16831 setUserEditTarget,
16832 subscribeUserEditTarget
16833 }, Symbol.toStringTag, { value: "Module" }));
16834 function wpdConfirmGlobal(options) {
16835 const w = window;
16836 const fn = w.wp?.desktop?.confirm;
16837 if (typeof fn !== "function") {
16838 return Promise.resolve(window.confirm(options.message));
16839 }
16840 return fn(options);
16841 }
16842 function notifyToast(body, opts = {}) {
16843 const w = window;
16844 const api = w.wp?.desktop;
16845 if (api?.notify) {
16846 api.notify({ body, kind: opts.kind });
16847 return;
16848 }
16849 console.info("[users-window]", body);
16850 }
16851 function openUserEditWindow(userId) {
16852 if (!Number.isFinite(userId) || userId <= 0) {
16853 return;
16854 }
16855 setUserEditTarget(userId);
16856 console.info(
16857 "[users-window] opening user-edit window for user",
16858 userId
16859 );
16860 const w = window;
16861 const fn = w.wp?.desktop?.openWindow;
16862 if (typeof fn !== "function") {
16863 console.error(
16864 "[users-window] wp.desktop.openWindow is missing — desktop shell may not be ready."
16865 );
16866 notifyToast(
16867 __("Could not open profile window — desktop shell unavailable."),
16868 { kind: "error" }
16869 );
16870 return;
16871 }
16872 const opened = fn("desktop-mode-user-edit", {
16873 source: "users-window/row-click"
16874 });
16875 if (!opened) {
16876 console.error(
16877 '[users-window] openWindow("desktop-mode-user-edit") returned false — window not registered server-side. Check includes/user-edit-window/window.php.'
16878 );
16879 notifyToast(
16880 __("Profile window not registered — see console."),
16881 { kind: "error" }
16882 );
16883 }
16884 }
16885 const ROOT = "[data-desktop-mode-posts-root]";
16886 const STATUS = "[data-desktop-mode-posts-status]";
16887 const SEARCH = "[data-desktop-mode-posts-search]";
16888 const REFRESH = "[data-desktop-mode-posts-refresh]";
16889 const NEW_BTN = "[data-desktop-mode-posts-new]";
16890 const TABLE = "[data-desktop-mode-posts-table]";
16891 const BULK = "[data-desktop-mode-posts-bulk]";
16892 const COUNT = "[data-desktop-mode-posts-count]";
16893 const PAGE_INDICATOR = "[data-desktop-mode-posts-page-indicator]";
16894 const PREV = "[data-desktop-mode-posts-prev]";
16895 const NEXT = "[data-desktop-mode-posts-next]";
16896 const PER_PAGE = "[data-desktop-mode-posts-per-page]";
16897 const BULK_ACTIONS_HOST = "[data-desktop-mode-posts-bulk-actions]";
16898 const SEARCH_DEBOUNCE_MS = 250;
16899 function userCellKey(id, key) {
16900 return `${id}::${key}`;
16901 }
16902 function memoUserCell(cache, id, key, build) {
16903 const k = userCellKey(id, key);
16904 const cached = cache.get(k);
16905 if (cached) {
16906 return cached;
16907 }
16908 const node = build();
16909 cache.set(k, node);
16910 return node;
16911 }
16912 const _usersIntroShown = { v: false };
16913 function maybeShowUsersIntro(client) {
16914 if (_usersIntroShown.v) {
16915 return;
16916 }
16917 let cfg;
16918 try {
16919 cfg = client.getConfig();
16920 } catch {
16921 return;
16922 }
16923 if (cfg.introSeen) {
16924 return;
16925 }
16926 _usersIntroShown.v = true;
16927 void showUsersIntroDialog().then((result) => {
16928 if (result === "cancel") {
16929 _usersIntroShown.v = false;
16930 return;
16931 }
16932 void markUsersIntroSeen(client, cfg);
16933 if (result === "settings") {
16934 const w = window;
16935 w.wp?.desktop?.openOsSettings?.();
16936 }
16937 }).catch(() => {
16938 _usersIntroShown.v = false;
16939 });
16940 }
16941 async function markUsersIntroSeen(client, cfg) {
16942 if (!cfg.introUrl) {
16943 return;
16944 }
16945 try {
16946 await trackedFetch(
16947 cfg.introUrl,
16948 {
16949 method: "POST",
16950 credentials: "same-origin",
16951 headers: {
16952 "Content-Type": "application/json",
16953 "X-WP-Nonce": cfg.restNonce
16954 },
16955 body: JSON.stringify({ slug: "users" })
16956 },
16957 {
16958 windowId: client.windowId,
16959 source: "users-window/intro"
16960 }
16961 );
16962 cfg.introSeen = true;
16963 } catch {
16964 }
16965 }
16966 function buildIdentityCell(row, cfg) {
16967 const cell = document.createElement("span");
16968 cell.style.cssText = "display:flex;align-items:center;gap:10px;min-width:0;";
16969 const avatar = document.createElement("wpd-avatar");
16970 avatar.setAttribute("size", "32");
16971 if (row.name) {
16972 avatar.setAttribute("name", row.name);
16973 }
16974 const presence = row.desktop_mode_presence ?? "offline";
16975 avatar.setAttribute("presence", presence);
16976 const avatars = row.avatar_urls ?? {};
16977 const rawAvatar = avatars["48"] ?? avatars["96"] ?? avatars["24"] ?? "";
16978 if (rawAvatar) {
16979 applyAvatarSrc(avatar, rawAvatar);
16980 }
16981 cell.appendChild(avatar);
16982 const text = document.createElement("span");
16983 text.style.cssText = "display:flex;flex-direction:column;min-width:0;line-height:1.25;";
16984 const nameRow = document.createElement("span");
16985 const name = document.createElement("a");
16986 name.href = `${cfg.editPostUrlBase}?user_id=${row.id}`;
16987 name.textContent = row.name || `#${row.id}`;
16988 name.title = name.textContent;
16989 name.setAttribute("data-noclick", "");
16990 name.style.cssText = "font-weight:600;color:inherit;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;";
16991 name.addEventListener("mouseenter", () => {
16992 name.style.textDecoration = "underline";
16993 });
16994 name.addEventListener("mouseleave", () => {
16995 name.style.textDecoration = "none";
16996 });
16997 name.addEventListener("click", (e) => {
16998 e.preventDefault();
16999 e.stopPropagation();
17000 void openUserEditWindow(row.id);
17001 });
17002 nameRow.appendChild(name);
17003 text.appendChild(nameRow);
17004 if (row.slug) {
17005 const sub = document.createElement("span");
17006 sub.textContent = `@${row.slug}`;
17007 sub.style.cssText = "font-size:11px;color:var(--wp-admin-theme-fg-muted, #8c8f94);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;";
17008 text.appendChild(sub);
17009 }
17010 cell.appendChild(text);
17011 return cell;
17012 }
17013 function buildEmailCell(row) {
17014 const cell = document.createElement("button");
17015 cell.type = "button";
17016 const email = typeof row.email === "string" ? row.email : "";
17017 cell.textContent = email || "—";
17018 cell.disabled = email === "";
17019 cell.title = email ? __("Click to copy email") : "";
17020 Object.assign(cell.style, {
17021 appearance: "none",
17022 background: "transparent",
17023 border: "none",
17024 padding: "2px 6px",
17025 font: "inherit",
17026 color: "inherit",
17027 cursor: email ? "copy" : "default",
17028 textAlign: "left",
17029 fontSize: "13px",
17030 borderRadius: "4px",
17031 maxWidth: "100%",
17032 overflow: "hidden",
17033 textOverflow: "ellipsis",
17034 whiteSpace: "nowrap"
17035 });
17036 cell.addEventListener("click", (e) => {
17037 e.stopPropagation();
17038 if (!email) {
17039 return;
17040 }
17041 void navigator.clipboard?.writeText(email).then(() => {
17042 const orig = cell.textContent;
17043 cell.textContent = __("Copied!");
17044 cell.style.color = "var(--wp-admin-theme-color, #2271b1)";
17045 setTimeout(() => {
17046 cell.textContent = orig;
17047 cell.style.color = "";
17048 }, 1200);
17049 }).catch(() => {
17050 });
17051 });
17052 return cell;
17053 }
17054 function buildRoleCell(row, cfg) {
17055 const cell = document.createElement("span");
17056 cell.style.cssText = "display:inline-flex;flex-wrap:wrap;gap:4px;min-width:0;";
17057 const roles = Array.isArray(row.roles) ? row.roles : [];
17058 const labels = cfg.allRoles ?? {};
17059 if (roles.length === 0) {
17060 const none = document.createElement("span");
17061 none.textContent = __("No role");
17062 none.style.cssText = "color:var(--wp-admin-theme-fg-muted, #8c8f94);font-style:italic;";
17063 cell.appendChild(none);
17064 return cell;
17065 }
17066 for (const slug of roles) {
17067 const chip = document.createElement("span");
17068 chip.textContent = labels[slug] ?? slug;
17069 chip.style.cssText = [
17070 "display:inline-flex",
17071 "align-items:center",
17072 "padding:2px 8px",
17073 "border-radius:10px",
17074 "font-size:11px",
17075 "font-weight:600",
17076 "background:rgba(34,113,177,0.10)",
17077 "color:#0a4b78",
17078 "white-space:nowrap"
17079 ].join(";");
17080 cell.appendChild(chip);
17081 }
17082 return cell;
17083 }
17084 function buildStatsCell(row) {
17085 const stats = row.desktop_mode_user_stats ?? {
17086 posts: 0,
17087 pages: 0,
17088 comments: 0
17089 };
17090 const cell = document.createElement("span");
17091 cell.style.cssText = "display:inline-flex;align-items:center;gap:10px;font-size:12px;font-variant-numeric:tabular-nums;";
17092 const mk = (dashicon, count, label) => {
17093 const span = document.createElement("span");
17094 span.style.cssText = "display:inline-flex;align-items:center;gap:3px;";
17095 span.title = label;
17096 const ic = document.createElement("wpd-icon");
17097 ic.setAttribute("name", dashicon);
17098 ic.setAttribute("size", "14");
17099 ic.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
17100 span.appendChild(ic);
17101 const txt = document.createElement("span");
17102 txt.textContent = String(count);
17103 if (count === 0) {
17104 txt.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
17105 }
17106 span.appendChild(txt);
17107 return span;
17108 };
17109 cell.appendChild(mk("admin-post", stats.posts, __("Posts")));
17110 cell.appendChild(mk("admin-page", stats.pages, __("Pages")));
17111 cell.appendChild(
17112 mk("admin-comments", stats.comments, __("Comments"))
17113 );
17114 return cell;
17115 }
17116 function relativeTime(ts) {
17117 const now = Math.floor(Date.now() / 1e3);
17118 const delta = now - ts;
17119 if (delta < 60) {
17120 return __("just now");
17121 }
17122 if (delta < 3600) {
17123 const m = Math.floor(delta / 60);
17124 return sprintf(__("%d min ago"), m);
17125 }
17126 if (delta < 86400) {
17127 const h = Math.floor(delta / 3600);
17128 return sprintf(__("%d h ago"), h);
17129 }
17130 if (delta < 86400 * 30) {
17131 const d = Math.floor(delta / 86400);
17132 return sprintf(__("%d d ago"), d);
17133 }
17134 if (delta < 86400 * 365) {
17135 const mo = Math.floor(delta / (86400 * 30));
17136 return sprintf(__("%d mo ago"), mo);
17137 }
17138 const y = Math.floor(delta / (86400 * 365));
17139 return sprintf(__("%d y ago"), y);
17140 }
17141 function buildLastLoginCell(row) {
17142 const cell = document.createElement("span");
17143 cell.style.cssText = "font-size:13px;font-variant-numeric:tabular-nums;";
17144 const ts = row.desktop_mode_last_login;
17145 if (!ts || typeof ts !== "number") {
17146 cell.textContent = __("Never");
17147 cell.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
17148 return cell;
17149 }
17150 cell.textContent = relativeTime(ts);
17151 const dt = new Date(ts * 1e3);
17152 cell.title = dt.toLocaleString();
17153 return cell;
17154 }
17155 function buildRegisteredCell(row) {
17156 const cell = document.createElement("span");
17157 cell.style.cssText = "font-size:13px;font-variant-numeric:tabular-nums;";
17158 const raw = typeof row.registered_date === "string" ? row.registered_date : "";
17159 if (!raw) {
17160 cell.textContent = "—";
17161 cell.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
17162 return cell;
17163 }
17164 const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(raw);
17165 const ts = Math.floor(Date.parse(hasTz ? raw : raw + "Z") / 1e3);
17166 if (!Number.isFinite(ts)) {
17167 cell.textContent = raw;
17168 return cell;
17169 }
17170 cell.textContent = relativeTime(ts);
17171 cell.title = new Date(ts * 1e3).toLocaleString();
17172 return cell;
17173 }
17174 function buildActionsCell(row, cfg, client) {
17175 const cell = document.createElement("span");
17176 cell.style.cssText = "display:inline-flex;gap:4px;align-items:center;";
17177 const canEditViewer = cfg.canEdit === true;
17178 const canEditRow = row.desktop_mode_can_edit === true;
17179 if (!canEditViewer || !canEditRow) {
17180 cell.textContent = "—";
17181 cell.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
17182 return cell;
17183 }
17184 const mk = (label, dashicon, fn) => {
17185 const btn = document.createElement("button");
17186 btn.type = "button";
17187 btn.title = label;
17188 btn.setAttribute("aria-label", label);
17189 Object.assign(btn.style, {
17190 appearance: "none",
17191 border: "1px solid var(--wp-admin-theme-border, #dcdcde)",
17192 background: "var(--wp-admin-theme-bg, #fff)",
17193 color: "inherit",
17194 padding: "4px 6px",
17195 borderRadius: "4px",
17196 cursor: "pointer",
17197 lineHeight: "1"
17198 });
17199 const ic = document.createElement("wpd-icon");
17200 ic.setAttribute("name", dashicon);
17201 ic.setAttribute("size", "14");
17202 btn.appendChild(ic);
17203 btn.addEventListener("click", (e) => {
17204 e.stopPropagation();
17205 fn();
17206 });
17207 return btn;
17208 };
17209 cell.appendChild(
17210 mk(
17211 __("Send password reset"),
17212 "email-alt",
17213 async () => {
17214 const ok = await wpdConfirmGlobal({
17215 title: __("Send password reset email?"),
17216 message: sprintf(
17217 // translators: %s is a user name.
17218 __("WordPress will email %s a password-reset link."),
17219 row.name
17220 ),
17221 confirmLabel: __("Send reset email")
17222 });
17223 if (!ok) {
17224 return;
17225 }
17226 const result = await client.sendPasswordReset(row.id);
17227 if (result.ok) {
17228 notifyToast(
17229 sprintf(
17230 // translators: %s is the user's email address.
17231 __("Reset email sent to %s."),
17232 result.email ?? row.email ?? ""
17233 ),
17234 { kind: "success" }
17235 );
17236 } else {
17237 notifyToast(
17238 sprintf(
17239 // translators: %s is an error code.
17240 __("Could not send reset email (%s)."),
17241 result.error ?? "unknown"
17242 ),
17243 { kind: "error" }
17244 );
17245 }
17246 }
17247 )
17248 );
17249 cell.appendChild(
17250 mk(
17251 __("Resend welcome email"),
17252 "megaphone",
17253 async () => {
17254 const ok = await wpdConfirmGlobal({
17255 title: __("Resend welcome email?"),
17256 message: sprintf(
17257 // translators: %s is a user name.
17258 __(
17259 "WordPress will resend the original welcome email to %s."
17260 ),
17261 row.name
17262 ),
17263 confirmLabel: __("Resend")
17264 });
17265 if (!ok) {
17266 return;
17267 }
17268 const result = await client.resendWelcome(row.id);
17269 if (result.ok) {
17270 notifyToast(
17271 sprintf(
17272 // translators: %s is the user's email address.
17273 __("Welcome email resent to %s."),
17274 result.email ?? row.email ?? ""
17275 ),
17276 { kind: "success" }
17277 );
17278 } else {
17279 notifyToast(
17280 sprintf(
17281 // translators: %s is an error code.
17282 __("Could not resend welcome (%s)."),
17283 result.error ?? "unknown"
17284 ),
17285 { kind: "error" }
17286 );
17287 }
17288 }
17289 )
17290 );
17291 return cell;
17292 }
17293 function buildColumns(cache, cfg, client) {
17294 const cols = [
17295 {
17296 key: "identity",
17297 label: __("Name"),
17298 sortable: false,
17299 sticky: true,
17300 minWidth: "260px",
17301 render: (_v, row) => memoUserCell(
17302 cache,
17303 row.id,
17304 "identity",
17305 () => buildIdentityCell(row, cfg)
17306 )
17307 },
17308 {
17309 key: "email",
17310 label: __("Email"),
17311 minWidth: "220px",
17312 render: (_v, row) => memoUserCell(cache, row.id, "email", () => buildEmailCell(row))
17313 },
17314 {
17315 key: "role",
17316 label: __("Role"),
17317 width: "180px",
17318 render: (_v, row) => memoUserCell(
17319 cache,
17320 row.id,
17321 "role",
17322 () => buildRoleCell(row, cfg)
17323 )
17324 },
17325 {
17326 key: "stats",
17327 label: __("Content"),
17328 width: "160px",
17329 sortValue: (row) => {
17330 const s = row.desktop_mode_user_stats;
17331 return s ? s.posts + s.pages + s.comments : 0;
17332 },
17333 render: (_v, row) => memoUserCell(cache, row.id, "stats", () => buildStatsCell(row))
17334 },
17335 {
17336 key: "last_login",
17337 label: __("Last login"),
17338 width: "140px",
17339 sortable: false,
17340 sortValue: (row) => typeof row.desktop_mode_last_login === "number" ? row.desktop_mode_last_login : 0,
17341 render: (_v, row) => memoUserCell(
17342 cache,
17343 row.id,
17344 "last_login",
17345 () => buildLastLoginCell(row)
17346 )
17347 },
17348 {
17349 key: "registered",
17350 label: __("Registered"),
17351 width: "140px",
17352 sortable: true,
17353 render: (_v, row) => memoUserCell(
17354 cache,
17355 row.id,
17356 "registered",
17357 () => buildRegisteredCell(row)
17358 )
17359 }
17360 ];
17361 if (cfg.canEdit === true) {
17362 cols.push({
17363 key: "actions",
17364 label: __("Actions"),
17365 width: "110px",
17366 sortable: false,
17367 render: (_v, row) => (
17368 // Actions cell is intentionally NOT memoized — its closure
17369 // captures `row` and the row payload changes between
17370 // fetches. Cheap to rebuild, fewer surprises.
17371 buildActionsCell(row, cfg, client)
17372 )
17373 });
17374 }
17375 return cols;
17376 }
17377 function defaultStatusSegments() {
17378 return [
17379 { value: "", label: __("All") },
17380 { value: "online", label: __("Online") },
17381 { value: "recent", label: __("Active 30d") },
17382 { value: "never", label: __("Never logged in") }
17383 ];
17384 }
17385 function applyClientStatusFilter(rows, status) {
17386 if (!status) {
17387 return rows;
17388 }
17389 if (status === "online") {
17390 return rows.filter((r) => r.desktop_mode_presence === "online");
17391 }
17392 if (status === "recent") {
17393 const now = Math.floor(Date.now() / 1e3);
17394 return rows.filter((r) => {
17395 const ts = r.desktop_mode_last_login;
17396 return typeof ts === "number" && ts > 0 && now - ts < 86400 * 30;
17397 });
17398 }
17399 if (status === "never") {
17400 return rows.filter(
17401 (r) => !r.desktop_mode_last_login || typeof r.desktop_mode_last_login !== "number"
17402 );
17403 }
17404 return rows;
17405 }
17406 async function renderUsersWindow(body, client) {
17407 const root = body.querySelector(ROOT);
17408 const table = body.querySelector(TABLE);
17409 if (!root || !table) {
17410 return;
17411 }
17412 table.addEventListener("wpd-table-row-click", (e) => {
17413 const detail = e.detail;
17414 const id = detail?.row?.id;
17415 if (typeof id !== "number" || id <= 0) {
17416 return;
17417 }
17418 void openUserEditWindow(id);
17419 });
17420 maybeShowUsersIntro(client);
17421 const cfg = client.getConfig();
17422 const view = {
17423 page: 1,
17424 perPage: Math.max(1, cfg.defaultPerPage || 20),
17425 search: "",
17426 status: "",
17427 orderby: "name",
17428 order: "asc",
17429 roles: [],
17430 searchDebounce: null
17431 };
17432 const cellCache = /* @__PURE__ */ new Map();
17433 table.columns = buildColumns(cellCache, cfg, client);
17434 table.getRowId = (row) => row.id;
17435 table.sort = { key: "name", direction: "asc" };
17436 if (!cfg.canEdit && !cfg.canPromote && !cfg.canDelete) {
17437 table.removeAttribute("selectable");
17438 }
17439 let totalPages = 0;
17440 let totalRows = 0;
17441 let refreshSeq = 0;
17442 const perPageEl = root.querySelector(PER_PAGE);
17443 if (perPageEl) {
17444 perPageEl.value = String(view.perPage);
17445 }
17446 const clearSelectionOnQueryChange = () => {
17447 table.clearSelection();
17448 };
17449 const indicator = root.querySelector(PAGE_INDICATOR);
17450 const prevBtn = root.querySelector(PREV);
17451 const nextBtn = root.querySelector(NEXT);
17452 const bulkBar = root.querySelector(BULK);
17453 const countEl = root.querySelector(COUNT);
17454 const bulkActionsHost = root.querySelector(BULK_ACTIONS_HOST);
17455 const statusHost = root.querySelector(STATUS);
17456 if (statusHost) {
17457 statusHost.replaceChildren();
17458 for (const seg of defaultStatusSegments()) {
17459 const el = document.createElement("wpd-segment");
17460 el.setAttribute("value", seg.value);
17461 el.textContent = seg.label;
17462 statusHost.appendChild(el);
17463 }
17464 statusHost.addEventListener("wpd-pick", (e) => {
17465 const detail = e.detail;
17466 view.status = detail?.value ?? "";
17467 view.page = 1;
17468 clearSelectionOnQueryChange();
17469 void refresh();
17470 });
17471 }
17472 const searchEl = root.querySelector(SEARCH);
17473 if (searchEl) {
17474 searchEl.addEventListener("input", () => {
17475 if (view.searchDebounce !== null) {
17476 clearTimeout(view.searchDebounce);
17477 }
17478 view.searchDebounce = window.setTimeout(() => {
17479 view.search = searchEl.value.trim();
17480 view.page = 1;
17481 clearSelectionOnQueryChange();
17482 void refresh();
17483 }, SEARCH_DEBOUNCE_MS);
17484 });
17485 }
17486 const refreshBtn = root.querySelector(REFRESH);
17487 refreshBtn?.addEventListener("click", () => {
17488 void refresh();
17489 });
17490 const newBtn = root.querySelector(NEW_BTN);
17491 if (newBtn) {
17492 if (!cfg.canCreate) {
17493 newBtn.style.display = "none";
17494 } else {
17495 newBtn.addEventListener("click", (e) => {
17496 e.preventDefault();
17497 const tabs = body.querySelector(
17498 "[data-desktop-mode-users-tabs]"
17499 );
17500 if (!tabs) {
17501 return;
17502 }
17503 tabs.value = "add-new";
17504 tabs.setAttribute("value", "add-new");
17505 });
17506 }
17507 }
17508 perPageEl?.addEventListener("change", () => {
17509 const n = parseInt(perPageEl.value, 10);
17510 if (Number.isFinite(n) && n > 0) {
17511 view.perPage = n;
17512 view.page = 1;
17513 clearSelectionOnQueryChange();
17514 void refresh();
17515 }
17516 });
17517 const renderBulkBar = () => {
17518 if (!bulkBar || !bulkActionsHost) {
17519 return;
17520 }
17521 const sel = table.selection;
17522 const ids = sel ? Array.from(sel) : [];
17523 if (ids.length === 0) {
17524 bulkBar.hidden = true;
17525 return;
17526 }
17527 bulkBar.hidden = false;
17528 if (countEl) {
17529 countEl.textContent = sprintf(
17530 // translators: %d is a count of selected users.
17531 __("%d selected"),
17532 ids.length
17533 );
17534 }
17535 bulkActionsHost.replaceChildren();
17536 const assignable = cfg.assignableRoles ?? {};
17537 const assignableKeys = Object.keys(assignable);
17538 if (cfg.canPromote && assignableKeys.length > 0) {
17539 const wrap = document.createElement("span");
17540 wrap.style.cssText = "display:inline-flex;align-items:center;gap:6px;";
17541 const roleDropdown = document.createElement("select");
17542 Object.assign(roleDropdown.style, {
17543 padding: "4px 8px",
17544 borderRadius: "4px",
17545 border: "1px solid var(--wp-admin-theme-border, #dcdcde)",
17546 background: "var(--wp-admin-theme-bg, #fff)",
17547 color: "inherit",
17548 font: "inherit",
17549 fontSize: "13px"
17550 });
17551 const placeholder = document.createElement("option");
17552 placeholder.value = "";
17553 placeholder.textContent = __("Set role to…");
17554 roleDropdown.appendChild(placeholder);
17555 for (const slug of assignableKeys) {
17556 const opt = document.createElement("option");
17557 opt.value = slug;
17558 opt.textContent = assignable[slug];
17559 roleDropdown.appendChild(opt);
17560 }
17561 const apply = document.createElement("wpd-button");
17562 apply.setAttribute("variant", "primary");
17563 apply.textContent = __("Apply");
17564 apply.addEventListener("click", async (e) => {
17565 e.preventDefault();
17566 const role = roleDropdown.value;
17567 if (!role) {
17568 return;
17569 }
17570 const targetIds = Array.from(
17571 table.selection ?? []
17572 ).map((id) => Number(id));
17573 if (targetIds.length === 0) {
17574 return;
17575 }
17576 const ok = await wpdConfirmGlobal({
17577 title: __("Change role for selected users?"),
17578 message: sprintf(
17579 // translators: %1$d is a user count, %2$s is a role label.
17580 __("Set %1$d user(s)' role to %2$s?"),
17581 targetIds.length,
17582 assignable[role]
17583 ),
17584 confirmLabel: __("Set role")
17585 });
17586 if (!ok) {
17587 return;
17588 }
17589 const out = await client.bulkSetRole(targetIds, role).catch((err) => {
17590 notifyToast(
17591 String(err.message ?? err),
17592 { kind: "error" }
17593 );
17594 return null;
17595 });
17596 if (!out) {
17597 return;
17598 }
17599 const successes = Object.values(out.results).filter(
17600 (r) => r.ok
17601 ).length;
17602 const failures = targetIds.length - successes;
17603 if (successes > 0) {
17604 notifyToast(
17605 sprintf(
17606 // translators: %1$d users updated, %2$d failed.
17607 __("Role updated for %1$d user(s) (%2$d skipped)."),
17608 successes,
17609 failures
17610 ),
17611 { kind: failures > 0 ? "info" : "success" }
17612 );
17613 } else {
17614 notifyToast(__("No users updated."), { kind: "error" });
17615 }
17616 table.clearSelection();
17617 void refresh();
17618 });
17619 wrap.appendChild(roleDropdown);
17620 wrap.appendChild(apply);
17621 bulkActionsHost.appendChild(wrap);
17622 }
17623 };
17624 table.addEventListener("wpd-table-selection-change", renderBulkBar);
17625 prevBtn?.addEventListener("click", () => {
17626 if (view.page > 1) {
17627 view.page -= 1;
17628 clearSelectionOnQueryChange();
17629 void refresh();
17630 }
17631 });
17632 nextBtn?.addEventListener("click", () => {
17633 if (view.page < totalPages) {
17634 view.page += 1;
17635 clearSelectionOnQueryChange();
17636 void refresh();
17637 }
17638 });
17639 const updatePager = () => {
17640 if (indicator) {
17641 indicator.textContent = sprintf(
17642 // translators: %1$d current page, %2$d total pages, %3$d total rows.
17643 __("Page %1$d of %2$d · %3$d users"),
17644 view.page,
17645 Math.max(1, totalPages),
17646 totalRows
17647 );
17648 }
17649 if (prevBtn) {
17650 prevBtn.disabled = view.page <= 1;
17651 }
17652 if (nextBtn) {
17653 nextBtn.disabled = view.page >= totalPages;
17654 }
17655 };
17656 const buildParams = () => {
17657 return {
17658 page: view.page,
17659 perPage: view.perPage,
17660 search: view.search || void 0,
17661 roles: view.roles.length > 0 ? view.roles : void 0,
17662 orderby: view.orderby,
17663 order: view.order
17664 };
17665 };
17666 const refresh = async () => {
17667 const mySeq = ++refreshSeq;
17668 table.toggleAttribute("loading", true);
17669 try {
17670 const result = await client.fetchUsers(buildParams());
17671 if (mySeq !== refreshSeq) {
17672 return;
17673 }
17674 if (result.items.length === 0 && view.page > 1 && result.totalPages > 0 && view.page > result.totalPages) {
17675 view.page = 1;
17676 await refresh();
17677 return;
17678 }
17679 cellCache.clear();
17680 const filtered = applyClientStatusFilter(result.items, view.status);
17681 table.data = filtered;
17682 totalRows = result.total;
17683 totalPages = result.totalPages;
17684 updatePager();
17685 renderBulkBar();
17686 } catch (err) {
17687 console.error("[users-window] fetch failed:", err);
17688 notifyToast(
17689 __("Could not load users. Try Refresh."),
17690 { kind: "error" }
17691 );
17692 } finally {
17693 table.toggleAttribute("loading", false);
17694 }
17695 };
17696 mountAddUserForm(body, client, cfg, {
17697 afterCreate: () => {
17698 const tabs = body.querySelector(
17699 "[data-desktop-mode-users-tabs]"
17700 );
17701 if (tabs) {
17702 tabs.value = "all";
17703 tabs.setAttribute("value", "all");
17704 }
17705 view.page = 1;
17706 void refresh();
17707 }
17708 });
17709 wireProfileSubTab(body, cfg);
17710 const patchUserRow = async (id) => {
17711 try {
17712 const updated = await client.fetchOneUser(id);
17713 const list = table.data;
17714 const idx = list.findIndex((r) => r.id === id);
17715 if (idx < 0) {
17716 return;
17717 }
17718 if (!updated) {
17719 const next2 = list.slice();
17720 next2.splice(idx, 1);
17721 table.data = next2;
17722 return;
17723 }
17724 for (const k of Array.from(cellCache.keys())) {
17725 if (k.startsWith(`${id}::`)) {
17726 cellCache.delete(k);
17727 }
17728 }
17729 const next = list.slice();
17730 next[idx] = updated;
17731 table.data = applyClientStatusFilter(next, view.status);
17732 } catch (err) {
17733 console.warn("[users-window] row patch failed, falling back to refresh", err);
17734 void refresh();
17735 }
17736 };
17737 const subscribeApi = window.wp?.desktop;
17738 const unsubscribe = subscribeApi?.subscribe?.(
17739 "desktop-mode.user.changed",
17740 (payload) => {
17741 const ids = payload?.ids;
17742 if (!Array.isArray(ids)) {
17743 return;
17744 }
17745 for (const raw of ids) {
17746 const id = typeof raw === "number" ? raw : Number(raw);
17747 if (Number.isFinite(id) && id > 0) {
17748 void patchUserRow(id);
17749 }
17750 }
17751 }
17752 );
17753 if (unsubscribe) {
17754 document.addEventListener(
17755 "desktop-mode-window-closed",
17756 (e) => {
17757 const detail = e.detail;
17758 if (detail?.windowId === "desktop-mode-users") {
17759 unsubscribe();
17760 }
17761 },
17762 { once: false }
17763 );
17764 }
17765 void refresh();
17766 }
17767 function wireProfileSubTab(body, cfg) {
17768 const profile = body.querySelector(
17769 "wpd-user-profile[data-wpd-user-profile-self]"
17770 );
17771 if (!profile) {
17772 return;
17773 }
17774 const viewerId = cfg.currentUserId;
17775 if (typeof viewerId === "number" && viewerId > 0) {
17776 profile.setAttribute("user-id", String(viewerId));
17777 }
17778 }
17779 function mountAddUserForm(body, client, cfg, opts) {
17780 const formNullable = body.querySelector(
17781 "[data-desktop-mode-users-add-form]"
17782 );
17783 if (!formNullable) {
17784 return;
17785 }
17786 const form = formNullable;
17787 const defaultRole = cfg.defaultRole ?? "subscriber";
17788 const assignableRoles = cfg.assignableRoles && Object.keys(cfg.assignableRoles).length > 0 ? cfg.assignableRoles : { [defaultRole]: defaultRole };
17789 mountSelect(form, "role", __("Role"), assignableRoles, defaultRole);
17790 mountSelect(
17791 form,
17792 "locale",
17793 __("Language"),
17794 cfg.locales ?? { "": __("Site default") },
17795 ""
17796 );
17797 const generateBtn = form.querySelector(
17798 '[data-action="generate-password"]'
17799 );
17800 generateBtn?.addEventListener("click", (e) => {
17801 e.preventDefault();
17802 e.stopPropagation();
17803 const pwd = generateStrongPassword(18);
17804 const pwdField = form.querySelector(
17805 'wpd-text-field[name="password"]'
17806 );
17807 if (pwdField) {
17808 pwdField.value = pwd;
17809 pwdField.setAttribute("value", pwd);
17810 }
17811 void navigator.clipboard?.writeText(pwd).catch(() => {
17812 });
17813 notifyToast(__("Generated password copied to clipboard."), {
17814 kind: "success"
17815 });
17816 });
17817 let pending = false;
17818 form.addEventListener("wpd-form-submit", (e) => {
17819 const detail = e.detail;
17820 void onSubmit(detail.values);
17821 });
17822 async function onSubmit(values) {
17823 if (pending) {
17824 return;
17825 }
17826 pending = true;
17827 form.setBusy(true);
17828 form.clearErrors();
17829 const payload = {
17830 username: String(values.username ?? "").trim(),
17831 email: String(values.email ?? "").trim(),
17832 first_name: optionalString(values.first_name),
17833 last_name: optionalString(values.last_name),
17834 url: optionalString(values.url),
17835 locale: String(values.locale ?? ""),
17836 password: optionalString(values.password),
17837 role: optionalString(values.role),
17838 send_notification: Boolean(values.send_notification)
17839 };
17840 const result = await client.createUser(payload);
17841 pending = false;
17842 form.setBusy(false);
17843 if (!result.ok) {
17844 handleCreateError(form, result.error, result.message, payload);
17845 return;
17846 }
17847 notifyToast(
17848 sprintf(
17849 // translators: %s is the user's email address.
17850 __("User created — welcome email sent to %s."),
17851 result.email ?? payload.email
17852 ),
17853 { kind: "success" }
17854 );
17855 opts.afterCreate();
17856 }
17857 }
17858 function mountSelect(form, name, _label, optionsMap, initialValue) {
17859 const select = form.querySelector(
17860 `wpd-select[name="${name}"]`
17861 );
17862 if (!select) {
17863 return;
17864 }
17865 const items = Object.entries(optionsMap).map(([value, label]) => ({
17866 value,
17867 label
17868 }));
17869 select.items = items;
17870 if (initialValue && optionsMap[initialValue] !== void 0) {
17871 select.value = initialValue;
17872 select.setAttribute("value", initialValue);
17873 }
17874 }
17875 function handleCreateError(form, code, message, payload) {
17876 let summary = message;
17877 if (!summary) {
17878 switch (code) {
17879 case "desktop_mode_users_username_exists":
17880 case "existing_user_login":
17881 summary = __("That username is already in use.");
17882 break;
17883 case "desktop_mode_users_email_exists":
17884 case "existing_user_email":
17885 summary = __("That email is already in use.");
17886 break;
17887 case "desktop_mode_users_username_invalid":
17888 summary = __("Username is not valid.");
17889 break;
17890 case "desktop_mode_users_email_invalid":
17891 summary = __("A valid email address is required.");
17892 break;
17893 case "desktop_mode_users_role_forbidden":
17894 summary = __("You are not allowed to assign that role.");
17895 break;
17896 default:
17897 summary = __("Could not create the user.");
17898 }
17899 }
17900 form.setError(summary);
17901 if (code === "desktop_mode_users_username_exists" || code === "existing_user_login" || code === "desktop_mode_users_username_invalid") {
17902 form.setFieldInvalid("username");
17903 }
17904 if (code === "desktop_mode_users_email_exists" || code === "existing_user_email" || code === "desktop_mode_users_email_invalid") {
17905 form.setFieldInvalid("email");
17906 }
17907 if (code === "desktop_mode_users_role_forbidden") {
17908 form.setFieldInvalid("role");
17909 }
17910 notifyToast(summary, { kind: "error" });
17911 console.warn("[users-window] create failed", { code, payload });
17912 }
17913 function optionalString(value) {
17914 if (typeof value !== "string") {
17915 return void 0;
17916 }
17917 const trimmed = value.trim();
17918 return trimmed === "" ? void 0 : trimmed;
17919 }
17920 function generateStrongPassword(length) {
17921 const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
17922 const lower = "abcdefghjkmnpqrstuvwxyz";
17923 const digits = "23456789";
17924 const symbols = "!@#$%^&*-_=+";
17925 const all = upper + lower + digits + symbols;
17926 const buf = new Uint32Array(length);
17927 crypto.getRandomValues(buf);
17928 let out = "";
17929 for (let i = 0; i < length; i += 1) {
17930 out += all[buf[i] % all.length];
17931 }
17932 return out;
17933 }
17934 const usersRender = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
17935 __proto__: null,
17936 renderUsersWindow
17937 }, Symbol.toStringTag, { value: "Module" }));
17938 exports.renderPostsWindow = renderPostsWindow;
17939 Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17940 return exports;
17941 }({});
17942