PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.5
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.5
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.5, at assets/js/posts-window.js

17,926 lines 611.2 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 render(result, container) {
1345 const existing = mountState.get(container);
1346 if (existing && existing.strings === result.strings) {
1347 applyValues(existing.parts, result.values);
1348 return;
1349 }
1350 const compiled = compile(result.strings);
1351 const fragment = compiled.template.content.cloneNode(true);
1352 const parts = compiled.buildParts(fragment);
1353 while (container.firstChild) {
1354 container.removeChild(container.firstChild);
1355 }
1356 container.appendChild(fragment);
1357 applyValues(parts, result.values);
1358 mountState.set(container, { strings: result.strings, parts });
1359 }
1360 function applyValues(parts, values) {
1361 for (const part of parts) {
1362 if (part.kind === "node") {
1363 updateChildPart(part.child, values[part.valueIndex]);
1364 } else if (part.kind === "attr") {
1365 let composed = part.template[0];
1366 for (let i = 0; i < part.valueIndices.length; i++) {
1367 composed += formatText(values[part.valueIndices[i]]);
1368 composed += part.template[i + 1];
1369 }
1370 if (composed !== part.last) {
1371 part.last = composed;
1372 if (composed === "") {
1373 part.element.removeAttribute(part.name);
1374 } else {
1375 part.element.setAttribute(part.name, composed);
1376 }
1377 }
1378 } else if (part.kind === "event") {
1379 const next = values[part.valueIndex];
1380 if (next !== part.current) {
1381 if (part.current) {
1382 part.element.removeEventListener(part.name, part.current);
1383 }
1384 if (next) {
1385 part.element.addEventListener(part.name, next);
1386 }
1387 part.current = next;
1388 }
1389 } else if (part.kind === "prop") {
1390 const next = values[part.valueIndex];
1391 if (next !== part.last) {
1392 part.last = next;
1393 part.element[part.name] = next;
1394 }
1395 } else if (part.kind === "bool") {
1396 const next = !!values[part.valueIndex];
1397 if (next !== part.last) {
1398 part.last = next;
1399 if (next) {
1400 part.element.setAttribute(part.name, "");
1401 } else {
1402 part.element.removeAttribute(part.name);
1403 }
1404 }
1405 }
1406 }
1407 }
1408 function updateChildPart(child, value) {
1409 if (value === null || value === void 0 || value === false) {
1410 if (child.state) {
1411 disposeChildState(child.state);
1412 child.state = null;
1413 }
1414 return;
1415 }
1416 if (Array.isArray(value)) {
1417 updateArrayChild(child, value);
1418 return;
1419 }
1420 if (isTemplateResult$1(value)) {
1421 updateTemplateChild(child, value);
1422 return;
1423 }
1424 if (value instanceof Node) {
1425 updateNodeChild(child, value);
1426 return;
1427 }
1428 updateTextChild(child, formatText(value));
1429 }
1430 function updateNodeChild(child, node) {
1431 const old = child.state;
1432 if (old?.shape === "node" && old.node === node) {
1433 return;
1434 }
1435 if (old) {
1436 disposeChildState(old);
1437 }
1438 insertBeforeAnchor(child, [node]);
1439 child.state = { shape: "node", node };
1440 }
1441 function updateTextChild(child, text) {
1442 const old = child.state;
1443 if (old?.shape === "text") {
1444 if (old.text !== text) {
1445 old.node.textContent = text;
1446 old.text = text;
1447 }
1448 return;
1449 }
1450 if (old) {
1451 disposeChildState(old);
1452 }
1453 const node = document.createTextNode(text);
1454 insertBeforeAnchor(child, [node]);
1455 child.state = { shape: "text", node, text };
1456 }
1457 function updateTemplateChild(child, result) {
1458 const old = child.state;
1459 if (old?.shape === "template" && old.strings === result.strings) {
1460 applyValues(old.parts, result.values);
1461 return;
1462 }
1463 if (old) {
1464 disposeChildState(old);
1465 }
1466 const compiled = compile(result.strings);
1467 const fragment = compiled.template.content.cloneNode(true);
1468 const parts = compiled.buildParts(fragment);
1469 const topNodes = Array.from(fragment.childNodes);
1470 insertBeforeAnchor(child, [fragment]);
1471 applyValues(parts, result.values);
1472 child.state = {
1473 shape: "template",
1474 strings: result.strings,
1475 parts,
1476 nodes: topNodes
1477 };
1478 }
1479 function updateArrayChild(child, arr) {
1480 const old = child.state;
1481 if (old?.shape === "array" && old.entries.length === arr.length) {
1482 for (let i = 0; i < arr.length; i++) {
1483 updateChildPart(old.entries[i], arr[i]);
1484 }
1485 return;
1486 }
1487 if (old) {
1488 disposeChildState(old);
1489 }
1490 const entries = [];
1491 for (const v of arr) {
1492 const entryAnchor = document.createTextNode("");
1493 insertBeforeAnchor(child, [entryAnchor]);
1494 const entry = { anchor: entryAnchor, state: null };
1495 updateChildPart(entry, v);
1496 entries.push(entry);
1497 }
1498 child.state = { shape: "array", entries };
1499 }
1500 function insertBeforeAnchor(child, nodes) {
1501 const parent = child.anchor.parentNode;
1502 if (!parent) {
1503 return;
1504 }
1505 for (const node of nodes) {
1506 parent.insertBefore(node, child.anchor);
1507 }
1508 }
1509 function disposeChildState(state) {
1510 if (state.shape === "text") {
1511 state.node.remove();
1512 return;
1513 }
1514 if (state.shape === "template") {
1515 for (const node of state.nodes) {
1516 if (node.parentNode) {
1517 node.parentNode.removeChild(node);
1518 }
1519 }
1520 return;
1521 }
1522 if (state.shape === "node") {
1523 if (state.node.parentNode) {
1524 state.node.parentNode.removeChild(state.node);
1525 }
1526 return;
1527 }
1528 for (const entry of state.entries) {
1529 if (entry.state) {
1530 disposeChildState(entry.state);
1531 }
1532 entry.anchor.remove();
1533 }
1534 }
1535 function formatText(v) {
1536 if (v === null || v === void 0 || v === false) {
1537 return "";
1538 }
1539 return String(v);
1540 }
1541 const _Component = class _Component extends HTMLElement {
1542 constructor() {
1543 super();
1544 this._renderScheduled = false;
1545 this._propValues = {};
1546 const ctor = this.constructor;
1547 if (ctor.shadow) {
1548 this.attachShadow({ mode: "open" });
1549 this._renderRoot = this.shadowRoot;
1550 } else {
1551 this._renderRoot = this;
1552 }
1553 this._installPropAccessors();
1554 }
1555 static get observedAttributes() {
1556 return this.props.map(kebab);
1557 }
1558 connectedCallback() {
1559 this._adoptStyles();
1560 this.requestUpdate();
1561 }
1562 attributeChangedCallback(name, oldValue, newValue) {
1563 if (oldValue === newValue) {
1564 return;
1565 }
1566 const prop = camel(name);
1567 this._propValues[prop] = newValue;
1568 this.requestUpdate();
1569 }
1570 /**
1571 * Declarative class-name setter. Assign an array (or a
1572 * space-separated string) and the host's `class` attribute is
1573 * rewritten to match. Intended for programmatic styling — when
1574 * a plugin has enqueued its own stylesheet and wants to apply
1575 * one of those classes to a shell component:
1576 *
1577 * ```js
1578 * element.classNames = [ 'my-plugin-brand', 'is-active' ];
1579 * // → <wpd-select class="my-plugin-brand is-active">
1580 * ```
1581 *
1582 * The plain HTML `class="…"` attribute works just the same and
1583 * is always preferred when writing markup by hand — this setter
1584 * exists for the JS-API case where the caller has an array of
1585 * conditional classes in hand.
1586 *
1587 * Getter returns the current `classList` as a plain array for
1588 * symmetric read/write.
1589 *
1590 * @since 0.5.0
1591 */
1592 get classNames() {
1593 return Array.from(this.classList);
1594 }
1595 set classNames(next) {
1596 if (next === null || next === void 0) {
1597 this.removeAttribute("class");
1598 return;
1599 }
1600 const list = Array.isArray(next) ? next : String(next).split(/\s+/);
1601 const cleaned = list.map((s) => String(s).trim()).filter((s) => s !== "");
1602 this.className = cleaned.join(" ");
1603 }
1604 /**
1605 * Request a re-render explicitly. Components rarely need this —
1606 * declare state via props + attribute observers and the render
1607 * loop picks up changes automatically.
1608 */
1609 requestUpdate() {
1610 this._scheduleRender();
1611 }
1612 /**
1613 * Dispatch a `CustomEvent` with a `detail`. Bubbles + composed
1614 * by default (matches typical WC UX — events cross shadow
1615 * boundaries, parents can listen without knowing about internal
1616 * structure).
1617 */
1618 emit(name, detail) {
1619 return this.dispatchEvent(
1620 new CustomEvent(name, {
1621 detail,
1622 bubbles: true,
1623 composed: true
1624 })
1625 );
1626 }
1627 // ------------------------------------------------------------------
1628 // Internals
1629 // ------------------------------------------------------------------
1630 /**
1631 * Wire every `static props` entry to a matched property getter +
1632 * setter on the element. Setting the property reflects into the
1633 * attribute (so downstream observers + CSS selectors see it);
1634 * reading the property falls back to the attribute.
1635 */
1636 _installPropAccessors() {
1637 const ctor = this.constructor;
1638 for (const prop of ctor.props) {
1639 if (Object.getOwnPropertyDescriptor(this, prop)) {
1640 continue;
1641 }
1642 const attr = kebab(prop);
1643 Object.defineProperty(this, prop, {
1644 get: () => {
1645 if (prop in this._propValues) {
1646 return this._propValues[prop];
1647 }
1648 return this.getAttribute(attr);
1649 },
1650 set: (value) => {
1651 let str;
1652 if (value === null || value === void 0 || value === false) {
1653 str = null;
1654 } else if (value === true) {
1655 str = "";
1656 } else {
1657 str = String(value);
1658 }
1659 this._propValues[prop] = str;
1660 if (str === null) {
1661 this.removeAttribute(attr);
1662 } else {
1663 this.setAttribute(attr, str);
1664 }
1665 this.requestUpdate();
1666 },
1667 enumerable: true,
1668 configurable: true
1669 });
1670 }
1671 }
1672 /**
1673 * Schedule a render on the next microtask. Multiple property
1674 * assignments in the same tick collapse into a single render.
1675 */
1676 _scheduleRender() {
1677 if (this._renderScheduled || !this.isConnected) {
1678 return;
1679 }
1680 this._renderScheduled = true;
1681 queueMicrotask(() => {
1682 this._renderScheduled = false;
1683 if (!this.isConnected) {
1684 return;
1685 }
1686 render(this.render(), this._renderRoot);
1687 });
1688 }
1689 /**
1690 * Mount adoptable stylesheets onto the shadow root (via
1691 * `adoptedStyleSheets`) or the light DOM (via one `<style>`
1692 * tag per def). No-op if `static styles` is empty.
1693 */
1694 _adoptStyles() {
1695 const ctor = this.constructor;
1696 if (ctor.styles.length === 0) {
1697 return;
1698 }
1699 if (ctor.shadow && this.shadowRoot) {
1700 const sheets = ctor.styles.map((s) => s.sheet).filter((s) => s !== null);
1701 this.shadowRoot.adoptedStyleSheets = sheets;
1702 if (sheets.length !== ctor.styles.length) {
1703 for (const s of ctor.styles) {
1704 if (!s.sheet) {
1705 const tag = document.createElement("style");
1706 tag.textContent = s.cssText;
1707 this.shadowRoot.appendChild(tag);
1708 }
1709 }
1710 }
1711 } else {
1712 this._adoptLightStyles(ctor);
1713 }
1714 }
1715 _adoptLightStyles(ctor) {
1716 if (_Component._lightStylesAdopted.has(ctor)) {
1717 return;
1718 }
1719 _Component._lightStylesAdopted.add(ctor);
1720 for (const s of ctor.styles) {
1721 const tag = document.createElement("style");
1722 tag.dataset.wpdUi = this.tagName.toLowerCase();
1723 tag.textContent = s.cssText;
1724 document.head.appendChild(tag);
1725 }
1726 }
1727 };
1728 _Component.props = [];
1729 _Component.styles = [];
1730 _Component.shadow = true;
1731 _Component._lightStylesAdopted = /* @__PURE__ */ new WeakSet();
1732 let Component = _Component;
1733 function defineComponent(tag, ctor) {
1734 if (customElements.get(tag)) {
1735 return;
1736 }
1737 customElements.define(tag, ctor);
1738 }
1739 function kebab(s) {
1740 return s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
1741 }
1742 function camel(s) {
1743 return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1744 }
1745 const SUPPORTS_CONSTRUCTABLE_SHEETS = (() => {
1746 try {
1747 const s = new CSSStyleSheet();
1748 return typeof s.replaceSync === "function";
1749 } catch {
1750 return false;
1751 }
1752 })();
1753 function css(strings, ...values) {
1754 let text = strings[0];
1755 for (let i = 1; i < strings.length; i++) {
1756 const v = values[i - 1];
1757 if (typeof v === "string" || typeof v === "number") {
1758 text += String(v);
1759 } else if (v && v.__wpdCss) {
1760 text += v.cssText;
1761 } else {
1762 throw new TypeError(
1763 "[wpd-ui] css`` interpolations must be strings, numbers, or other css`` results. Got: " + typeof v
1764 );
1765 }
1766 text += strings[i];
1767 }
1768 if (SUPPORTS_CONSTRUCTABLE_SHEETS) {
1769 const sheet = new CSSStyleSheet();
1770 sheet.replaceSync(text);
1771 return { __wpdCss: true, sheet, cssText: text };
1772 }
1773 return { __wpdCss: true, sheet: null, cssText: text };
1774 }
1775 function computeAutoId(element) {
1776 const parts = [];
1777 const tabs = [];
1778 let windowId = null;
1779 let node = element.parentElement;
1780 while (node) {
1781 if (node === document.body || node === document.documentElement) {
1782 break;
1783 }
1784 const id = node.id || "";
1785 if (id.startsWith("wp-window-")) {
1786 windowId = id.slice("wp-window-".length);
1787 break;
1788 }
1789 if (node.tagName.toLowerCase() === "wpd-tabpanel") {
1790 const forValue = node.getAttribute("for");
1791 if (forValue) {
1792 tabs.unshift(forValue);
1793 }
1794 }
1795 node = node.parentElement;
1796 }
1797 if (windowId) {
1798 parts.push(slugify(windowId));
1799 }
1800 for (const tab of tabs) {
1801 parts.push("tab-" + slugify(tab));
1802 }
1803 const label = element.getAttribute("label");
1804 if (label) {
1805 parts.push(slugify(label));
1806 }
1807 if (parts.length === 0) {
1808 return "wpd-unnamed";
1809 }
1810 return "wpd-" + parts.filter((p) => p !== "").join("-");
1811 }
1812 function slugify(s) {
1813 return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1814 }
1815 function ensureAutoId(element) {
1816 if (element.id) {
1817 return element.id;
1818 }
1819 const id = computeAutoId(element);
1820 element.id = id;
1821 return id;
1822 }
1823 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}}`;
1824 const EXPANDER_KEY = "__wpd_expander__";
1825 const SELECT_KEY = "__wpd_select__";
1826 const _WpdTable = class _WpdTable extends Component {
1827 constructor() {
1828 super(...arguments);
1829 this._data = [];
1830 this._columns = [];
1831 this._filters = {};
1832 this._expanded = /* @__PURE__ */ new Set();
1833 this._subTable = null;
1834 this._sort = null;
1835 this._selection = /* @__PURE__ */ new Set();
1836 this._getRowId = (_row, index) => index;
1837 this._filterCache = /* @__PURE__ */ new Map();
1838 this._paintScheduled = false;
1839 this._stickyHeaderWarned = false;
1840 this._stickyRaceWarned = false;
1841 this._resizeObserver = null;
1842 this._stickyMicroScheduled = false;
1843 this._stickyRafHandle = null;
1844 this._loadingDesyncWarned = false;
1845 this._lastStickyIndex = -1;
1846 }
1847 // ------------------------------------------------------------------
1848 // Public properties — set from JS (use `.data=${...}` in templates).
1849 // ------------------------------------------------------------------
1850 /** The row buffer. Reassigning replaces (and clears expansion state). */
1851 get data() {
1852 return this._data;
1853 }
1854 set data(next) {
1855 this._data = Array.isArray(next) ? next.slice() : [];
1856 this._expanded.clear();
1857 this._schedulePaint();
1858 }
1859 /** Column descriptors. See {@link WpdTableColumn}. */
1860 get columns() {
1861 return this._columns;
1862 }
1863 set columns(next) {
1864 this._columns = Array.isArray(next) ? next.slice() : [];
1865 const keys = new Set(this._columns.map((c) => c.key));
1866 for (const k of Object.keys(this._filters)) {
1867 if (!keys.has(k)) {
1868 delete this._filters[k];
1869 }
1870 }
1871 for (const k of Array.from(this._filterCache.keys())) {
1872 if (!keys.has(k)) {
1873 this._filterCache.delete(k);
1874 }
1875 }
1876 if (this._sort && !keys.has(this._sort.key)) {
1877 this._sort = null;
1878 }
1879 this._schedulePaint();
1880 }
1881 /** Read or replace the current filter map. */
1882 get filters() {
1883 return { ...this._filters };
1884 }
1885 set filters(next) {
1886 this._filters = next ? { ...next } : {};
1887 this._schedulePaint();
1888 }
1889 /** Read or set the active sort. `null` clears it. */
1890 get sort() {
1891 return this._sort ? { ...this._sort } : null;
1892 }
1893 set sort(next) {
1894 this._sort = next ? { ...next } : null;
1895 this._schedulePaint();
1896 }
1897 /** Read or replace the selection (set of row ids). */
1898 get selection() {
1899 return new Set(this._selection);
1900 }
1901 set selection(next) {
1902 this._selection = new Set(next ?? []);
1903 this._schedulePaint();
1904 }
1905 /** The currently-selected rows (resolved from `selection` + `data`). */
1906 get selectedRows() {
1907 const out = [];
1908 this._data.forEach((row, i) => {
1909 if (this._selection.has(this._getRowId(row, i))) {
1910 out.push(row);
1911 }
1912 });
1913 return out;
1914 }
1915 /**
1916 * The rows currently visible — i.e. passing the active client-side
1917 * filters, in data order. This is the row set `selectAll()` and
1918 * the header select-all tri-state operate on.
1919 *
1920 * Destructive bulk consumers should resolve `selection` against
1921 * THIS list rather than `data`: selection deliberately survives
1922 * `data` reassignment, and a data-driven change (a realtime
1923 * refresh editing a row so it no longer matches an active filter)
1924 * can hide a selected row without any filter event firing. Rows
1925 * the user cannot see must never be swept into a destructive
1926 * action. See `collectSelectedItems()` in src/recycle-bin/index.ts
1927 * for the canonical consumer.
1928 *
1929 * @since 0.9.4
1930 */
1931 get visibleRows() {
1932 return this._filteredRows().map((entry) => entry.row);
1933 }
1934 /** Stable row-id extractor. Default is row index. */
1935 get getRowId() {
1936 return this._getRowId;
1937 }
1938 set getRowId(fn) {
1939 this._getRowId = typeof fn === "function" ? fn : (_r, i) => i;
1940 this._schedulePaint();
1941 }
1942 /**
1943 * Sub-table accessor. Return `null` (or omit) for rows with no
1944 * children. Return `{ columns, data }` to render a nested
1945 * `<wpd-table>` inline; or return any `Node` / `html\`\`` template
1946 * for fully custom expanded content.
1947 */
1948 get subTable() {
1949 return this._subTable;
1950 }
1951 set subTable(fn) {
1952 this._subTable = typeof fn === "function" ? fn : null;
1953 this._expanded.clear();
1954 this._schedulePaint();
1955 }
1956 /** Read or replace the expansion set (row indices that are open). */
1957 get expanded() {
1958 return new Set(this._expanded);
1959 }
1960 set expanded(next) {
1961 this._expanded = new Set(next ?? []);
1962 this._schedulePaint();
1963 }
1964 // ------------------------------------------------------------------
1965 // Programmatic methods
1966 // ------------------------------------------------------------------
1967 /** Open a row's sub-table by index. No-op if the index is out of range. */
1968 expand(index) {
1969 if (index < 0 || index >= this._data.length) {
1970 return;
1971 }
1972 if (this._expanded.has(index)) {
1973 return;
1974 }
1975 this._expanded.add(index);
1976 this.emit("wpd-table-expand-change", {
1977 row: this._data[index],
1978 index,
1979 expanded: true
1980 });
1981 this._schedulePaint();
1982 }
1983 /** Close a row's sub-table by index. No-op if it wasn't open. */
1984 collapse(index) {
1985 if (!this._expanded.has(index)) {
1986 return;
1987 }
1988 this._expanded.delete(index);
1989 this.emit("wpd-table-expand-change", {
1990 row: this._data[index],
1991 index,
1992 expanded: false
1993 });
1994 this._schedulePaint();
1995 }
1996 /** Open every row that has children. */
1997 expandAll() {
1998 if (!this._subTable) {
1999 return;
2000 }
2001 let changed = false;
2002 for (let i = 0; i < this._data.length; i++) {
2003 if (!this._subTable(this._data[i], i)) {
2004 continue;
2005 }
2006 if (!this._expanded.has(i)) {
2007 this._expanded.add(i);
2008 changed = true;
2009 }
2010 }
2011 if (changed) {
2012 this._schedulePaint();
2013 }
2014 }
2015 /** Close every open row. */
2016 collapseAll() {
2017 if (this._expanded.size === 0) {
2018 return;
2019 }
2020 this._expanded.clear();
2021 this._schedulePaint();
2022 }
2023 isExpanded(index) {
2024 return this._expanded.has(index);
2025 }
2026 /** Drop every active filter and emit `wpd-table-filter-change`. */
2027 clearFilters() {
2028 if (Object.keys(this._filters).length === 0) {
2029 return;
2030 }
2031 this._filters = {};
2032 this.emit("wpd-table-filter-change", { filters: {} });
2033 this._schedulePaint();
2034 }
2035 /** Drop the active sort and emit `wpd-table-sort-change`. */
2036 clearSort() {
2037 if (this._sort === null) {
2038 return;
2039 }
2040 this._sort = null;
2041 this.emit("wpd-table-sort-change", { sort: null });
2042 this._schedulePaint();
2043 }
2044 /**
2045 * Add a row id to the selection. Emits `wpd-table-selection-change`.
2046 *
2047 * Selection mutators (`select` / `deselect` / `selectAll` /
2048 * `clearSelection`) update the affected row in place via
2049 * {@link _syncSelectionDom} rather than re-rendering the whole
2050 * tbody — a rebuild would tear down the focused checkbox and
2051 * (because scroll-anchoring abandons a momentarily empty container)
2052 * could snap scroll back to the top.
2053 */
2054 select(id) {
2055 if (this._selection.has(id)) {
2056 return;
2057 }
2058 const mode = this._readSelectable();
2059 const previouslySelected = mode === "single" ? Array.from(this._selection) : [];
2060 if (mode === "single") {
2061 this._selection.clear();
2062 }
2063 this._selection.add(id);
2064 this._emitSelectionChange();
2065 this._syncSelectionDom([id, ...previouslySelected]);
2066 }
2067 /** Remove a row id from the selection. */
2068 deselect(id) {
2069 if (!this._selection.delete(id)) {
2070 return;
2071 }
2072 this._emitSelectionChange();
2073 this._syncSelectionDom([id]);
2074 }
2075 /** Select every visible row — the rows passing the active client-side filters (multi-mode only). */
2076 selectAll() {
2077 if (this._readSelectable() !== "multi") {
2078 return;
2079 }
2080 for (const { row, index } of this._filteredRows()) {
2081 this._selection.add(this._getRowId(row, index));
2082 }
2083 this._emitSelectionChange();
2084 this._syncSelectionDom("all");
2085 }
2086 /** Empty the selection. */
2087 clearSelection() {
2088 if (this._selection.size === 0) {
2089 return;
2090 }
2091 this._selection.clear();
2092 this._emitSelectionChange();
2093 this._syncSelectionDom("all");
2094 }
2095 /**
2096 * Apply a selection change to the existing tbody DOM without
2097 * rebuilding it. Updates each affected row's `is-selected` class
2098 * and `select-row-checkbox` `checked` state, then re-syncs the
2099 * header select-all checkbox (checked / indeterminate / empty).
2100 *
2101 * @param ids `'all'` to walk every row, or an iterable of row ids
2102 * whose rows need updating. Unknown ids are silently
2103 * skipped (row may not be in the current filter/page).
2104 */
2105 _syncSelectionDom(ids) {
2106 const root = this.shadowRoot;
2107 if (!root) {
2108 return;
2109 }
2110 const tbody = root.querySelector("tbody");
2111 if (!tbody) {
2112 return;
2113 }
2114 let needle = null;
2115 if (ids !== "all") {
2116 needle = /* @__PURE__ */ new Set();
2117 for (const id of ids) {
2118 needle.add(String(id));
2119 }
2120 }
2121 const rows = tbody.querySelectorAll(
2122 "tr[data-row-id]"
2123 );
2124 for (const tr of rows) {
2125 const rowIdStr = tr.dataset.rowId;
2126 if (rowIdStr === void 0) {
2127 continue;
2128 }
2129 if (needle && !needle.has(rowIdStr)) {
2130 continue;
2131 }
2132 const idx = Number(tr.dataset.rowIndex);
2133 if (!Number.isFinite(idx)) {
2134 continue;
2135 }
2136 const row = this._data[idx];
2137 if (row === void 0) {
2138 continue;
2139 }
2140 const id = this._getRowId(row, idx);
2141 const isSelected = this._selection.has(id);
2142 tr.classList.toggle("is-selected", isSelected);
2143 const cb = tr.querySelector(
2144 "input.select-row-checkbox"
2145 );
2146 if (cb && cb.checked !== isSelected) {
2147 cb.checked = isSelected;
2148 }
2149 }
2150 const headerCb = root.querySelector(
2151 "thead .select-all-checkbox"
2152 );
2153 if (headerCb) {
2154 const { total, selected } = this._visibleSelectionStats();
2155 headerCb.checked = total > 0 && selected === total;
2156 headerCb.indeterminate = selected > 0 && selected < total;
2157 }
2158 }
2159 /** Scroll the (filtered) row at `index` into view inside the table's scroll container. */
2160 scrollToRow(index) {
2161 const root = this.shadowRoot;
2162 if (!root) {
2163 return;
2164 }
2165 const rows = root.querySelectorAll(
2166 "tbody tr:not(.subtable):not(.empty):not(.skeleton)"
2167 );
2168 const row = rows[index];
2169 if (row) {
2170 row.scrollIntoView({ block: "nearest", inline: "nearest" });
2171 }
2172 }
2173 connectedCallback() {
2174 super.connectedCallback();
2175 this._schedulePaint();
2176 }
2177 disconnectedCallback() {
2178 this._resizeObserver?.disconnect();
2179 this._resizeObserver = null;
2180 if (this._stickyRafHandle !== null && typeof cancelAnimationFrame !== "undefined") {
2181 cancelAnimationFrame(this._stickyRafHandle);
2182 this._stickyRafHandle = null;
2183 }
2184 }
2185 /**
2186 * Force a sticky-offsets recompute. Public escape hatch for the
2187 * rare case where layout settles after every internal hook has
2188 * fired — e.g. an out-of-band font swap or a JS-driven width
2189 * change on an ancestor that doesn't bubble through ResizeObserver.
2190 *
2191 * Usually you don't need this: the component schedules recomputes
2192 * on a microtask + animation frame after every paint, and a
2193 * ResizeObserver on the inner scroll element catches geometry
2194 * changes thereafter. Reach for `recomputeLayout()` only if you've
2195 * confirmed that all of those pathways missed your case.
2196 */
2197 recomputeLayout() {
2198 this._applyStickyOffsets();
2199 this._measureHeaderHeight();
2200 }
2201 // ------------------------------------------------------------------
2202 // Skeleton + paint pipeline
2203 // ------------------------------------------------------------------
2204 render() {
2205 return html`
2206 <div class="scroll" part="scroll">
2207 <table part="table">
2208 <colgroup></colgroup>
2209 <thead></thead>
2210 <tbody></tbody>
2211 </table>
2212 </div>
2213 `;
2214 }
2215 requestUpdate() {
2216 super.requestUpdate();
2217 this._schedulePaint();
2218 }
2219 _schedulePaint() {
2220 if (this._paintScheduled || !this.isConnected) {
2221 return;
2222 }
2223 this._paintScheduled = true;
2224 queueMicrotask(() => {
2225 this._paintScheduled = false;
2226 if (!this.isConnected) {
2227 return;
2228 }
2229 this._paint();
2230 });
2231 }
2232 _paint() {
2233 const root = this.shadowRoot;
2234 if (!root) {
2235 return;
2236 }
2237 if (!root.querySelector("tbody")) {
2238 render(this.render(), root);
2239 }
2240 const colgroup = root.querySelector("colgroup");
2241 const thead = root.querySelector("thead");
2242 const tbody = root.querySelector("tbody");
2243 if (!colgroup || !thead || !tbody) {
2244 return;
2245 }
2246 const cols = this._effectiveColumns();
2247 const stickyN = this._readStickyColumns();
2248 this._lastStickyIndex = this._computeLastStickyIndex(cols, stickyN);
2249 this._paintColgroup(colgroup, cols);
2250 this._paintHead(thead, cols, stickyN);
2251 this._paintBody(tbody, cols, stickyN);
2252 this._applyStickyOffsets();
2253 this._measureHeaderHeight();
2254 this._scheduleStickyOffsets();
2255 this._maybeWarnStickyHeader();
2256 this._maybeWarnLoadingDesync(tbody);
2257 this._ensureResizeObserver();
2258 }
2259 /**
2260 * Diagnostic for the "I set `loading` but the skeleton never
2261 * appeared" footgun. If we get here with the attribute on but no
2262 * `.skeleton` rows in `tbody`, something between attribute set and
2263 * paint went off the rails — historically this happened when the
2264 * base `Component.attributeChangedCallback` called `_scheduleRender`
2265 * directly, bypassing our `requestUpdate` override. Same pattern as
2266 * the sticky-columns 0px tripwire: should never fire, but if it
2267 * does, names the bug instead of leaving the dev guessing.
2268 */
2269 _maybeWarnLoadingDesync(tbody) {
2270 if (this._loadingDesyncWarned) {
2271 return;
2272 }
2273 if (!this.hasAttribute("loading")) {
2274 return;
2275 }
2276 if (tbody.querySelector("tr.skeleton")) {
2277 return;
2278 }
2279 this._loadingDesyncWarned = true;
2280 console.warn(
2281 "[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."
2282 );
2283 }
2284 /**
2285 * Belt-and-braces sticky-offset scheduling.
2286 *
2287 * - Microtask: cheap, fires after the current task drains. Fixes
2288 * mounts where the synchronous read in `_paint` happened before
2289 * a sibling style applied.
2290 * - rAF: fires before the next paint. Catches "layout settles
2291 * after a queued style mutation" races — the most common cause
2292 * of "col 1 ended up at inset-inline-start: 0px".
2293 *
2294 * Both reduce to a no-op when nothing changed. The cost is two
2295 * extra DOM reads per paint; the win is the bug class disappears.
2296 */
2297 _scheduleStickyOffsets() {
2298 if (!this._stickyMicroScheduled) {
2299 this._stickyMicroScheduled = true;
2300 queueMicrotask(() => {
2301 this._stickyMicroScheduled = false;
2302 if (this.isConnected) {
2303 this._applyStickyOffsets();
2304 }
2305 });
2306 }
2307 if (this._stickyRafHandle === null && typeof requestAnimationFrame !== "undefined") {
2308 this._stickyRafHandle = requestAnimationFrame(() => {
2309 this._stickyRafHandle = null;
2310 if (this.isConnected) {
2311 this._applyStickyOffsets();
2312 this._measureHeaderHeight();
2313 }
2314 });
2315 }
2316 }
2317 /**
2318 * Wire a `ResizeObserver` on the inner `.scroll` element (NOT the
2319 * host). Why: the host's outer width is often pinned by its parent
2320 * panel — a vertical scrollbar appearing inside the table changes
2321 * the inner scroll-area width by ~15px without changing the host
2322 * size. Observing the host would miss that reflow and leave sticky
2323 * offsets stale.
2324 *
2325 * Idempotent — runs once after the first paint produces a real
2326 * `.scroll` element. Disconnect happens in `disconnectedCallback`.
2327 */
2328 _ensureResizeObserver() {
2329 if (this._resizeObserver) {
2330 return;
2331 }
2332 if (typeof ResizeObserver === "undefined") {
2333 return;
2334 }
2335 const scroll = this.shadowRoot?.querySelector(
2336 ".scroll"
2337 );
2338 if (!scroll) {
2339 return;
2340 }
2341 this._resizeObserver = new ResizeObserver(() => {
2342 if (!this.isConnected) {
2343 return;
2344 }
2345 this._applyStickyOffsets();
2346 this._measureHeaderHeight();
2347 this._stickyHeaderWarned = false;
2348 this._maybeWarnStickyHeader();
2349 });
2350 this._resizeObserver.observe(scroll);
2351 this._resizeObserver.observe(this);
2352 }
2353 _paintColgroup(colgroup, cols) {
2354 const out = [];
2355 for (const c of cols) {
2356 const col = document.createElement("col");
2357 if (c.width) {
2358 col.style.width = c.width;
2359 }
2360 out.push(col);
2361 }
2362 colgroup.replaceChildren(...out);
2363 }
2364 _paintHead(thead, cols, stickyN) {
2365 const newHeaderRow = document.createElement("tr");
2366 newHeaderRow.setAttribute("part", "header-row");
2367 for (let i = 0; i < cols.length; i++) {
2368 newHeaderRow.appendChild(this._buildHeaderCell(cols[i], i, stickyN));
2369 }
2370 const existingHeader = thead.querySelector(
2371 ':scope > tr[part="header-row"]'
2372 );
2373 if (existingHeader) {
2374 thead.replaceChild(newHeaderRow, existingHeader);
2375 } else {
2376 thead.insertBefore(newHeaderRow, thead.firstChild);
2377 }
2378 const hasFilter = cols.some(
2379 (c) => c.filter || Array.isArray(c.filterOptions) || typeof c.filterRender === "function"
2380 );
2381 let existingFilter = thead.querySelector(
2382 ":scope > tr.filter-row"
2383 );
2384 if (hasFilter) {
2385 const cells = [];
2386 for (let i = 0; i < cols.length; i++) {
2387 cells.push(this._buildFilterCell(cols[i], i, stickyN));
2388 }
2389 if (!existingFilter) {
2390 existingFilter = document.createElement("tr");
2391 existingFilter.classList.add("filter-row");
2392 existingFilter.setAttribute("part", "filter-row");
2393 thead.appendChild(existingFilter);
2394 }
2395 const current = Array.from(existingFilter.children);
2396 let same = current.length === cells.length;
2397 if (same) {
2398 for (let i = 0; i < cells.length; i++) {
2399 if (current[i] !== cells[i]) {
2400 same = false;
2401 break;
2402 }
2403 }
2404 }
2405 if (!same) {
2406 const wanted = new Set(cells);
2407 for (const cell of cells) {
2408 existingFilter.appendChild(cell);
2409 }
2410 for (const child of Array.from(existingFilter.children)) {
2411 if (!wanted.has(child)) {
2412 existingFilter.removeChild(child);
2413 }
2414 }
2415 }
2416 } else if (existingFilter) {
2417 existingFilter.remove();
2418 }
2419 }
2420 _buildHeaderCell(col, index, stickyN) {
2421 const th = document.createElement("th");
2422 th.setAttribute("scope", "col");
2423 th.dataset.key = col.key;
2424 this._applyCellClasses(th, col, index, stickyN);
2425 if (col.minWidth) {
2426 th.style.minWidth = col.minWidth;
2427 }
2428 if (col.key === SELECT_KEY) {
2429 const mode = this._readSelectable();
2430 if (mode === "multi") {
2431 const cb = document.createElement("input");
2432 cb.type = "checkbox";
2433 cb.className = "select-all-checkbox";
2434 cb.setAttribute("data-noclick", "");
2435 cb.setAttribute("aria-label", "Select all rows");
2436 const { total, selected } = this._visibleSelectionStats();
2437 cb.checked = total > 0 && selected === total;
2438 cb.indeterminate = selected > 0 && selected < total;
2439 cb.addEventListener("change", () => {
2440 if (cb.checked) {
2441 this.selectAll();
2442 } else {
2443 this.clearSelection();
2444 }
2445 });
2446 th.appendChild(cb);
2447 }
2448 return th;
2449 }
2450 th.textContent = col.label ?? (col.key === EXPANDER_KEY ? "" : col.key);
2451 if (col.sortable) {
2452 th.classList.add("is-sortable");
2453 const isActive = this._sort?.key === col.key;
2454 const indicator = document.createElement("span");
2455 indicator.className = "sort-indicator";
2456 let arrow = "";
2457 if (isActive) {
2458 arrow = this._sort.direction === "asc" ? " ▲" : " ▼";
2459 }
2460 indicator.textContent = arrow;
2461 th.appendChild(indicator);
2462 if (isActive) {
2463 th.classList.add(
2464 this._sort.direction === "asc" ? "sort-asc" : "sort-desc"
2465 );
2466 }
2467 th.addEventListener("click", () => this._cycleSort(col.key));
2468 }
2469 return th;
2470 }
2471 _buildFilterCell(col, index, stickyN) {
2472 const cached = this._filterCache.get(col.key);
2473 const hasExplicitOptions = Array.isArray(col.filterOptions);
2474 const hasCustomRender = typeof col.filterRender === "function";
2475 let desiredKind;
2476 if (!col.filter && !hasExplicitOptions && !hasCustomRender || col.key === EXPANDER_KEY || col.key === SELECT_KEY) {
2477 desiredKind = "none";
2478 } else if (hasCustomRender) {
2479 desiredKind = "custom";
2480 } else if (col.filter === "select" || hasExplicitOptions) {
2481 desiredKind = "select";
2482 } else {
2483 desiredKind = "text";
2484 }
2485 if (cached && cached.kind === desiredKind) {
2486 cached.th.className = "";
2487 this._applyCellClasses(cached.th, col, index, stickyN);
2488 if (desiredKind === "select") {
2489 const select = cached.control;
2490 const opts = this._resolveFilterOptions(col);
2491 const optsKey = opts.map((o) => o.value).join("|");
2492 if (optsKey !== cached.optionsKey) {
2493 this._populateSelect(select, opts, this._filters[col.key] ?? "");
2494 cached.optionsKey = optsKey;
2495 } else {
2496 select.value = this._filters[col.key] ?? "";
2497 }
2498 } else if (desiredKind === "text") {
2499 const input = cached.control;
2500 const want = this._filters[col.key] ?? "";
2501 if (input.value !== want && input.ownerDocument.activeElement !== input) {
2502 input.value = want;
2503 }
2504 } else if (desiredKind === "custom" && col.filterRender) {
2505 col.filterRender(cached.th, {
2506 value: this._filters[col.key] ?? "",
2507 setValue: (next) => this._onFilterChange(col.key, next),
2508 col
2509 });
2510 }
2511 return cached.th;
2512 }
2513 const th = document.createElement("th");
2514 this._applyCellClasses(th, col, index, stickyN);
2515 if (desiredKind === "none") {
2516 this._filterCache.set(col.key, {
2517 th,
2518 control: null,
2519 optionsKey: "",
2520 kind: "none"
2521 });
2522 return th;
2523 }
2524 if (desiredKind === "custom" && col.filterRender) {
2525 col.filterRender(th, {
2526 value: this._filters[col.key] ?? "",
2527 setValue: (next) => this._onFilterChange(col.key, next),
2528 col
2529 });
2530 this._filterCache.set(col.key, {
2531 th,
2532 control: null,
2533 optionsKey: "",
2534 kind: "custom"
2535 });
2536 return th;
2537 }
2538 let control;
2539 let optionsKey = "";
2540 if (desiredKind === "select") {
2541 const select = document.createElement("select");
2542 select.classList.add("filter-select");
2543 select.setAttribute("data-noclick", "");
2544 select.setAttribute(
2545 "aria-label",
2546 `Filter ${col.label ?? col.key}`
2547 );
2548 const opts = this._resolveFilterOptions(col);
2549 this._populateSelect(select, opts, this._filters[col.key] ?? "");
2550 optionsKey = opts.map((o) => o.value).join("|");
2551 select.addEventListener("change", () => {
2552 this._onFilterChange(col.key, select.value);
2553 });
2554 control = select;
2555 } else {
2556 const input = document.createElement("input");
2557 input.type = "search";
2558 input.classList.add("filter-input");
2559 input.setAttribute("data-noclick", "");
2560 input.setAttribute("placeholder", "Filter…");
2561 input.setAttribute("aria-label", `Filter ${col.label ?? col.key}`);
2562 input.value = this._filters[col.key] ?? "";
2563 input.addEventListener("input", () => {
2564 this._onFilterChange(col.key, input.value);
2565 });
2566 control = input;
2567 }
2568 th.appendChild(control);
2569 this._filterCache.set(col.key, {
2570 th,
2571 control,
2572 optionsKey,
2573 kind: desiredKind
2574 });
2575 return th;
2576 }
2577 _populateSelect(select, options, current) {
2578 select.replaceChildren();
2579 const all = document.createElement("option");
2580 all.value = "";
2581 all.textContent = "All";
2582 select.appendChild(all);
2583 for (const opt of options) {
2584 const el = document.createElement("option");
2585 el.value = opt.value;
2586 el.textContent = opt.label;
2587 if (opt.value === current) {
2588 el.selected = true;
2589 }
2590 select.appendChild(el);
2591 }
2592 select.value = current;
2593 }
2594 /**
2595 * Resolve the option list for a select-filter column. Explicit
2596 * `filterOptions` win — that's the contract for server-driven
2597 * tables that need the dropdown to list values not present on
2598 * the current page. Without `filterOptions`, fall back to the
2599 * unique row values in the column (legacy behaviour for
2600 * client-side tables).
2601 */
2602 _resolveFilterOptions(col) {
2603 if (Array.isArray(col.filterOptions)) {
2604 return col.filterOptions;
2605 }
2606 return this._uniqueValues(col.key).map((v) => ({
2607 value: v,
2608 label: v
2609 }));
2610 }
2611 // ------------------------------------------------------------------
2612 // Body
2613 // ------------------------------------------------------------------
2614 _paintBody(tbody, cols, stickyN) {
2615 tbody.replaceChildren();
2616 if (this.hasAttribute("loading")) {
2617 const count = this._readLoadingRows();
2618 for (let i = 0; i < count; i++) {
2619 tbody.appendChild(this._buildSkeletonRow(cols, i));
2620 }
2621 return;
2622 }
2623 const filtered = this._sortedRows(this._filteredRows());
2624 if (filtered.length === 0) {
2625 tbody.appendChild(this._buildEmptyRow(cols.length));
2626 return;
2627 }
2628 for (const { row, index } of filtered) {
2629 tbody.appendChild(this._buildBodyRow(row, index, cols, stickyN));
2630 if (this._expanded.has(index) && this._subTable) {
2631 const sub = this._subTable(row, index);
2632 if (sub) {
2633 tbody.appendChild(this._buildSubTableRow(sub, cols.length));
2634 }
2635 }
2636 }
2637 }
2638 _buildEmptyRow(colspan) {
2639 const tr = document.createElement("tr");
2640 tr.classList.add("empty");
2641 const td = document.createElement("td");
2642 td.colSpan = colspan;
2643 const slot = document.createElement("slot");
2644 slot.name = "empty";
2645 slot.textContent = this.getAttribute("empty") || "No data";
2646 td.appendChild(slot);
2647 tr.appendChild(td);
2648 return tr;
2649 }
2650 _buildSkeletonRow(cols, seed) {
2651 const tr = document.createElement("tr");
2652 tr.classList.add("skeleton");
2653 tr.setAttribute("aria-hidden", "true");
2654 for (const _c of cols) {
2655 const td = document.createElement("td");
2656 const bar = document.createElement("span");
2657 bar.className = "skeleton-bar";
2658 const widthPct = 50 + (seed * 7 + tr.children.length * 13) % 40;
2659 bar.style.width = `${widthPct}%`;
2660 td.appendChild(bar);
2661 tr.appendChild(td);
2662 }
2663 return tr;
2664 }
2665 _buildBodyRow(row, rowIndex, cols, stickyN) {
2666 const tr = document.createElement("tr");
2667 tr.setAttribute("part", "row");
2668 tr.dataset.rowIndex = String(rowIndex);
2669 const id = this._getRowId(row, rowIndex);
2670 tr.dataset.rowId = String(id);
2671 if (this._selection.has(id)) {
2672 tr.classList.add("is-selected");
2673 }
2674 tr.addEventListener("click", (e) => {
2675 this._onRowClick(row, rowIndex, e);
2676 });
2677 for (let i = 0; i < cols.length; i++) {
2678 tr.appendChild(
2679 this._buildBodyCell(cols[i], i, row, rowIndex, stickyN)
2680 );
2681 }
2682 return tr;
2683 }
2684 _buildBodyCell(col, colIndex, row, rowIndex, stickyN) {
2685 const td = document.createElement("td");
2686 this._applyCellClasses(td, col, colIndex, stickyN);
2687 if (col.minWidth) {
2688 td.style.minWidth = col.minWidth;
2689 }
2690 if (col.key === SELECT_KEY) {
2691 const id = this._getRowId(row, rowIndex);
2692 const cb = document.createElement("input");
2693 cb.type = "checkbox";
2694 cb.className = "select-row-checkbox";
2695 cb.setAttribute("data-noclick", "");
2696 cb.setAttribute("aria-label", "Select row");
2697 cb.checked = this._selection.has(id);
2698 cb.addEventListener("change", () => {
2699 if (cb.checked) {
2700 this.select(id);
2701 } else {
2702 this.deselect(id);
2703 }
2704 });
2705 td.appendChild(cb);
2706 return td;
2707 }
2708 if (col.key === EXPANDER_KEY) {
2709 const hasChildren = this._subTable ? !!this._subTable(row, rowIndex) : false;
2710 if (!hasChildren) {
2711 return td;
2712 }
2713 const isOpen = this._expanded.has(rowIndex);
2714 const btn = document.createElement("button");
2715 btn.type = "button";
2716 btn.className = "expander";
2717 btn.setAttribute("data-noclick", "");
2718 btn.setAttribute("aria-expanded", isOpen ? "true" : "false");
2719 btn.setAttribute(
2720 "aria-label",
2721 isOpen ? "Collapse row" : "Expand row"
2722 );
2723 btn.textContent = isOpen ? "▾" : "▸";
2724 btn.addEventListener("click", (e) => {
2725 this._toggleRow(rowIndex, row, e);
2726 });
2727 td.appendChild(btn);
2728 return td;
2729 }
2730 const value = row[col.key];
2731 if (col.render) {
2732 const out = col.render(value, row, rowIndex);
2733 this._mountCellContent(td, out);
2734 } else if (value !== null && value !== void 0) {
2735 td.textContent = String(value);
2736 }
2737 return td;
2738 }
2739 _buildSubTableRow(sub, colspan) {
2740 const tr = document.createElement("tr");
2741 tr.classList.add("subtable");
2742 tr.setAttribute("part", "subtable-row");
2743 const td = document.createElement("td");
2744 td.colSpan = colspan;
2745 const inner = document.createElement("div");
2746 inner.classList.add("subtable-inner");
2747 if (sub instanceof Node) {
2748 inner.appendChild(sub);
2749 } else if (isTemplateResult(sub)) {
2750 render(sub, inner);
2751 } else {
2752 const nested = document.createElement("wpd-table");
2753 nested.columns = sub.columns;
2754 nested.data = sub.data;
2755 if (sub.subTable) {
2756 nested.subTable = sub.subTable;
2757 }
2758 inner.appendChild(nested);
2759 }
2760 td.appendChild(inner);
2761 tr.appendChild(td);
2762 return tr;
2763 }
2764 _mountCellContent(td, out) {
2765 if (typeof out === "string") {
2766 td.textContent = out;
2767 return;
2768 }
2769 if (out instanceof Node) {
2770 td.appendChild(out);
2771 return;
2772 }
2773 if (isTemplateResult(out)) {
2774 render(out, td);
2775 }
2776 }
2777 // ------------------------------------------------------------------
2778 // Behavior
2779 // ------------------------------------------------------------------
2780 _onFilterChange(key, value) {
2781 if (value === "") {
2782 delete this._filters[key];
2783 } else {
2784 this._filters[key] = value;
2785 }
2786 this.emit("wpd-table-filter-change", { filters: { ...this._filters } });
2787 const root = this.shadowRoot;
2788 const tbody = root?.querySelector("tbody");
2789 if (tbody) {
2790 const cols = this._effectiveColumns();
2791 const stickyN = this._readStickyColumns();
2792 this._lastStickyIndex = this._computeLastStickyIndex(cols, stickyN);
2793 this._paintBody(tbody, cols, stickyN);
2794 this._applyStickyOffsets();
2795 }
2796 }
2797 _onRowClick(row, index, e) {
2798 const path = e.composedPath?.() ?? [];
2799 for (const node of path) {
2800 if (node instanceof Element && node.hasAttribute("data-noclick")) {
2801 return;
2802 }
2803 if (node === this) {
2804 break;
2805 }
2806 }
2807 this.emit("wpd-table-row-click", { row, index, originalEvent: e });
2808 }
2809 _toggleRow(index, row, e) {
2810 e.stopPropagation();
2811 const isOpen = this._expanded.has(index);
2812 if (isOpen) {
2813 this._expanded.delete(index);
2814 } else {
2815 this._expanded.add(index);
2816 }
2817 this.emit("wpd-table-expand-change", {
2818 row,
2819 index,
2820 expanded: !isOpen
2821 });
2822 this._schedulePaint();
2823 }
2824 _cycleSort(key) {
2825 if (!this._sort || this._sort.key !== key) {
2826 this._sort = { key, direction: "asc" };
2827 } else if (this._sort.direction === "asc") {
2828 this._sort = { key, direction: "desc" };
2829 } else {
2830 this._sort = null;
2831 }
2832 this.emit("wpd-table-sort-change", {
2833 sort: this._sort ? { ...this._sort } : null
2834 });
2835 this._schedulePaint();
2836 }
2837 _emitSelectionChange() {
2838 this.emit("wpd-table-selection-change", {
2839 selection: Array.from(this._selection),
2840 rows: this.selectedRows
2841 });
2842 }
2843 // ------------------------------------------------------------------
2844 // Filtering + sorting
2845 // ------------------------------------------------------------------
2846 _filteredRows() {
2847 const out = [];
2848 const active = Object.keys(this._filters).filter(
2849 (k) => this._filters[k] !== ""
2850 );
2851 for (let i = 0; i < this._data.length; i++) {
2852 const row = this._data[i];
2853 let pass = true;
2854 for (const key of active) {
2855 const col = this._columns.find((c) => c.key === key);
2856 if (col && typeof col.filterRender === "function") {
2857 continue;
2858 }
2859 const filter = this._filters[key] ?? "";
2860 const cell = row[key];
2861 const cellStr = cell === null || cell === void 0 ? "" : String(cell);
2862 if (col?.filter === "select") {
2863 if (cellStr !== filter) {
2864 pass = false;
2865 break;
2866 }
2867 } else if (!cellStr.toLowerCase().includes(filter.toLowerCase())) {
2868 pass = false;
2869 break;
2870 }
2871 }
2872 if (pass) {
2873 out.push({ row, index: i });
2874 }
2875 }
2876 return out;
2877 }
2878 _sortedRows(rows) {
2879 if (!this._sort) {
2880 return rows;
2881 }
2882 const col = this._columns.find((c) => c.key === this._sort.key);
2883 if (!col) {
2884 return rows;
2885 }
2886 const dir = this._sort.direction === "desc" ? -1 : 1;
2887 const out = rows.slice();
2888 out.sort((a, b) => {
2889 const av = col.sortValue ? col.sortValue(a.row, a.row[col.key]) : a.row[col.key];
2890 const bv = col.sortValue ? col.sortValue(b.row, b.row[col.key]) : b.row[col.key];
2891 return compareValues(av, bv) * dir;
2892 });
2893 return out;
2894 }
2895 _uniqueValues(key) {
2896 const seen = /* @__PURE__ */ new Set();
2897 for (const row of this._data) {
2898 const v = row[key];
2899 if (v === null || v === void 0) {
2900 continue;
2901 }
2902 seen.add(String(v));
2903 }
2904 return Array.from(seen).sort();
2905 }
2906 /**
2907 * Selection stats over the VISIBLE (client-side-filtered) rows —
2908 * the same set `selectAll()` operates on. The header select-all
2909 * tri-state derives from these so "checked" always means "every
2910 * row the user can see is selected", even while ids of currently
2911 * hidden rows linger in the selection set.
2912 */
2913 _visibleSelectionStats() {
2914 let total = 0;
2915 let selected = 0;
2916 for (const { row, index } of this._filteredRows()) {
2917 total++;
2918 if (this._selection.has(this._getRowId(row, index))) {
2919 selected++;
2920 }
2921 }
2922 return { total, selected };
2923 }
2924 // ------------------------------------------------------------------
2925 // Sticky columns + attribute reads
2926 // ------------------------------------------------------------------
2927 _readStickyColumns() {
2928 const raw = parseInt(this.getAttribute("sticky-columns") || "0", 10);
2929 return Number.isFinite(raw) && raw > 0 ? raw : 0;
2930 }
2931 _readLoadingRows() {
2932 const raw = parseInt(this.getAttribute("loading-rows") || "5", 10);
2933 return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 100) : 5;
2934 }
2935 _readSelectable() {
2936 const v = this.getAttribute("selectable");
2937 if (v === "single") {
2938 return "single";
2939 }
2940 if (v === "multi" || v === "") {
2941 return "multi";
2942 }
2943 return null;
2944 }
2945 /**
2946 * Sticky-band membership. The first N columns get pinned, with two
2947 * per-column overrides: `column.sticky = true` opts in even outside
2948 * the band; `column.sticky = false` opts out within it.
2949 */
2950 _isStickyIndex(index, stickyN, col) {
2951 if (col.sticky === false) {
2952 return false;
2953 }
2954 if (col.sticky === true) {
2955 return true;
2956 }
2957 return index < stickyN;
2958 }
2959 _computeLastStickyIndex(cols, stickyN) {
2960 let last = -1;
2961 for (let i = 0; i < cols.length; i++) {
2962 if (this._isStickyIndex(i, stickyN, cols[i])) {
2963 last = i;
2964 }
2965 }
2966 return last;
2967 }
2968 _applyCellClasses(cell, col, index, stickyN) {
2969 if (col.key === EXPANDER_KEY) {
2970 cell.classList.add("col-expander");
2971 }
2972 if (col.key === SELECT_KEY) {
2973 cell.classList.add("col-select");
2974 }
2975 if (col.align === "center") {
2976 cell.classList.add("align-center");
2977 }
2978 if (col.align === "end") {
2979 cell.classList.add("align-end");
2980 }
2981 const sticky = this._isStickyIndex(index, stickyN, col);
2982 if (sticky) {
2983 cell.classList.add("is-sticky");
2984 if (index === this._lastStickyIndex) {
2985 cell.classList.add("is-sticky-edge");
2986 }
2987 }
2988 }
2989 _effectiveColumns() {
2990 const out = [];
2991 if (this._readSelectable()) {
2992 out.push({
2993 key: SELECT_KEY,
2994 label: "",
2995 // The descriptor width is painted onto a `<col>`
2996 // element and is the authoritative column-width
2997 // source in table-layout: auto — CSS `td { width }`
2998 // is ignored once `<col>` has a value. Pair with
2999 // the matching `td.col-select` rule (zero
3000 // `padding-inline`, `text-align: center`) so the
3001 // checkbox sits with breathing room on both sides.
3002 width: "40px",
3003 align: "center"
3004 });
3005 }
3006 if (this._subTable) {
3007 out.push({
3008 key: EXPANDER_KEY,
3009 label: "",
3010 // Same contract as col-select. 36px column +
3011 // 20px button + zero padding centers the chevron
3012 // with ~8px on each side.
3013 width: "36px",
3014 align: "center"
3015 });
3016 }
3017 out.push(...this._columns);
3018 return out;
3019 }
3020 /**
3021 * Walk the header row, sum the natural widths of the sticky cells,
3022 * then write cumulative `inset-inline-start` offsets onto every
3023 * row's matching cells.
3024 */
3025 _applyStickyOffsets() {
3026 const root = this.shadowRoot;
3027 if (!root) {
3028 return;
3029 }
3030 const headRow = root.querySelector("thead tr");
3031 if (!headRow) {
3032 return;
3033 }
3034 const ths = Array.from(headRow.children);
3035 const offsets = [];
3036 let acc = 0;
3037 for (let i = 0; i < ths.length; i++) {
3038 offsets[i] = acc;
3039 if (ths[i].classList.contains("is-sticky")) {
3040 acc += ths[i].offsetWidth;
3041 }
3042 }
3043 const rows = root.querySelectorAll(
3044 "thead tr, tbody tr:not(.subtable):not(.empty):not(.skeleton)"
3045 );
3046 rows.forEach((r) => {
3047 const cells = Array.from(r.children);
3048 for (let i = 0; i < cells.length; i++) {
3049 if (cells[i].classList.contains("is-sticky")) {
3050 cells[i].style.insetInlineStart = `${offsets[i]}px`;
3051 }
3052 }
3053 });
3054 this._maybeWarnStickyOffsetRace(ths, offsets);
3055 }
3056 _maybeWarnStickyOffsetRace(ths, offsets) {
3057 if (this._stickyRaceWarned) {
3058 return;
3059 }
3060 const stickyN = this._readStickyColumns();
3061 if (stickyN < 2) {
3062 return;
3063 }
3064 const lastIdx = Math.min(stickyN - 1, ths.length - 1);
3065 if (lastIdx <= 0) {
3066 return;
3067 }
3068 if (offsets[lastIdx] !== 0) {
3069 return;
3070 }
3071 if (this.offsetWidth === 0) {
3072 return;
3073 }
3074 this._stickyRaceWarned = true;
3075 const w0 = ths[0]?.offsetWidth ?? 0;
3076 console.warn(
3077 `[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.`
3078 );
3079 }
3080 _measureHeaderHeight() {
3081 const root = this.shadowRoot;
3082 if (!root) {
3083 return;
3084 }
3085 const headRow = root.querySelector("thead tr");
3086 if (!headRow) {
3087 return;
3088 }
3089 const h = headRow.offsetHeight;
3090 if (h > 0) {
3091 this.style.setProperty("--wpd-table-header-height", `${h}px`);
3092 }
3093 }
3094 /**
3095 * Once-per-element warning for the most common sticky-header
3096 * mistake: forgetting to give the table a scroll container. Without
3097 * a max-height (or a scrolling ancestor), `position: sticky`
3098 * silently does nothing because there's no scrollport for it to
3099 * stick within.
3100 */
3101 _maybeWarnStickyHeader() {
3102 if (this._stickyHeaderWarned) {
3103 return;
3104 }
3105 if (!this.hasAttribute("sticky-header")) {
3106 return;
3107 }
3108 if (this.hasAttribute("loading") || this._data.length < 8) {
3109 return;
3110 }
3111 const scroll = this.shadowRoot?.querySelector(
3112 ".scroll"
3113 );
3114 if (!scroll) {
3115 return;
3116 }
3117 if (scroll.offsetWidth === 0) {
3118 return;
3119 }
3120 if (scroll.scrollHeight <= scroll.clientHeight + 1) {
3121 this._stickyHeaderWarned = true;
3122 console.warn(
3123 "[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."
3124 );
3125 }
3126 }
3127 };
3128 _WpdTable.props = [
3129 "stickyColumns",
3130 "stickyHeader",
3131 "striped",
3132 "hover",
3133 "compact",
3134 "bordered",
3135 "empty",
3136 "loading",
3137 "loadingRows",
3138 "selectable"
3139 ];
3140 _WpdTable.styles = [styles$8];
3141 _WpdTable.help = {
3142 title: "Table",
3143 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.",
3144 status: "experimental",
3145 since: "0.6.0",
3146 props: [
3147 {
3148 name: "sticky-columns",
3149 type: "integer",
3150 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."
3151 },
3152 {
3153 name: "sticky-header",
3154 type: "boolean",
3155 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."
3156 },
3157 { name: "striped", type: "boolean", description: "Zebra rows." },
3158 { name: "hover", type: "boolean", description: "Highlight rows on hover." },
3159 { name: "compact", type: "boolean", description: "Tighter padding + smaller font." },
3160 { name: "bordered", type: "boolean", description: "Vertical cell borders." },
3161 {
3162 name: "empty",
3163 type: "string",
3164 description: "Fallback text shown when there are no rows. For richer empty states, project light-DOM content into the `empty` slot."
3165 },
3166 {
3167 name: "loading",
3168 type: "boolean",
3169 description: "Paint shimmering skeleton rows in place of body content. Filters / sort headers stay live."
3170 },
3171 {
3172 name: "loading-rows",
3173 type: "integer",
3174 description: "Number of skeleton rows when loading. Default 5."
3175 },
3176 {
3177 name: "selectable",
3178 type: '"single" | "multi"',
3179 description: "Auto-prepend a checkbox column. `multi` puts a select-all checkbox in the header; `single` enforces at-most-one selected."
3180 }
3181 ],
3182 events: [
3183 { name: "wpd-table-filter-change", description: "Filter input changed." },
3184 { name: "wpd-table-sort-change", description: "Header click cycled the sort." },
3185 { name: "wpd-table-selection-change", description: "Selection set changed." },
3186 { name: "wpd-table-row-click", description: "Body row clicked (skips data-noclick descendants)." },
3187 { name: "wpd-table-expand-change", description: "Sub-table toggled." }
3188 ],
3189 slots: [
3190 { name: "empty", description: "Custom empty-state content (CTA, illustration, etc.)." }
3191 ],
3192 cssProps: [
3193 { name: "--wpd-table-bg" },
3194 { name: "--wpd-table-border" },
3195 { name: "--wpd-table-column-border" },
3196 { name: "--wpd-table-header-bg" },
3197 { name: "--wpd-table-row-hover" },
3198 { name: "--wpd-table-stripe" },
3199 { name: "--wpd-table-cell-padding" },
3200 { name: "--wpd-table-font-size" },
3201 { name: "--wpd-table-max-height" },
3202 { name: "--wpd-table-skeleton-color" }
3203 ],
3204 example: html`
3205 <wpd-table id="sample-table" sticky-header striped hover></wpd-table>
3206 `
3207 };
3208 let WpdTable = _WpdTable;
3209 function isTemplateResult(v) {
3210 return !!v && v.__wpdHtml === true;
3211 }
3212 function compareValues(a, b) {
3213 if (a === b) {
3214 return 0;
3215 }
3216 if (a === null || a === void 0) {
3217 return -1;
3218 }
3219 if (b === null || b === void 0) {
3220 return 1;
3221 }
3222 if (typeof a === "number" && typeof b === "number") {
3223 return a - b;
3224 }
3225 if (a instanceof Date && b instanceof Date) {
3226 return a.getTime() - b.getTime();
3227 }
3228 const an = Number(a);
3229 const bn = Number(b);
3230 if (Number.isFinite(an) && Number.isFinite(bn)) {
3231 return an - bn;
3232 }
3233 return String(a).localeCompare(String(b));
3234 }
3235 defineComponent("wpd-table", WpdTable);
3236 const tabsStyles = css`:host{display:flex;gap:4px;margin-bottom:10px;border-bottom:1px solid var( --desktop-mode-border,#dcdcde )}`;
3237 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}`;
3238 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 )}`;
3239 const _WpdTab = class _WpdTab extends Component {
3240 render() {
3241 this.setAttribute("role", "tab");
3242 return html`
3243 <button type="button" @click=${() => this._onPick()}>
3244 <slot></slot>
3245 </button>
3246 `;
3247 }
3248 _onPick() {
3249 this.emit("wpd-tab-pick", {
3250 value: this.value
3251 });
3252 }
3253 };
3254 _WpdTab.props = ["value"];
3255 _WpdTab.styles = [tabStyles];
3256 _WpdTab.help = {
3257 title: "Tab",
3258 summary: "Single tab inside a <wpd-tabs> strip. Carries its identifier via `value`; aria-selected + tabindex are mirrored by the parent.",
3259 status: "stable",
3260 since: "0.7.0",
3261 props: [
3262 {
3263 name: "value",
3264 type: "string",
3265 description: "Identifier the tab contributes to the parent strip selection."
3266 }
3267 ],
3268 slots: [
3269 { name: "(default)", description: "Visible tab label." }
3270 ],
3271 events: [
3272 {
3273 name: "wpd-tab-pick",
3274 description: "Internal event bubbled to the parent <wpd-tabs>. Consumers should listen for wpd-tab-change on the strip instead.",
3275 detail: "{ value: string | null }"
3276 }
3277 ]
3278 };
3279 let WpdTab = _WpdTab;
3280 defineComponent("wpd-tab", WpdTab);
3281 const _WpdTabs = class _WpdTabs extends Component {
3282 connectedCallback() {
3283 super.connectedCallback();
3284 this.addEventListener("wpd-tab-pick", (e) => {
3285 const detail = e.detail;
3286 e.stopPropagation();
3287 this.value = detail.value;
3288 this.emit("wpd-tab-change", { value: detail.value });
3289 });
3290 }
3291 /**
3292 * Declarative item-list setter. Replaces the existing `<wpd-tab>`
3293 * children with a fresh set built from a `{ value, label }`
3294 * array. The `value` prop is preserved if it still matches a new
3295 * entry; otherwise it falls back to the first item.
3296 *
3297 * Lets plugins that populate tabs dynamically (route-driven
3298 * admin screens, filtered lists) replace the declarative
3299 * markup with a one-liner:
3300 *
3301 * ```js
3302 * tabs.items = [
3303 * { value: 'calc', label: 'Calc' },
3304 * { value: 'convert', label: 'Convert' },
3305 * ];
3306 * ```
3307 *
3308 * @since 0.5.0
3309 */
3310 set items(list) {
3311 replaceChildren(this, "wpd-tab", list);
3312 const current = this.value;
3313 const stillValid = current !== null && list.some((i) => i.value === current);
3314 if (!stillValid && list.length > 0) {
3315 this.value = list[0].value;
3316 } else {
3317 this.requestUpdate();
3318 }
3319 }
3320 render() {
3321 this.setAttribute("role", "tablist");
3322 const label = this.label || "";
3323 if (label) {
3324 this.setAttribute("aria-label", label);
3325 }
3326 const current = this.value;
3327 queueMicrotask(() => {
3328 const tabs = this.querySelectorAll("wpd-tab");
3329 for (const tab of Array.from(tabs)) {
3330 const v = tab.getAttribute("value");
3331 tab.setAttribute(
3332 "aria-selected",
3333 v === current ? "true" : "false"
3334 );
3335 tab.setAttribute("tabindex", v === current ? "0" : "-1");
3336 }
3337 syncTabpanels(this, current);
3338 });
3339 return html`<slot></slot>`;
3340 }
3341 };
3342 _WpdTabs.props = ["value", "label"];
3343 _WpdTabs.styles = [tabsStyles];
3344 _WpdTabs.help = {
3345 title: "Tabs",
3346 summary: 'Underline-accent tab strip. Pair with sibling <wpd-tabpanel for="…"> elements and the strip auto-toggles their hidden attribute on selection.',
3347 status: "stable",
3348 since: "0.7.0",
3349 props: [
3350 {
3351 name: "value",
3352 type: "string",
3353 description: "Currently active tab value. Mirrored to child <wpd-tab> aria-selected."
3354 },
3355 {
3356 name: "label",
3357 type: "string",
3358 description: "aria-label for the tablist — describe the tab group for assistive tech."
3359 }
3360 ],
3361 slots: [
3362 {
3363 name: "(default)",
3364 description: '<wpd-tab value="…"> children forming the strip.'
3365 }
3366 ],
3367 events: [
3368 {
3369 name: "wpd-tab-change",
3370 description: "Fires when the active tab changes.",
3371 detail: "{ value: string }"
3372 }
3373 ],
3374 example: html`
3375 <wpd-tabs value="one" label="Demo tabs">
3376 <wpd-tab value="one">One</wpd-tab>
3377 <wpd-tab value="two">Two</wpd-tab>
3378 <wpd-tab value="three">Three</wpd-tab>
3379 </wpd-tabs>
3380 <wpd-tabpanel for="one">First panel.</wpd-tabpanel>
3381 <wpd-tabpanel for="two">Second panel.</wpd-tabpanel>
3382 <wpd-tabpanel for="three">Third panel.</wpd-tabpanel>
3383 `
3384 };
3385 let WpdTabs = _WpdTabs;
3386 defineComponent("wpd-tabs", WpdTabs);
3387 const _WpdTabPanel = class _WpdTabPanel extends Component {
3388 // Shadow DOM — the render target for this component is its
3389 // own shadow root, which holds a single `<slot>` that projects
3390 // whatever the caller placed between the `<wpd-tabpanel>` open
3391 // and close tags. Slotted children remain light-DOM descendants
3392 // of the panel element (the slot rendering mechanism doesn't
3393 // move them), so `panel.querySelector(...)` from plugin render
3394 // callbacks keeps working.
3395 //
3396 // Earlier 0.5.0 builds of this component used light DOM with
3397 // a `<slot>` render, which wiped the panel's server-rendered
3398 // template content on first mount — every `render()` writes
3399 // into `_renderRoot`, and with light DOM that's the panel
3400 // itself. Shadow DOM isolates the render surface.
3401 connectedCallback() {
3402 super.connectedCallback();
3403 this.setAttribute("role", "tabpanel");
3404 if (!this.hasAttribute("tabindex")) {
3405 this.setAttribute("tabindex", "0");
3406 }
3407 const owner = findOwningTabs(this);
3408 if (owner) {
3409 syncTabpanels(owner, owner.getAttribute("value"));
3410 }
3411 }
3412 render() {
3413 return html`<slot></slot>`;
3414 }
3415 };
3416 _WpdTabPanel.props = ["for"];
3417 _WpdTabPanel.styles = [tabPanelStyles];
3418 _WpdTabPanel.help = {
3419 title: "Tab panel",
3420 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.',
3421 status: "stable",
3422 since: "0.5.0",
3423 props: [
3424 {
3425 name: "for",
3426 type: "string",
3427 description: "Matches the `value` of the owning <wpd-tab>. Panel is shown when its parent tabs strip is on that value."
3428 }
3429 ],
3430 slots: [
3431 { name: "(default)", description: "Panel body content." }
3432 ]
3433 };
3434 let WpdTabPanel = _WpdTabPanel;
3435 defineComponent("wpd-tabpanel", WpdTabPanel);
3436 function replaceChildren(host, tag, items) {
3437 const existing = host.querySelectorAll(`:scope > ${tag}`);
3438 for (const el of Array.from(existing)) {
3439 el.remove();
3440 }
3441 for (const item of items) {
3442 const el = document.createElement(tag);
3443 el.setAttribute("value", item.value);
3444 el.textContent = item.label;
3445 host.appendChild(el);
3446 }
3447 }
3448 function findOwningTabs(panel) {
3449 const parent = panel.parentElement;
3450 if (!parent) {
3451 return null;
3452 }
3453 const sibling = parent.querySelector(":scope > wpd-tabs");
3454 if (sibling) {
3455 return sibling;
3456 }
3457 return panel.closest("wpd-tabs");
3458 }
3459 function syncTabpanels(tabs, value) {
3460 const panels = /* @__PURE__ */ new Set();
3461 const parent = tabs.parentElement;
3462 if (parent) {
3463 for (const p of Array.from(
3464 parent.querySelectorAll(":scope > wpd-tabpanel")
3465 )) {
3466 panels.add(p);
3467 }
3468 }
3469 for (const p of Array.from(
3470 tabs.querySelectorAll(":scope > wpd-tabpanel")
3471 )) {
3472 panels.add(p);
3473 }
3474 for (const panel of panels) {
3475 const pfor = panel.getAttribute("for");
3476 const active = pfor !== null && pfor === value;
3477 if (active) {
3478 panel.removeAttribute("hidden");
3479 } else {
3480 panel.setAttribute("hidden", "");
3481 }
3482 panel.setAttribute("aria-hidden", active ? "false" : "true");
3483 }
3484 }
3485 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}`;
3486 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 )}`;
3487 const _WpdChip = class _WpdChip extends Component {
3488 constructor() {
3489 super(...arguments);
3490 this._onHostKeyDown = (e) => {
3491 const dismissible = this.dismissible !== null;
3492 if (!dismissible) {
3493 return;
3494 }
3495 if (e.key === "Backspace" || e.key === "Delete") {
3496 e.preventDefault();
3497 const disabled = this.disabled !== null;
3498 if (disabled) {
3499 return;
3500 }
3501 const label = this.label ?? "";
3502 this.emit("wpd-chip-dismiss", { label });
3503 }
3504 };
3505 }
3506 connectedCallback() {
3507 super.connectedCallback();
3508 this.addEventListener("keydown", this._onHostKeyDown);
3509 }
3510 disconnectedCallback() {
3511 this.removeEventListener("keydown", this._onHostKeyDown);
3512 }
3513 render() {
3514 const label = this.label ?? "";
3515 const dismissible = this.dismissible !== null;
3516 const disabled = this.disabled !== null;
3517 return html`
3518 <span part="chip" class="wpd-chip">
3519 <span class="wpd-chip__icon">
3520 <slot name="icon"></slot>
3521 </span>
3522 <span class="wpd-chip__label">
3523 ${label === "" ? html`<slot></slot>` : label}
3524 </span>
3525 ${dismissible ? html`
3526 <button
3527 part="dismiss"
3528 class="wpd-chip__dismiss"
3529 type="button"
3530 aria-label=${`Remove ${label || "chip"}`}
3531 ?disabled=${disabled}
3532 @click=${(e) => this._onDismiss(e)}
3533 >
3534 ${_iconCross$1()}
3535 </button>
3536 ` : html``}
3537 </span>
3538 `;
3539 }
3540 _onDismiss(e) {
3541 e.stopPropagation();
3542 const disabled = this.disabled !== null;
3543 if (disabled) {
3544 return;
3545 }
3546 const label = this.label ?? "";
3547 this.emit("wpd-chip-dismiss", { label });
3548 }
3549 };
3550 _WpdChip.props = [
3551 "label",
3552 "tone",
3553 "size",
3554 "dismissible",
3555 "disabled",
3556 "pending"
3557 ];
3558 _WpdChip.styles = [styles$6];
3559 _WpdChip.help = {
3560 title: "Chip",
3561 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.",
3562 status: "experimental",
3563 since: "0.8.0",
3564 props: [
3565 {
3566 name: "label",
3567 type: "string",
3568 description: "Visible text. Falls back to the default slot when omitted."
3569 },
3570 {
3571 name: "tone",
3572 type: "'neutral' | 'accent' | 'positive' | 'warning' | 'danger'",
3573 default: "neutral",
3574 description: "Color variant. Mirrors <wpd-badge> tones."
3575 },
3576 {
3577 name: "size",
3578 type: "'default' | 'compact'",
3579 default: "default",
3580 description: "Vertical density. Compact halves horizontal padding for dense lists."
3581 },
3582 {
3583 name: "dismissible",
3584 type: "boolean attribute",
3585 description: "Renders a trailing × button. Click / Enter / Space emits wpd-chip-dismiss."
3586 },
3587 {
3588 name: "disabled",
3589 type: "boolean attribute",
3590 description: "Visually mutes the chip and blocks the dismiss button. Useful while a parent is mid-update."
3591 },
3592 {
3593 name: "pending",
3594 type: "boolean attribute",
3595 description: "Renders a subtle pulse animation while a REST mutation is in flight. Auto-applied by <wpd-tag-input>; safe to set by hand."
3596 }
3597 ],
3598 slots: [
3599 { name: "(default)", description: "Fallback label when `label` is unset." },
3600 {
3601 name: "icon",
3602 description: "Leading icon (Dashicon, SVG, image). Inherits text color."
3603 }
3604 ],
3605 parts: [
3606 { name: "chip", description: "The pill container." },
3607 {
3608 name: "dismiss",
3609 description: "The trailing × button (when `dismissible`)."
3610 }
3611 ],
3612 events: [
3613 {
3614 name: "wpd-chip-dismiss",
3615 description: "Fires when the dismiss button is activated. Detail carries the chip's label so a delegated listener can act without DOM walking.",
3616 detail: "{ label: string }"
3617 }
3618 ],
3619 cssProps: [
3620 { name: "--wpd-chip-bg", description: "Background color." },
3621 { name: "--wpd-chip-fg", description: "Text color." },
3622 { name: "--wpd-chip-border", description: "Border shorthand." },
3623 {
3624 name: "--wpd-chip-padding",
3625 description: "Padding shorthand.",
3626 default: "2px 8px"
3627 },
3628 {
3629 name: "--wpd-chip-radius",
3630 description: "Corner radius.",
3631 default: "999px"
3632 },
3633 {
3634 name: "--wpd-chip-label-max",
3635 description: "Max width of the inner label before ellipsis.",
3636 default: "220px"
3637 }
3638 ],
3639 example: html`
3640 <wpd-cluster gap="6">
3641 <wpd-chip label="Neutral"></wpd-chip>
3642 <wpd-chip label="Accent" tone="accent"></wpd-chip>
3643 <wpd-chip label="Positive" tone="positive"></wpd-chip>
3644 <wpd-chip label="Warning" tone="warning"></wpd-chip>
3645 <wpd-chip label="Danger" tone="danger"></wpd-chip>
3646 <wpd-chip label="Dismissible" dismissible></wpd-chip>
3647 </wpd-cluster>
3648 `
3649 };
3650 let WpdChip = _WpdChip;
3651 defineComponent("wpd-chip", WpdChip);
3652 function _iconCross$1() {
3653 return html`
3654 <svg
3655 viewBox="0 0 12 12"
3656 width="10"
3657 height="10"
3658 aria-hidden="true"
3659 focusable="false"
3660 fill="none"
3661 stroke="currentColor"
3662 stroke-width="1.5"
3663 stroke-linecap="round"
3664 >
3665 <path d="M3 3 L9 9 M9 3 L3 9" />
3666 </svg>
3667 `;
3668 }
3669 const _WpdTagInput = class _WpdTagInput extends Component {
3670 constructor() {
3671 super(...arguments);
3672 this._value = [];
3673 this._suggestions = [];
3674 this._suggestionsLoading = false;
3675 this._query = "";
3676 this._highlight = -1;
3677 this._focusedChip = -1;
3678 this._onDocumentPointerDown = (e) => {
3679 if (!this.isOpen) {
3680 return;
3681 }
3682 const path = e.composedPath();
3683 if (path.includes(this)) {
3684 return;
3685 }
3686 this.closeInput();
3687 };
3688 }
3689 // Resolves to the inline input AFTER each render. Re-queried on
3690 // every `requestUpdate` because the shadow tree builds fresh
3691 // nodes per render.
3692 get _input() {
3693 const root = this.shadowRoot;
3694 return root ? root.querySelector(".wpd-tag-input__input") : null;
3695 }
3696 // --- Public properties (JS-only) -------------------------------------
3697 get value() {
3698 return this._value;
3699 }
3700 set value(next) {
3701 this._value = Array.isArray(next) ? next.slice() : [];
3702 if (this._focusedChip >= this._value.length) {
3703 this._focusedChip = -1;
3704 }
3705 this.requestUpdate();
3706 }
3707 get suggestions() {
3708 return this._suggestions;
3709 }
3710 set suggestions(next) {
3711 this._suggestions = Array.isArray(next) ? next.slice() : [];
3712 this._highlight = this._suggestions.length > 0 ? 0 : -1;
3713 this._suggestionsLoading = false;
3714 this.requestUpdate();
3715 }
3716 get suggestionsLoading() {
3717 return this._suggestionsLoading;
3718 }
3719 set suggestionsLoading(next) {
3720 this._suggestionsLoading = !!next;
3721 this.requestUpdate();
3722 }
3723 get query() {
3724 return this._query;
3725 }
3726 get isOpen() {
3727 return this.open !== null;
3728 }
3729 /**
3730 * Open the inline input + suggestions popover. Equivalent to
3731 * clicking the "+" trigger. Call from the parent to start tag
3732 * entry programmatically (e.g. paste interception).
3733 */
3734 openInput() {
3735 if (this.isOpen) {
3736 return;
3737 }
3738 this.open = "";
3739 this._query = "";
3740 this._highlight = -1;
3741 this.emit("wpd-tag-open", {});
3742 queueMicrotask(() => {
3743 this._input?.focus();
3744 this._emitSuggest("");
3745 });
3746 }
3747 /**
3748 * Close the inline input. Use from a parent to dismiss after a
3749 * background save resolves.
3750 */
3751 closeInput() {
3752 if (!this.isOpen) {
3753 return;
3754 }
3755 this.open = null;
3756 this._query = "";
3757 this._suggestions = [];
3758 this._highlight = -1;
3759 this._suggestionsLoading = false;
3760 this.emit("wpd-tag-close", {});
3761 this.requestUpdate();
3762 }
3763 // --- Lifecycle --------------------------------------------------------
3764 connectedCallback() {
3765 super.connectedCallback();
3766 document.addEventListener("pointerdown", this._onDocumentPointerDown, true);
3767 }
3768 disconnectedCallback() {
3769 document.removeEventListener("pointerdown", this._onDocumentPointerDown, true);
3770 }
3771 // --- Render -----------------------------------------------------------
3772 render() {
3773 const isOpen = this.isOpen;
3774 const disabled = this.disabled !== null;
3775 const readonly = this.readonly !== null;
3776 const removable = this.removable !== null || this.removable === null && !readonly;
3777 const creatable = this.creatable !== null;
3778 const addLabel = this["add-label"] || "+ Add";
3779 const placeholder = this.placeholder || "Add a tag…";
3780 return html`
3781 <span
3782 class="wpd-tag-input"
3783 role="group"
3784 aria-label=${this.label ?? ""}
3785 >
3786 ${this._renderChips(removable, disabled)}
3787 ${this._renderTrailing({
3788 isOpen,
3789 readonly,
3790 disabled,
3791 placeholder,
3792 creatable,
3793 addLabel
3794 })}
3795 </span>
3796 `;
3797 }
3798 _renderTrailing(opts) {
3799 if (opts.isOpen) {
3800 return this._renderEditor(opts.placeholder, opts.creatable);
3801 }
3802 if (opts.readonly || opts.disabled) {
3803 return html``;
3804 }
3805 return this._renderTrigger(opts.addLabel);
3806 }
3807 _renderChips(removable, disabled) {
3808 const tags = this._value;
3809 if (tags.length === 0) {
3810 return html``;
3811 }
3812 return html`
3813 <span class="wpd-tag-input__chips" role="list">
3814 ${tags.map((tag, idx) => {
3815 const tone = tag.tone ?? "neutral";
3816 return html`
3817 <wpd-chip
3818 role="listitem"
3819 size="compact"
3820 tone=${tone}
3821 label=${tag.label}
3822 ?dismissible=${removable && !disabled}
3823 ?disabled=${disabled}
3824 ?pending=${!!tag.pending}
3825 tabindex=${idx === this._focusedChip ? "0" : "-1"}
3826 data-idx=${String(idx)}
3827 @wpd-chip-dismiss=${(e) => this._onChipDismiss(e, tag)}
3828 @focus=${() => this._focusedChip = idx}
3829 ></wpd-chip>
3830 `;
3831 })}
3832 </span>
3833 `;
3834 }
3835 _renderTrigger(addLabel) {
3836 const disabled = this.disabled !== null;
3837 return html`
3838 <button
3839 type="button"
3840 class="wpd-tag-input__add"
3841 aria-label=${addLabel}
3842 aria-haspopup="listbox"
3843 aria-expanded="false"
3844 ?disabled=${disabled}
3845 @click=${() => this.openInput()}
3846 >
3847 ${_iconPlus()}
3848 <span>${addLabel}</span>
3849 </button>
3850 `;
3851 }
3852 _renderEditor(placeholder, creatable) {
3853 const showSuggestions = this._suggestions.length > 0 || this._suggestionsLoading || creatable && this._query.trim().length > 0;
3854 return html`
3855 <span class="wpd-tag-input__editor">
3856 <input
3857 class="wpd-tag-input__input"
3858 type="text"
3859 autocomplete="off"
3860 autocapitalize="off"
3861 spellcheck="false"
3862 placeholder=${placeholder}
3863 .value=${this._query}
3864 aria-autocomplete="list"
3865 aria-expanded=${showSuggestions ? "true" : "false"}
3866 aria-activedescendant=${this._highlight >= 0 ? `wpd-tag-suggestion-${this._highlight}` : ""}
3867 @input=${(e) => this._onInput(e)}
3868 @keydown=${(e) => this._onInputKeyDown(e)}
3869 @blur=${(e) => this._onInputBlur(e)}
3870 />
3871 ${showSuggestions ? this._renderSuggestions(creatable) : html``}
3872 </span>
3873 `;
3874 }
3875 _renderSuggestions(creatable) {
3876 const trimmed = this._query.trim();
3877 const items = this._suggestions;
3878 const showCreate = creatable && trimmed.length > 0 && !items.some((s) => s.label.toLowerCase() === trimmed.toLowerCase()) && !this._value.some((v) => v.label.toLowerCase() === trimmed.toLowerCase());
3879 return html`
3880 <div
3881 class="wpd-tag-input__suggestions"
3882 role="listbox"
3883 >
3884 ${this._suggestionsLoading ? html`
3885 <div class="wpd-tag-input__suggestion-loading">
3886 <span class="wpd-tag-input__suggestion-spinner" aria-hidden="true"></span>
3887 <span>Searching…</span>
3888 </div>
3889 ` : html``}
3890 ${items.length === 0 && !this._suggestionsLoading && !showCreate ? html`
3891 <div class="wpd-tag-input__suggestion-empty">
3892 ${trimmed.length > 0 ? "No matches." : "Type to search."}
3893 </div>
3894 ` : html``}
3895 ${items.map((item, idx) => {
3896 const selected = idx === this._highlight;
3897 return html`
3898 <div
3899 id=${`wpd-tag-suggestion-${idx}`}
3900 role="option"
3901 aria-selected=${selected ? "true" : "false"}
3902 class="wpd-tag-input__suggestion-item"
3903 @mousedown=${(e) => {
3904 e.preventDefault();
3905 this._addSuggestion(item, false);
3906 }}
3907 @mouseenter=${() => {
3908 this._highlight = idx;
3909 this.requestUpdate();
3910 }}
3911 >
3912 <span>${item.label}</span>
3913 </div>
3914 `;
3915 })}
3916 ${showCreate ? html`
3917 <div
3918 id=${`wpd-tag-suggestion-${items.length}`}
3919 role="option"
3920 aria-selected=${this._highlight === items.length ? "true" : "false"}
3921 class="wpd-tag-input__suggestion-item wpd-tag-input__suggestion-create"
3922 @mousedown=${(e) => {
3923 e.preventDefault();
3924 this._addSuggestion(
3925 { label: trimmed },
3926 true
3927 );
3928 }}
3929 @mouseenter=${() => {
3930 this._highlight = items.length;
3931 this.requestUpdate();
3932 }}
3933 >
3934 Create "${trimmed}"
3935 </div>
3936 ` : html``}
3937 </div>
3938 `;
3939 }
3940 // --- Event handlers ---------------------------------------------------
3941 _onChipDismiss(e, tag) {
3942 e.stopPropagation();
3943 this.emit("wpd-tag-remove", { tag });
3944 }
3945 _onInput(e) {
3946 const value = e.target.value;
3947 this._query = value;
3948 this._emitSuggest(value);
3949 }
3950 _emitSuggest(query) {
3951 const minQuery = parseInt(
3952 this["min-query"] || "0",
3953 10
3954 ) || 0;
3955 if (query.length < minQuery) {
3956 this._suggestions = [];
3957 this._suggestionsLoading = false;
3958 this.requestUpdate();
3959 return;
3960 }
3961 this._suggestionsLoading = true;
3962 this.requestUpdate();
3963 this.emit("wpd-tag-suggest", { query });
3964 }
3965 _onInputKeyDown(e) {
3966 const creatable = this.creatable !== null;
3967 const items = this._suggestions;
3968 const trimmed = this._query.trim();
3969 const showCreate = creatable && trimmed.length > 0 && !items.some((s) => s.label.toLowerCase() === trimmed.toLowerCase()) && !this._value.some((v) => v.label.toLowerCase() === trimmed.toLowerCase());
3970 const totalSelectable = items.length + (showCreate ? 1 : 0);
3971 switch (e.key) {
3972 case "ArrowDown": {
3973 if (totalSelectable === 0) {
3974 return;
3975 }
3976 e.preventDefault();
3977 this._highlight = this._highlight + 1 >= totalSelectable ? 0 : this._highlight + 1;
3978 this.requestUpdate();
3979 return;
3980 }
3981 case "ArrowUp": {
3982 if (totalSelectable === 0) {
3983 return;
3984 }
3985 e.preventDefault();
3986 this._highlight = this._highlight <= 0 ? totalSelectable - 1 : this._highlight - 1;
3987 this.requestUpdate();
3988 return;
3989 }
3990 case "Enter": {
3991 e.preventDefault();
3992 if (this._highlight >= 0 && this._highlight < items.length) {
3993 this._addSuggestion(items[this._highlight], false);
3994 return;
3995 }
3996 if (this._highlight === items.length && showCreate) {
3997 this._addSuggestion({ label: trimmed }, true);
3998 return;
3999 }
4000 if (showCreate && trimmed.length > 0) {
4001 this._addSuggestion({ label: trimmed }, true);
4002 return;
4003 }
4004 return;
4005 }
4006 case "Escape": {
4007 e.preventDefault();
4008 this.closeInput();
4009 return;
4010 }
4011 case "Backspace": {
4012 if (this._query === "" && this._value.length > 0) {
4013 e.preventDefault();
4014 const lastIdx = this._value.length - 1;
4015 if (this._focusedChip === lastIdx) {
4016 this.emit("wpd-tag-remove", {
4017 tag: this._value[lastIdx]
4018 });
4019 this._focusedChip = -1;
4020 } else {
4021 this._focusedChip = lastIdx;
4022 this.requestUpdate();
4023 }
4024 }
4025 return;
4026 }
4027 default:
4028 if (this._focusedChip !== -1) {
4029 this._focusedChip = -1;
4030 }
4031 }
4032 }
4033 _onInputBlur(_e) {
4034 queueMicrotask(() => {
4035 if (!this.shadowRoot?.activeElement) {
4036 this.closeInput();
4037 }
4038 });
4039 }
4040 _addSuggestion(tag, isNew) {
4041 const exists = this._value.some(
4042 (v) => v.label.toLowerCase() === tag.label.toLowerCase()
4043 );
4044 if (exists) {
4045 this._query = "";
4046 this._highlight = -1;
4047 this._suggestions = [];
4048 this.requestUpdate();
4049 this._input?.focus();
4050 return;
4051 }
4052 this.emit("wpd-tag-add", { tag, isNew });
4053 this._query = "";
4054 this._highlight = -1;
4055 this._suggestions = [];
4056 this._suggestionsLoading = false;
4057 this.requestUpdate();
4058 queueMicrotask(() => {
4059 this._input?.focus();
4060 });
4061 }
4062 };
4063 _WpdTagInput.props = [
4064 "label",
4065 "placeholder",
4066 "add-label",
4067 "creatable",
4068 "removable",
4069 "disabled",
4070 "readonly",
4071 "size",
4072 "min-query",
4073 "open"
4074 ];
4075 _WpdTagInput.styles = [styles$7];
4076 _WpdTagInput.help = {
4077 title: "Tag input",
4078 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.",
4079 status: "experimental",
4080 since: "0.8.0",
4081 props: [
4082 {
4083 name: "label",
4084 type: "string",
4085 description: "Accessible label for the inline input."
4086 },
4087 {
4088 name: "placeholder",
4089 type: "string",
4090 description: "Native placeholder for the inline input.",
4091 default: "Add a tag…"
4092 },
4093 {
4094 name: "add-label",
4095 type: "string",
4096 description: 'Label of the "+" trigger button.',
4097 default: "+ Add"
4098 },
4099 {
4100 name: "creatable",
4101 type: "boolean attribute",
4102 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."
4103 },
4104 {
4105 name: "removable",
4106 type: "boolean attribute",
4107 description: "Show × on every chip and emit `wpd-tag-remove` on click. On by default; switch off for read-only views."
4108 },
4109 {
4110 name: "disabled",
4111 type: "boolean attribute",
4112 description: "Disables the entire control. Chips render but the trigger / input / dismiss buttons are inert."
4113 },
4114 {
4115 name: "readonly",
4116 type: "boolean attribute",
4117 description: 'Hides the "+" trigger and chip × buttons. Same as setting `creatable=false` and `removable=false` together.'
4118 },
4119 {
4120 name: "size",
4121 type: "'default' | 'compact'",
4122 default: "default",
4123 description: "Density preset. Compact suits dense table cells."
4124 },
4125 {
4126 name: "min-query",
4127 type: "integer (string)",
4128 default: "0",
4129 description: "Minimum query length before `wpd-tag-suggest` fires. Set to 1 or 2 for taxonomies with thousands of terms."
4130 },
4131 {
4132 name: "open",
4133 type: "boolean attribute",
4134 description: "Two-way reflected: present while the inline input is showing. Setting it externally opens / closes the picker."
4135 }
4136 ],
4137 events: [
4138 {
4139 name: "wpd-tag-suggest",
4140 description: "Fires when the user types in the input. Consumer fetches suggestions and assigns them back via `el.suggestions = […]`.",
4141 detail: "{ query: string }"
4142 },
4143 {
4144 name: "wpd-tag-add",
4145 description: "Fires when the user picks a suggestion or, with `creatable`, presses Enter on a free-form value. Consumer mutates `value`.",
4146 detail: "{ tag: WpdTagItem; isNew: boolean }"
4147 },
4148 {
4149 name: "wpd-tag-remove",
4150 description: "Fires when × on a chip is activated. Consumer mutates `value`.",
4151 detail: "{ tag: WpdTagItem }"
4152 },
4153 {
4154 name: "wpd-tag-open",
4155 description: "Fires when the inline input opens.",
4156 detail: "{}"
4157 },
4158 {
4159 name: "wpd-tag-close",
4160 description: "Fires when the inline input closes.",
4161 detail: "{}"
4162 }
4163 ],
4164 cssProps: [
4165 {
4166 name: "--wpd-tag-input-gap",
4167 description: "Gap between chips / between chips and trigger.",
4168 default: "4px"
4169 },
4170 {
4171 name: "--wpd-tag-input-padding",
4172 description: "Padding around the chip row.",
4173 default: "2px"
4174 },
4175 {
4176 name: "--wpd-tag-input-add-fg",
4177 description: 'Foreground color of the "+ Add" trigger.'
4178 },
4179 { name: "--wpd-tag-input-pop-bg", description: "Suggestions popover background." }
4180 ],
4181 example: html`
4182 <wpd-tag-input
4183 label="Tags"
4184 placeholder="Add a tag…"
4185 creatable
4186 ></wpd-tag-input>
4187 `
4188 };
4189 let WpdTagInput = _WpdTagInput;
4190 defineComponent("wpd-tag-input", WpdTagInput);
4191 function _iconPlus() {
4192 return html`
4193 <svg
4194 viewBox="0 0 12 12"
4195 width="9"
4196 height="9"
4197 aria-hidden="true"
4198 focusable="false"
4199 fill="none"
4200 stroke="currentColor"
4201 stroke-width="2"
4202 stroke-linecap="round"
4203 >
4204 <path d="M6 2 L6 10 M2 6 L10 6" />
4205 </svg>
4206 `;
4207 }
4208 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}`;
4209 const CHEVRON_W = "10px";
4210 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}`;
4211 var __freeze = Object.freeze;
4212 var __defProp = Object.defineProperty;
4213 var __template = (cooked, raw) => __freeze(__defProp(cooked, "raw", { value: __freeze(cooked.slice()) }));
4214 var _a;
4215 const _WpdCrumbChain = class _WpdCrumbChain extends Component {
4216 constructor() {
4217 super(...arguments);
4218 this._segments = [];
4219 }
4220 get segments() {
4221 return this._segments;
4222 }
4223 set segments(next) {
4224 this._segments = Array.isArray(next) ? next.slice() : [];
4225 this.requestUpdate();
4226 }
4227 render() {
4228 const removable = this.removable !== null;
4229 const segments = this._segments;
4230 if (segments.length === 0) {
4231 return html``;
4232 }
4233 return html`
4234 <div class="wpd-crumb-chain" role="group">
4235 ${segments.map((seg, idx) => {
4236 const variant = pickVariant(idx, segments.length);
4237 const bg = seg.color ?? "rgba( 0, 0, 0, 0.08 )";
4238 const fg = pickForegroundColor(bg);
4239 const styleStr = `--wpd-crumb-bg: ${bg}; --wpd-crumb-fg: ${fg};`;
4240 return html`
4241 <span
4242 class=${`wpd-crumb wpd-crumb--${variant}`}
4243 style=${styleStr}
4244 title=${seg.name}
4245 draggable="true"
4246 @click=${(e) => this._onSegmentClick(e, idx, seg)}
4247 @dragstart=${(e) => this._onSegmentDragStart(e, idx, seg)}
4248 >
4249 <span class="wpd-crumb__label">${seg.name}</span>
4250 ${removable ? html`
4251 <button
4252 type="button"
4253 class="wpd-crumb__remove"
4254 aria-label=${`Remove ${seg.name}`}
4255 draggable="false"
4256 @click=${(e) => this._onRemove(e, idx, seg)}
4257 >${_iconCross()}</button>
4258 ` : html``}
4259 </span>
4260 `;
4261 })}
4262 </div>
4263 `;
4264 }
4265 _onSegmentDragStart(e, index, segment) {
4266 const target = e.target;
4267 if (target?.closest(".wpd-crumb__remove")) {
4268 e.preventDefault();
4269 return;
4270 }
4271 const dragSegments = this._segments.slice(index);
4272 if (e.dataTransfer) {
4273 const ghost = buildDragGhost(dragSegments);
4274 document.body.appendChild(ghost);
4275 const rect = e.currentTarget?.getBoundingClientRect();
4276 const offsetX = rect ? Math.min(30, rect.width / 2) : 16;
4277 const offsetY = rect ? Math.min(16, rect.height / 2) : 12;
4278 e.dataTransfer.setDragImage(ghost, offsetX, offsetY);
4279 requestAnimationFrame(() => ghost.remove());
4280 }
4281 this.emit("wpd-chain-segment-dragstart", {
4282 index,
4283 id: segment.id,
4284 segment,
4285 segments: dragSegments,
4286 dragEvent: e
4287 });
4288 }
4289 _onSegmentClick(e, index, segment) {
4290 const target = e.target;
4291 if (target?.closest(".wpd-crumb__remove")) {
4292 return;
4293 }
4294 this.emit("wpd-chain-segment-click", {
4295 index,
4296 id: segment.id,
4297 segment
4298 });
4299 }
4300 _onRemove(e, index, segment) {
4301 e.stopPropagation();
4302 this.emit("wpd-chain-remove", { index, id: segment.id, segment });
4303 }
4304 };
4305 _WpdCrumbChain.props = ["removable", "disabled"];
4306 _WpdCrumbChain.styles = [styles$4];
4307 _WpdCrumbChain.help = {
4308 title: "Crumb chain",
4309 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.",
4310 status: "experimental",
4311 since: "0.8.0",
4312 props: [
4313 {
4314 name: "removable",
4315 type: "boolean attribute",
4316 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)."
4317 },
4318 {
4319 name: "disabled",
4320 type: "boolean attribute",
4321 description: "Visually mute the chain and ignore pointer + keyboard input."
4322 }
4323 ],
4324 events: [
4325 {
4326 name: "wpd-chain-remove",
4327 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).",
4328 detail: "{ index: number; id?: number | string; segment: WpdCrumbSegment }"
4329 },
4330 {
4331 name: "wpd-chain-segment-click",
4332 description: 'Fires when ANY segment is clicked. Useful for navigation drills (click "Tech" to filter to Tech).',
4333 detail: "{ index: number; id?: number | string; segment: WpdCrumbSegment }"
4334 },
4335 {
4336 name: "wpd-chain-segment-dragstart",
4337 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.',
4338 detail: "{ index: number; id?: number | string; segment: WpdCrumbSegment; segments: WpdCrumbSegment[]; dragEvent: DragEvent }"
4339 }
4340 ],
4341 example: html(_a || (_a = __template([`
4342 <wpd-crumb-chain id="example-chain" removable></wpd-crumb-chain>
4343 <script>
4344 document.getElementById( 'example-chain' ).segments = [
4345 { id: 1, name: 'Tech', color: '#2271b1' },
4346 { id: 2, name: 'Web Dev', color: '#3a8ed4' },
4347 { id: 3, name: 'Frontend', color: '#5cb0ff' },
4348 ];
4349 <\/script>
4350 `])))
4351 };
4352 let WpdCrumbChain = _WpdCrumbChain;
4353 defineComponent("wpd-crumb-chain", WpdCrumbChain);
4354 const DRAG_GHOST_CHEVRON = 10;
4355 function buildDragGhost(segments) {
4356 const wrap = document.createElement("div");
4357 wrap.style.cssText = [
4358 "display: inline-flex",
4359 "align-items: stretch",
4360 "border-radius: 999px",
4361 "overflow: hidden",
4362 "filter: drop-shadow( 0 1px 2px rgba( 0, 0, 0, 0.18 ) )",
4363 'font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
4364 "font-size: 12px",
4365 "line-height: 1",
4366 "font-weight: 500",
4367 // Position offscreen but rendered — display:none / visibility:
4368 // hidden produce a blank drag-image snapshot.
4369 "position: fixed",
4370 "top: -10000px",
4371 "left: -10000px",
4372 "pointer-events: none",
4373 "z-index: 2147483647"
4374 ].join("; ");
4375 const total = segments.length;
4376 segments.forEach((seg, idx) => {
4377 const span = document.createElement("span");
4378 const bg = seg.color ?? "rgba( 0, 0, 0, 0.08 )";
4379 const fg = pickForegroundColor(bg);
4380 const variant = pickVariant(idx, total);
4381 const styleParts = [
4382 "display: inline-flex",
4383 "align-items: center",
4384 "justify-content: center",
4385 "min-height: 22px",
4386 `background: ${bg}`,
4387 `color: ${fg}`,
4388 "white-space: nowrap",
4389 "box-sizing: border-box",
4390 "letter-spacing: 0.01em"
4391 ];
4392 const c = DRAG_GHOST_CHEVRON;
4393 if (variant === "solo") {
4394 styleParts.push("padding: 2px 12px", "border-radius: 999px");
4395 } else if (variant === "first") {
4396 styleParts.push(
4397 "padding: 2px 22px 2px 12px",
4398 `clip-path: polygon( 0 0, calc( 100% - ${c}px ) 0, 100% 50%, calc( 100% - ${c}px ) 100%, 0 100% )`
4399 );
4400 } else if (variant === "middle") {
4401 styleParts.push(
4402 "padding: 2px 22px",
4403 `margin-inline-start: -${c}px`,
4404 `clip-path: polygon( ${c}px 0, calc( 100% - ${c}px ) 0, 100% 50%, calc( 100% - ${c}px ) 100%, ${c}px 100%, 0 50% )`
4405 );
4406 } else {
4407 styleParts.push(
4408 "padding: 2px 14px 2px 22px",
4409 `margin-inline-start: -${c}px`,
4410 `clip-path: polygon( ${c}px 0, 100% 0, 100% 100%, ${c}px 100%, 0 50% )`
4411 );
4412 }
4413 span.style.cssText = styleParts.join("; ");
4414 span.textContent = seg.name;
4415 wrap.appendChild(span);
4416 });
4417 return wrap;
4418 }
4419 function pickVariant(index, total) {
4420 if (total === 1) {
4421 return "solo";
4422 }
4423 if (index === 0) {
4424 return "first";
4425 }
4426 if (index === total - 1) {
4427 return "last";
4428 }
4429 return "middle";
4430 }
4431 let _readbackCanvas = null;
4432 function pickForegroundColor(bg) {
4433 if (!_readbackCanvas) {
4434 _readbackCanvas = document.createElement("canvas");
4435 _readbackCanvas.width = 1;
4436 _readbackCanvas.height = 1;
4437 }
4438 const ctx = _readbackCanvas.getContext("2d", { willReadFrequently: true });
4439 if (!ctx) {
4440 return "#1d2327";
4441 }
4442 try {
4443 ctx.clearRect(0, 0, 1, 1);
4444 ctx.fillStyle = bg;
4445 ctx.fillRect(0, 0, 1, 1);
4446 const data = ctx.getImageData(0, 0, 1, 1).data;
4447 const a = data[3] / 255;
4448 const r = data[0] * a + 255 * (1 - a);
4449 const g = data[1] * a + 255 * (1 - a);
4450 const b = data[2] * a + 255 * (1 - a);
4451 const lin = (c) => {
4452 const v = c / 255;
4453 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
4454 };
4455 const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
4456 return L > 0.55 ? "#1d2327" : "#fff";
4457 } catch {
4458 return "#1d2327";
4459 }
4460 }
4461 function _iconCross() {
4462 return html`
4463 <svg
4464 viewBox="0 0 12 12"
4465 aria-hidden="true"
4466 focusable="false"
4467 fill="none"
4468 stroke="currentColor"
4469 stroke-width="2"
4470 stroke-linecap="round"
4471 >
4472 <path d="M3 3 L9 9 M9 3 L3 9" />
4473 </svg>
4474 `;
4475 }
4476 const UNCATEGORIZED_SLUG = "uncategorized";
4477 const UNCATEGORIZED_DEFAULT_ID = 1;
4478 function _isUncategorized(item) {
4479 if (item.id === UNCATEGORIZED_DEFAULT_ID) {
4480 return true;
4481 }
4482 return (item.name || "").toLowerCase() === UNCATEGORIZED_SLUG;
4483 }
4484 const _WpdCategoryPicker = class _WpdCategoryPicker extends Component {
4485 constructor() {
4486 super(...arguments);
4487 this._items = [];
4488 this._value = [];
4489 this._query = "";
4490 this._collapsed = /* @__PURE__ */ new Set();
4491 this._focusedRow = -1;
4492 this._creatingValues = /* @__PURE__ */ new Map();
4493 this._creatingPending = /* @__PURE__ */ new Set();
4494 this._onCellClick = (e) => {
4495 const target = e.target;
4496 if (target?.closest(".wpd-cat-node")) {
4497 return;
4498 }
4499 if (this.isOpen) {
4500 return;
4501 }
4502 const disabled = this.disabled !== null;
4503 const readonly = this.readonly !== null;
4504 if (disabled || readonly) {
4505 return;
4506 }
4507 this.openPicker();
4508 };
4509 this._onDocPointerDown = (e) => {
4510 if (!this.isOpen) {
4511 return;
4512 }
4513 const path = e.composedPath();
4514 if (path.includes(this)) {
4515 return;
4516 }
4517 this.closePicker();
4518 };
4519 this._onLayoutChange = () => {
4520 if (!this.isOpen) {
4521 return;
4522 }
4523 this.closePicker();
4524 };
4525 this._onDocKeydown = (e) => {
4526 if (this.isOpen && e.key === "Escape") {
4527 e.preventDefault();
4528 this.closePicker();
4529 }
4530 };
4531 }
4532 get items() {
4533 return this._items;
4534 }
4535 set items(next) {
4536 this._items = Array.isArray(next) ? next.slice() : [];
4537 this.requestUpdate();
4538 }
4539 get value() {
4540 return this._value;
4541 }
4542 set value(next) {
4543 this._value = Array.isArray(next) ? next.slice() : [];
4544 this.requestUpdate();
4545 }
4546 get isOpen() {
4547 return this.open !== null;
4548 }
4549 openPicker() {
4550 if (this.isOpen) {
4551 return;
4552 }
4553 this.open = "";
4554 this._query = "";
4555 this._focusedRow = 0;
4556 this.emit("wpd-categories-open", {});
4557 queueMicrotask(() => {
4558 this._positionPopover();
4559 this._searchInput?.focus();
4560 });
4561 }
4562 closePicker() {
4563 if (!this.isOpen) {
4564 return;
4565 }
4566 this.open = null;
4567 this._query = "";
4568 this._focusedRow = -1;
4569 this.emit("wpd-categories-close", {});
4570 this.requestUpdate();
4571 }
4572 connectedCallback() {
4573 super.connectedCallback();
4574 document.addEventListener("pointerdown", this._onDocPointerDown, true);
4575 document.addEventListener("keydown", this._onDocKeydown, true);
4576 window.addEventListener("resize", this._onLayoutChange, { passive: true });
4577 window.addEventListener("scroll", this._onLayoutChange, {
4578 passive: true,
4579 capture: true
4580 });
4581 }
4582 disconnectedCallback() {
4583 document.removeEventListener("pointerdown", this._onDocPointerDown, true);
4584 document.removeEventListener("keydown", this._onDocKeydown, true);
4585 window.removeEventListener("resize", this._onLayoutChange);
4586 window.removeEventListener("scroll", this._onLayoutChange, { capture: true });
4587 }
4588 get _searchInput() {
4589 return this.shadowRoot?.querySelector(".wpd-cat__search") ?? null;
4590 }
4591 // --- Render -----------------------------------------------------------
4592 render() {
4593 const isOpen = this.isOpen;
4594 const disabled = this.disabled !== null;
4595 const readonly = this.readonly !== null;
4596 const loading = this.loading !== null;
4597 const addLabel = this["add-label"] || "Categorize";
4598 const placeholder = this.placeholder || "Search categories…";
4599 const maxVisible = Math.max(
4600 0,
4601 parseInt(
4602 this["max-visible"] || "2",
4603 10
4604 ) || 2
4605 );
4606 return html`
4607 <span class="wpd-cat" role="group">
4608 ${this._renderChipRow(maxVisible, readonly, disabled, addLabel)}
4609 ${isOpen ? this._renderPopover(placeholder, loading) : html``}
4610 </span>
4611 `;
4612 }
4613 _renderChipRow(_maxVisible, readonly, disabled, _addLabel) {
4614 const selectedItems = this._selectedItemsInOrder();
4615 if (selectedItems.length === 0) {
4616 return html`
4617 <span class="wpd-cat__chips" role="list">
4618 <span
4619 class="wpd-cat__uncategorized"
4620 title=${'Posts with no category appear as "Uncategorized" in WordPress.'}
4621 @click=${this._onCellClick}
4622 >${"Uncategorized"}</span>
4623 </span>
4624 `;
4625 }
4626 const chains = this._buildChains(selectedItems);
4627 return html`
4628 <div
4629 class="wpd-cat__chains"
4630 role="list"
4631 @click=${this._onCellClick}
4632 >
4633 ${chains.map(
4634 (chain) => this._renderChain(chain, readonly, disabled)
4635 )}
4636 </div>
4637 `;
4638 }
4639 /**
4640 * Build a `WpdCrumbSegment[]` per LEAF selection. A "leaf
4641 * selection" is a selected term that has no other selected
4642 * descendant. When the user has selected a parent AND its
4643 * children AND its grandchildren, only the deepest (leaf)
4644 * selection produces a chain — the chain itself walks
4645 * root → leaf and includes every path segment. Segments that
4646 * the user explicitly picked AND segments that just sit on the
4647 * path render the same way visually; the user's intent ("this
4648 * post is filed under Parent → Child → Grandchild") is what
4649 * gets shown, regardless of which subset of the path they
4650 * happened to tick.
4651 *
4652 * Two leaves under the same parent produce two chains; the
4653 * shared parent appears in both, which matches the user's
4654 * mental model ("filed under Tech/Web Dev/Frontend AND
4655 * Tech/Web Dev/Backend") without the ambiguity of merged-tree
4656 * visualizations.
4657 */
4658 _buildChains(selectedItems) {
4659 const byId = /* @__PURE__ */ new Map();
4660 for (const item of this._items) {
4661 byId.set(item.id, item);
4662 }
4663 const selectedIds = new Set(selectedItems.map((s) => s.id));
4664 const hasSelectedDescendant = (ancestorId) => {
4665 for (const otherId of selectedIds) {
4666 if (otherId === ancestorId) {
4667 continue;
4668 }
4669 let cursor = byId.get(otherId);
4670 let safety = 16;
4671 while (cursor && safety-- > 0) {
4672 if (cursor.parent === ancestorId) {
4673 return true;
4674 }
4675 if (!cursor.parent) {
4676 break;
4677 }
4678 cursor = byId.get(cursor.parent);
4679 }
4680 }
4681 return false;
4682 };
4683 const chainLeaves = selectedItems.filter(
4684 (item) => !hasSelectedDescendant(item.id)
4685 );
4686 const chains = [];
4687 for (const leaf of chainLeaves) {
4688 const path = [];
4689 let cursor = leaf;
4690 let safety = 16;
4691 while (cursor && safety-- > 0) {
4692 if (cursor === leaf || selectedIds.has(cursor.id)) {
4693 path.unshift(cursor);
4694 }
4695 if (!cursor.parent) {
4696 break;
4697 }
4698 cursor = byId.get(cursor.parent);
4699 }
4700 const segments = path.map((item) => ({
4701 id: item.id,
4702 name: item.name
4703 }));
4704 chains.push({ id: leaf.id, segments });
4705 }
4706 return chains;
4707 }
4708 _renderChain(chain, readonly, disabled) {
4709 const removable = !readonly && !disabled;
4710 const onRemove = (e) => {
4711 e.stopPropagation();
4712 const detail = e.detail;
4713 const startIdx = typeof detail?.index === "number" ? detail.index : chain.segments.length - 1;
4714 const idsToRemove = /* @__PURE__ */ new Set();
4715 for (const seg of chain.segments.slice(startIdx)) {
4716 if (typeof seg.id === "number") {
4717 idsToRemove.add(seg.id);
4718 }
4719 }
4720 const next = this._value.filter(
4721 (id) => !idsToRemove.has(id)
4722 );
4723 if (next.length === this._value.length) {
4724 return;
4725 }
4726 this.emit("wpd-categories-change", { value: next });
4727 };
4728 const el = document.createElement("wpd-crumb-chain");
4729 el.segments = chain.segments;
4730 if (removable) {
4731 el.setAttribute("removable", "");
4732 }
4733 el.addEventListener("wpd-chain-remove", onRemove);
4734 return html`<div role="listitem">${el}</div>`;
4735 }
4736 _renderPopover(placeholder, loading) {
4737 const tree = this._buildTree();
4738 const filtered = this._filterTree(tree, this._query);
4739 const flat = this._flattenForDisplay(filtered);
4740 if (this._focusedRow >= flat.length) {
4741 this._focusedRow = flat.length > 0 ? flat.length - 1 : -1;
4742 }
4743 return html`
4744 <div class="wpd-cat__popover" role="dialog" aria-label="Choose categories">
4745 <input
4746 class="wpd-cat__search"
4747 type="text"
4748 autocomplete="off"
4749 placeholder=${placeholder}
4750 .value=${this._query}
4751 @input=${(e) => this._onSearchInput(e)}
4752 @keydown=${(e) => this._onSearchKeydown(e, flat)}
4753 />
4754 <div class="wpd-cat__tree" role="listbox" aria-multiselectable="true">
4755 ${this._renderCreateRow(0, 12, 0, "")}
4756 ${this._renderTreeBody(loading, flat)}
4757 </div>
4758 <div class="wpd-cat__footer">
4759 <span class="dashicons dashicons-info-outline" aria-hidden="true"></span>
4760 <span>
4761 Posts with no category appear as
4762 <strong>Uncategorized</strong>.
4763 </span>
4764 </div>
4765 </div>
4766 `;
4767 }
4768 _renderTreeBody(loading, flat) {
4769 if (loading) {
4770 return html`
4771 <div class="wpd-cat__loading">
4772 <span class="wpd-cat__loading-spinner" aria-hidden="true"></span>
4773 ${"Loading categories…"}
4774 </div>
4775 `;
4776 }
4777 if (flat.length === 0) {
4778 return html`
4779 <div class="wpd-cat__empty">
4780 ${this._items.length === 0 ? "No categories yet — create one in WordPress to assign." : "No matches."}
4781 </div>
4782 `;
4783 }
4784 return flat.map((entry, idx) => this._renderRow(entry, idx, flat.length));
4785 }
4786 _renderRow(entry, idx, _total) {
4787 const { node, hasChildren } = entry;
4788 const isSelected = this._value.includes(node.item.id);
4789 const isExpanded = !this._collapsed.has(node.item.id);
4790 const indent = 12 + node.depth * 16;
4791 const guide = node.depth > 0 ? node.depth * 16 : 0;
4792 const isFocused = idx === this._focusedRow;
4793 return html`
4794 <div class="wpd-cat__row-block">
4795 <div
4796 class="wpd-cat__row"
4797 role="option"
4798 aria-selected=${isSelected ? "true" : "false"}
4799 data-selected=${isSelected ? "true" : "false"}
4800 data-expanded=${isExpanded ? "true" : "false"}
4801 data-focused=${isFocused ? "true" : "false"}
4802 data-row-id=${String(node.item.id)}
4803 style=${`--wpd-cat-row-indent: ${indent}px; --wpd-cat-guide-width: ${guide}px;`}
4804 @mouseenter=${() => {
4805 this._focusedRow = idx;
4806 this.requestUpdate();
4807 }}
4808 @click=${(e) => {
4809 e.preventDefault();
4810 this._toggleSelection(node.item.id);
4811 }}
4812 >
4813 ${hasChildren ? html`<button
4814 type="button"
4815 class="wpd-cat__expander"
4816 aria-label=${isExpanded ? "Collapse" : "Expand"}
4817 @click=${(e) => {
4818 e.stopPropagation();
4819 this._toggleExpand(node.item.id);
4820 }}
4821 >${_iconCaretRight()}</button>` : html`<span class="wpd-cat__expander wpd-cat__expander--placeholder" aria-hidden="true">${_iconCaretRight()}</span>`}
4822 <span class="wpd-cat__check" aria-hidden="true">${_iconCheck()}</span>
4823 <span class="wpd-cat__label">${this._highlight(node.item.name, this._query)}</span>
4824 ${_isUncategorized(node.item) ? html`` : html`<button
4825 type="button"
4826 class="wpd-cat__delete"
4827 aria-label=${`Delete ${node.item.name}`}
4828 title=${`Delete ${node.item.name}`}
4829 @click=${(e) => this._onDeleteClick(e, node.item)}
4830 >${_iconCrossSmall()}</button>`}
4831 </div>
4832 ${isExpanded && !_isUncategorized(node.item) ? this._renderCreateRow(
4833 node.item.id,
4834 12 + (node.depth + 1) * 16,
4835 (node.depth + 1) * 16,
4836 node.item.name
4837 ) : html``}
4838 </div>
4839 `;
4840 }
4841 /**
4842 * Render an always-visible inline create-input. One sits at the
4843 * top of the popover (parentId 0 = create a root category) and
4844 * one sits beneath every visible row (create a child of that
4845 * row). Indent + guide-line align the child input with where the
4846 * new term will appear in the tree, so the user reads "this
4847 * input creates a sibling of the children below".
4848 *
4849 * The "+" submit button lives inside the input chrome; pressing
4850 * it (or Enter) emits `wpd-categories-create`. Esc clears the
4851 * field. While the consumer is processing the create REST call,
4852 * the field disables and a spinner replaces the submit button.
4853 */
4854 _renderCreateRow(parentId, indent, guide, parentName) {
4855 const value = this._creatingValues.get(parentId) ?? "";
4856 const pending = this._creatingPending.has(parentId);
4857 const placeholder = parentId === 0 ? "Add new category…" : `Add child of "${parentName}"…`;
4858 return html`
4859 <div
4860 class="wpd-cat__create-row"
4861 style=${`--wpd-cat-row-indent: ${indent}px; --wpd-cat-guide-width: ${guide}px;`}
4862 @click=${(e) => e.stopPropagation()}
4863 >
4864 <div class="wpd-cat__create-wrap">
4865 <input
4866 class="wpd-cat__create-input"
4867 type="text"
4868 autocomplete="off"
4869 spellcheck="false"
4870 placeholder=${placeholder}
4871 aria-label=${placeholder}
4872 .value=${value}
4873 ?disabled=${pending}
4874 @input=${(e) => this._onCreateInput(e, parentId)}
4875 @keydown=${(e) => this._onCreateKeydown(e, parentId)}
4876 />
4877 ${pending ? html`<span class="wpd-cat__create-spinner" aria-hidden="true"></span>` : html`<button
4878 type="button"
4879 class="wpd-cat__create-submit"
4880 aria-label=${parentId === 0 ? "Create category" : `Create child of ${parentName}`}
4881 ?disabled=${value.trim().length === 0}
4882 @click=${(e) => {
4883 e.stopPropagation();
4884 this._submitCreate(parentId);
4885 }}
4886 >${_iconPlusSmall()}</button>`}
4887 </div>
4888 </div>
4889 `;
4890 }
4891 _onCreateInput(e, parentId) {
4892 const value = e.target.value;
4893 if (value === "") {
4894 this._creatingValues.delete(parentId);
4895 } else {
4896 this._creatingValues.set(parentId, value);
4897 }
4898 this.requestUpdate();
4899 }
4900 _onCreateKeydown(e, parentId) {
4901 if (e.key === "Escape") {
4902 e.preventDefault();
4903 this._creatingValues.delete(parentId);
4904 this.requestUpdate();
4905 return;
4906 }
4907 if (e.key === "Enter") {
4908 e.preventDefault();
4909 this._submitCreate(parentId);
4910 }
4911 }
4912 _submitCreate(parentId) {
4913 const name = (this._creatingValues.get(parentId) ?? "").trim();
4914 if (!name || this._creatingPending.has(parentId)) {
4915 return;
4916 }
4917 this._creatingPending.add(parentId);
4918 this.requestUpdate();
4919 this.emit("wpd-categories-create", { name, parent: parentId });
4920 }
4921 /**
4922 * Public API — call after a successful create-handler run to
4923 * clear the inline input for that parent. Consumers usually
4924 * mutate `items` + `value` first (so the new term appears + is
4925 * selected), then call `endCreating( parent )` to clear the
4926 * field.
4927 *
4928 * @param parent The parent id used in the create event detail
4929 * (`0` for a root-level create).
4930 *
4931 * @public
4932 */
4933 endCreating(parent = 0) {
4934 this._creatingPending.delete(parent);
4935 this._creatingValues.delete(parent);
4936 this.requestUpdate();
4937 }
4938 /**
4939 * Public API — call from a consumer's catch path when the
4940 * create REST request fails. Keeps the typed text intact so the
4941 * user can retry with the same name; only the pending flag
4942 * clears.
4943 *
4944 * @param parent The parent id used in the create event detail.
4945 * @param _error Reserved for future use (e.g. surfacing the
4946 * error in the input chrome).
4947 *
4948 * @public
4949 */
4950 failCreating(parent = 0, _error) {
4951 this._creatingPending.delete(parent);
4952 this.requestUpdate();
4953 }
4954 // --- Tree helpers ----------------------------------------------------
4955 _buildTree() {
4956 const byId = /* @__PURE__ */ new Map();
4957 for (const item of this._items) {
4958 byId.set(item.id, { item, children: [], depth: 0 });
4959 }
4960 const roots = [];
4961 for (const node of byId.values()) {
4962 const parentId = node.item.parent;
4963 if (parentId && byId.has(parentId)) {
4964 const parentNode = byId.get(parentId);
4965 parentNode.children.push(node);
4966 } else {
4967 roots.push(node);
4968 }
4969 }
4970 const setDepth = (node, depth) => {
4971 node.depth = depth;
4972 for (const child of node.children) {
4973 setDepth(child, depth + 1);
4974 }
4975 };
4976 for (const root of roots) {
4977 setDepth(root, 0);
4978 }
4979 const sortRecursive = (nodes) => {
4980 nodes.sort((a, b) => {
4981 const aUncat = _isUncategorized(a.item);
4982 const bUncat = _isUncategorized(b.item);
4983 if (aUncat !== bUncat) {
4984 return aUncat ? -1 : 1;
4985 }
4986 return a.item.name.localeCompare(b.item.name);
4987 });
4988 for (const n of nodes) {
4989 sortRecursive(n.children);
4990 }
4991 };
4992 sortRecursive(roots);
4993 return roots;
4994 }
4995 _filterTree(tree, query) {
4996 const trimmed = query.trim().toLowerCase();
4997 if (!trimmed) {
4998 return tree;
4999 }
5000 const matches = (node) => {
5001 const ownMatch = node.item.name.toLowerCase().includes(trimmed);
5002 if (ownMatch) {
5003 return {
5004 item: node.item,
5005 children: node.children.slice(),
5006 depth: node.depth
5007 };
5008 }
5009 const childrenMatched = node.children.map(matches).filter((n) => n !== null);
5010 if (childrenMatched.length > 0) {
5011 return {
5012 item: node.item,
5013 children: childrenMatched,
5014 depth: node.depth
5015 };
5016 }
5017 return null;
5018 };
5019 return tree.map(matches).filter((n) => n !== null);
5020 }
5021 _flattenForDisplay(tree) {
5022 const out = [];
5023 const isSearching = this._query.trim() !== "";
5024 const walk = (nodes) => {
5025 for (const node of nodes) {
5026 out.push({
5027 node,
5028 visible: true,
5029 hasChildren: node.children.length > 0
5030 });
5031 const collapsed = this._collapsed.has(node.item.id) && !isSearching;
5032 if (!collapsed && node.children.length > 0) {
5033 walk(node.children);
5034 }
5035 }
5036 };
5037 walk(tree);
5038 return out;
5039 }
5040 _selectedItemsInOrder() {
5041 const byId = /* @__PURE__ */ new Map();
5042 for (const item of this._items) {
5043 byId.set(item.id, item);
5044 }
5045 const real = [];
5046 const uncatItems = [];
5047 for (const id of this._value) {
5048 const item = byId.get(id);
5049 if (!item) {
5050 continue;
5051 }
5052 if (item.name.toLowerCase() === UNCATEGORIZED_SLUG || item.id === 1) {
5053 uncatItems.push(item);
5054 } else {
5055 real.push(item);
5056 }
5057 }
5058 if (real.length > 0) {
5059 return real;
5060 }
5061 return uncatItems.length > 0 ? [] : real;
5062 }
5063 _highlight(label, query) {
5064 const trimmed = query.trim();
5065 if (!trimmed) {
5066 return label;
5067 }
5068 const lower = label.toLowerCase();
5069 const needle = trimmed.toLowerCase();
5070 const idx = lower.indexOf(needle);
5071 if (idx === -1) {
5072 return label;
5073 }
5074 return html`${label.slice(0, idx)}<span class="wpd-cat__match"
5075 >${label.slice(idx, idx + trimmed.length)}</span
5076 >${label.slice(idx + trimmed.length)}`;
5077 }
5078 // --- Mutations -------------------------------------------------------
5079 _toggleSelection(id) {
5080 const next = this._value.includes(id) ? this._value.filter((v) => v !== id) : [...this._value, id];
5081 this.emit("wpd-categories-change", { value: next });
5082 }
5083 _onDeleteClick(e, item) {
5084 e.stopPropagation();
5085 e.preventDefault();
5086 this.emit("wpd-categories-delete", { id: item.id, name: item.name });
5087 }
5088 _toggleExpand(id) {
5089 if (this._collapsed.has(id)) {
5090 this._collapsed.delete(id);
5091 } else {
5092 this._collapsed.add(id);
5093 }
5094 this.requestUpdate();
5095 }
5096 _onSearchInput(e) {
5097 this._query = e.target.value;
5098 this._focusedRow = 0;
5099 this.requestUpdate();
5100 }
5101 _onSearchKeydown(e, flat) {
5102 switch (e.key) {
5103 case "ArrowDown": {
5104 if (flat.length === 0) {
5105 return;
5106 }
5107 e.preventDefault();
5108 this._focusedRow = this._focusedRow + 1 >= flat.length ? 0 : this._focusedRow + 1;
5109 this.requestUpdate();
5110 this._scrollFocusedIntoView();
5111 return;
5112 }
5113 case "ArrowUp": {
5114 if (flat.length === 0) {
5115 return;
5116 }
5117 e.preventDefault();
5118 this._focusedRow = this._focusedRow <= 0 ? flat.length - 1 : this._focusedRow - 1;
5119 this.requestUpdate();
5120 this._scrollFocusedIntoView();
5121 return;
5122 }
5123 case "ArrowRight": {
5124 if (this._focusedRow < 0 || this._focusedRow >= flat.length) {
5125 return;
5126 }
5127 const entry = flat[this._focusedRow];
5128 if (entry.hasChildren && this._collapsed.has(entry.node.item.id)) {
5129 e.preventDefault();
5130 this._toggleExpand(entry.node.item.id);
5131 }
5132 return;
5133 }
5134 case "ArrowLeft": {
5135 if (this._focusedRow < 0 || this._focusedRow >= flat.length) {
5136 return;
5137 }
5138 const entry = flat[this._focusedRow];
5139 if (entry.hasChildren && !this._collapsed.has(entry.node.item.id)) {
5140 e.preventDefault();
5141 this._toggleExpand(entry.node.item.id);
5142 }
5143 return;
5144 }
5145 case "Enter":
5146 case " ": {
5147 if (this._focusedRow < 0 || this._focusedRow >= flat.length) {
5148 return;
5149 }
5150 e.preventDefault();
5151 const entry = flat[this._focusedRow];
5152 this._toggleSelection(entry.node.item.id);
5153 return;
5154 }
5155 case "Escape": {
5156 e.preventDefault();
5157 this.closePicker();
5158 }
5159 }
5160 }
5161 _scrollFocusedIntoView() {
5162 queueMicrotask(() => {
5163 const tree = this.shadowRoot?.querySelector(".wpd-cat__tree");
5164 if (!tree) {
5165 return;
5166 }
5167 const row = tree.querySelector(
5168 `.wpd-cat__row[data-focused="true"]`
5169 );
5170 if (!row) {
5171 return;
5172 }
5173 const rRect = row.getBoundingClientRect();
5174 const tRect = tree.getBoundingClientRect();
5175 if (rRect.top < tRect.top) {
5176 row.scrollIntoView({ block: "nearest" });
5177 } else if (rRect.bottom > tRect.bottom) {
5178 row.scrollIntoView({ block: "nearest" });
5179 }
5180 });
5181 }
5182 /**
5183 * Anchor the `position: fixed` popover to the trigger button.
5184 * Flips up when the popover would overflow the viewport bottom,
5185 * right-aligns when it would overflow the right edge. Runs on
5186 * every open after the popover has rendered (so we can read its
5187 * actual measured size, not a guess).
5188 *
5189 * Why fixed-positioning: the table cell scrolls inside
5190 * `<wpd-table>`'s shadow DOM, which has its own
5191 * `overflow: auto`. An `absolute` popover anchored to the cell
5192 * would be clipped by both the cell scroll AND the table
5193 * scroll. Fixed positioning escapes every ancestor's overflow
5194 * and lands the popover wherever we tell it relative to the
5195 * viewport.
5196 */
5197 _positionPopover() {
5198 const popover = this.shadowRoot?.querySelector(
5199 ".wpd-cat__popover"
5200 );
5201 if (!popover) {
5202 return;
5203 }
5204 const anchorRect = this.getBoundingClientRect();
5205 const popRect = popover.getBoundingClientRect();
5206 const viewportW = window.innerWidth;
5207 const viewportH = window.innerHeight;
5208 const margin = 8;
5209 let top = anchorRect.bottom + 4;
5210 const overflowBottom = top + popRect.height + margin > viewportH;
5211 const fitsAbove = anchorRect.top - 4 - popRect.height >= margin;
5212 if (overflowBottom && fitsAbove) {
5213 top = anchorRect.top - 4 - popRect.height;
5214 } else if (overflowBottom) {
5215 top = Math.max(margin, viewportH - popRect.height - margin);
5216 }
5217 let left = anchorRect.left;
5218 if (left + popRect.width + margin > viewportW) {
5219 left = anchorRect.right - popRect.width;
5220 }
5221 left = Math.max(
5222 margin,
5223 Math.min(left, viewportW - popRect.width - margin)
5224 );
5225 popover.style.top = `${top}px`;
5226 popover.style.left = `${left}px`;
5227 }
5228 };
5229 _WpdCategoryPicker.props = [
5230 "placeholder",
5231 "add-label",
5232 "disabled",
5233 "readonly",
5234 "open",
5235 "loading",
5236 "max-visible"
5237 ];
5238 _WpdCategoryPicker.styles = [styles$5];
5239 _WpdCategoryPicker.help = {
5240 title: "Category picker",
5241 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.',
5242 status: "experimental",
5243 since: "0.8.0",
5244 props: [
5245 {
5246 name: "placeholder",
5247 type: "string",
5248 default: "Search categories…",
5249 description: "Native placeholder for the picker search input."
5250 },
5251 {
5252 name: "add-label",
5253 type: "string",
5254 default: "Categorize",
5255 description: "Currently inert — labeled the dedicated trigger button, which was replaced by the click-to-open cell. Parsed but unused."
5256 },
5257 {
5258 name: "disabled",
5259 type: "boolean attribute",
5260 description: "Disables every interactive surface."
5261 },
5262 {
5263 name: "readonly",
5264 type: "boolean attribute",
5265 description: "Prevents opening the picker and hides the per-segment remove buttons on the crumb chains."
5266 },
5267 {
5268 name: "open",
5269 type: "boolean attribute",
5270 description: "Two-way reflected: present while the picker popover is open. Setting it externally opens / closes the popover."
5271 },
5272 {
5273 name: "loading",
5274 type: "boolean attribute",
5275 description: 'Show a "Loading categories…" spinner inside the popover. Use while the consumer is fetching the term list.'
5276 },
5277 {
5278 name: "max-visible",
5279 type: "integer (string)",
5280 default: "2",
5281 description: 'Currently inert — configured the "+N" overflow chip, which was replaced by the crumb-chain rendering. Parsed but unused.'
5282 }
5283 ],
5284 events: [
5285 {
5286 name: "wpd-categories-change",
5287 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.",
5288 detail: "{ value: number[] }"
5289 },
5290 {
5291 name: "wpd-categories-open",
5292 description: "Fires when the popover opens.",
5293 detail: "{}"
5294 },
5295 {
5296 name: "wpd-categories-close",
5297 description: "Fires when the popover closes.",
5298 detail: "{}"
5299 },
5300 {
5301 name: "wpd-categories-create",
5302 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.",
5303 detail: "{ name: string; parent: number }"
5304 },
5305 {
5306 name: "wpd-categories-delete",
5307 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`).",
5308 detail: "{ id: number; name: string }"
5309 }
5310 ],
5311 example: html`
5312 <wpd-category-picker placeholder="Search categories…"></wpd-category-picker>
5313 `
5314 };
5315 let WpdCategoryPicker = _WpdCategoryPicker;
5316 defineComponent("wpd-category-picker", WpdCategoryPicker);
5317 function _iconCaretRight() {
5318 return html`
5319 <svg
5320 viewBox="0 0 12 12"
5321 width="8"
5322 height="8"
5323 aria-hidden="true"
5324 focusable="false"
5325 fill="none"
5326 stroke="currentColor"
5327 stroke-width="2"
5328 stroke-linecap="round"
5329 stroke-linejoin="round"
5330 >
5331 <path d="M5 3 L8 6 L5 9" />
5332 </svg>
5333 `;
5334 }
5335 function _iconPlusSmall() {
5336 return html`
5337 <svg
5338 viewBox="0 0 12 12"
5339 width="11"
5340 height="11"
5341 aria-hidden="true"
5342 focusable="false"
5343 fill="none"
5344 stroke="currentColor"
5345 stroke-width="2"
5346 stroke-linecap="round"
5347 >
5348 <path d="M6 3 L6 9 M3 6 L9 6" />
5349 </svg>
5350 `;
5351 }
5352 function _iconCheck() {
5353 return html`
5354 <svg
5355 viewBox="0 0 12 12"
5356 aria-hidden="true"
5357 focusable="false"
5358 fill="none"
5359 stroke="currentColor"
5360 stroke-width="2"
5361 stroke-linecap="round"
5362 stroke-linejoin="round"
5363 >
5364 <path d="M2.5 6 L5 8.5 L9.5 4" />
5365 </svg>
5366 `;
5367 }
5368 function _iconCrossSmall() {
5369 return html`
5370 <svg
5371 viewBox="0 0 12 12"
5372 aria-hidden="true"
5373 focusable="false"
5374 fill="none"
5375 stroke="currentColor"
5376 stroke-width="2"
5377 stroke-linecap="round"
5378 >
5379 <path d="M3 3 L9 9 M9 3 L3 9" />
5380 </svg>
5381 `;
5382 }
5383 function hashTitleToHue(input) {
5384 if (!input) {
5385 return 214;
5386 }
5387 let hash = 5381;
5388 for (let i = 0; i < input.length; i++) {
5389 hash = Math.imul(hash, 33) + input.charCodeAt(i);
5390 }
5391 return (hash % 360 + 360) % 360;
5392 }
5393 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}}`;
5394 const SIZE_MAP = {
5395 xs: 20,
5396 sm: 24,
5397 md: 40,
5398 lg: 64,
5399 xl: 96
5400 };
5401 const VALID_PRESENCE = /* @__PURE__ */ new Set(["online", "inactive", "offline"]);
5402 const _WpdAvatar = class _WpdAvatar extends Component {
5403 constructor() {
5404 super(...arguments);
5405 this._presenceHandler = null;
5406 this._imgFailed = false;
5407 this._onPointerMove = null;
5408 this._onPointerEnter = null;
5409 this._onPointerLeave = null;
5410 this._tiltRaf = 0;
5411 this._pendingTiltX = "0deg";
5412 this._pendingTiltY = "0deg";
5413 this._pendingGlareX = "50%";
5414 this._pendingGlareY = "50%";
5415 }
5416 connectedCallback() {
5417 super.connectedCallback();
5418 this._maybeAttachPresenceListener();
5419 this._attachHoverEffect();
5420 }
5421 disconnectedCallback() {
5422 if (this._presenceHandler) {
5423 document.removeEventListener(
5424 "desktop-mode-presence-changed",
5425 this._presenceHandler
5426 );
5427 this._presenceHandler = null;
5428 }
5429 this._detachHoverEffect();
5430 }
5431 attributeChangedCallback(name, oldValue, newValue) {
5432 super.attributeChangedCallback(name, oldValue, newValue);
5433 if (name === "src") {
5434 this._imgFailed = false;
5435 }
5436 if (name === "user-id" || name === "presence") {
5437 this._maybeAttachPresenceListener();
5438 }
5439 }
5440 render() {
5441 const src = this._attr("src");
5442 const name = this._attr("name") || "";
5443 const altRaw = this._attr("alt");
5444 const alt = altRaw !== null ? altRaw : name;
5445 const sizeRaw = this._attr("size");
5446 const size = this._resolveSize(sizeRaw);
5447 const presence = this._presenceForRender();
5448 const clickable = this._attr("clickable") !== null;
5449 this.style.setProperty("--wpd-avatar-size", `${size}px`);
5450 const initialsBg = src && !this._imgFailed ? "" : this._initialsBg(name);
5451 const inner = src && !this._imgFailed ? html`<img
5452 src=${src}
5453 alt=${alt}
5454 @error=${() => this._onImgError()}
5455 loading="lazy"
5456 />` : this._initials(name);
5457 const dot = presence ? html`<span
5458 class=${`wpd-avatar__dot wpd-avatar__dot--${presence}`}
5459 aria-label=${this._presenceLabel(presence)}
5460 ></span>` : html``;
5461 if (clickable) {
5462 return html`
5463 <button
5464 type="button"
5465 class="wpd-avatar__tile"
5466 aria-label=${alt || "User"}
5467 style=${initialsBg ? `background:${initialsBg};` : ""}
5468 @click=${(e) => this._onClick(e)}
5469 >${inner}</button>
5470 ${dot}
5471 `;
5472 }
5473 return html`
5474 <div
5475 class="wpd-avatar__tile"
5476 role="img"
5477 aria-label=${alt || "User"}
5478 style=${initialsBg ? `background:${initialsBg};` : ""}
5479 >${inner}</div>
5480 ${dot}
5481 `;
5482 }
5483 _attr(name) {
5484 return this.getAttribute(name);
5485 }
5486 _resolveSize(raw) {
5487 if (!raw) {
5488 return 32;
5489 }
5490 if (raw in SIZE_MAP) {
5491 return SIZE_MAP[raw];
5492 }
5493 const n = Number(raw);
5494 return Number.isFinite(n) && n > 0 ? n : 32;
5495 }
5496 _initials(name) {
5497 const trimmed = name.trim();
5498 if (!trimmed) {
5499 return "?";
5500 }
5501 return Array.from(trimmed)[0]?.toUpperCase() ?? "?";
5502 }
5503 _initialsBg(name) {
5504 const hue = hashTitleToHue(name);
5505 return `linear-gradient(135deg, hsl(${hue} 62% 55%), hsl(${(hue + 24) % 360} 58% 42%))`;
5506 }
5507 _presenceForRender() {
5508 const raw = this._attr("presence");
5509 if (raw && VALID_PRESENCE.has(raw)) {
5510 return raw;
5511 }
5512 return null;
5513 }
5514 _presenceLabel(p) {
5515 switch (p) {
5516 case "online":
5517 return "Online";
5518 case "inactive":
5519 return "Inactive";
5520 case "offline":
5521 return "Offline";
5522 }
5523 }
5524 _onImgError() {
5525 this._imgFailed = true;
5526 this.requestUpdate();
5527 }
5528 _onClick(e) {
5529 const userId = this._attr("user-id");
5530 const detail = {
5531 userId: userId !== null ? Number(userId) || null : null,
5532 originalEvent: e
5533 };
5534 this.emit("wpd-avatar-click", detail);
5535 }
5536 /**
5537 * Wire up the pointer-driven tilt + glare. Listens on the host so
5538 * one set of bindings covers both the clickable `<button>` and
5539 * the decorative `<div>` rendering branches. The actual math
5540 * runs in `_handlePointerMove`; this method just owns the
5541 * bind/unbind plumbing.
5542 *
5543 * Bails entirely when `prefers-reduced-motion: reduce` is set —
5544 * the CSS has its own `@media` guard for the visual layer, but
5545 * skipping the JS too saves the per-event work for users who
5546 * won't benefit from it.
5547 */
5548 _attachHoverEffect() {
5549 const reduceMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
5550 if (reduceMotion) {
5551 return;
5552 }
5553 this._onPointerEnter = () => {
5554 this.style.setProperty("--wpd-avatar-hover", "1");
5555 };
5556 this._onPointerLeave = () => {
5557 this.style.setProperty("--wpd-avatar-hover", "0");
5558 this._pendingTiltX = "0deg";
5559 this._pendingTiltY = "0deg";
5560 this._pendingGlareX = "50%";
5561 this._pendingGlareY = "50%";
5562 this._flushTilt();
5563 };
5564 this._onPointerMove = (e) => {
5565 const rect = this.getBoundingClientRect();
5566 if (rect.width === 0 || rect.height === 0) {
5567 return;
5568 }
5569 const nx = (e.clientX - rect.left) / rect.width - 0.5;
5570 const ny = (e.clientY - rect.top) / rect.height - 0.5;
5571 const MAX = 14;
5572 this._pendingTiltY = `${(nx * MAX).toFixed(2)}deg`;
5573 this._pendingTiltX = `${(-ny * MAX).toFixed(2)}deg`;
5574 const gx = Math.max(0, Math.min(100, (nx + 0.5) * 100));
5575 const gy = Math.max(0, Math.min(100, (ny + 0.5) * 100));
5576 this._pendingGlareX = `${gx.toFixed(1)}%`;
5577 this._pendingGlareY = `${gy.toFixed(1)}%`;
5578 if (!this._tiltRaf) {
5579 this._tiltRaf = requestAnimationFrame(() => this._flushTilt());
5580 }
5581 };
5582 this.addEventListener("pointerenter", this._onPointerEnter);
5583 this.addEventListener("pointerleave", this._onPointerLeave);
5584 this.addEventListener("pointermove", this._onPointerMove);
5585 }
5586 _flushTilt() {
5587 this._tiltRaf = 0;
5588 this.style.setProperty("--wpd-avatar-tilt-x", this._pendingTiltX);
5589 this.style.setProperty("--wpd-avatar-tilt-y", this._pendingTiltY);
5590 this.style.setProperty("--wpd-avatar-glare-x", this._pendingGlareX);
5591 this.style.setProperty("--wpd-avatar-glare-y", this._pendingGlareY);
5592 }
5593 _detachHoverEffect() {
5594 if (this._onPointerMove) {
5595 this.removeEventListener("pointermove", this._onPointerMove);
5596 this._onPointerMove = null;
5597 }
5598 if (this._onPointerEnter) {
5599 this.removeEventListener("pointerenter", this._onPointerEnter);
5600 this._onPointerEnter = null;
5601 }
5602 if (this._onPointerLeave) {
5603 this.removeEventListener("pointerleave", this._onPointerLeave);
5604 this._onPointerLeave = null;
5605 }
5606 if (this._tiltRaf) {
5607 cancelAnimationFrame(this._tiltRaf);
5608 this._tiltRaf = 0;
5609 }
5610 }
5611 _maybeAttachPresenceListener() {
5612 const userId = this._attr("user-id");
5613 const explicit = this._attr("presence");
5614 const wantsListener = !!userId && !explicit;
5615 if (wantsListener && !this._presenceHandler) {
5616 this._presenceHandler = (e) => {
5617 const detail = e.detail;
5618 if (!detail) {
5619 return;
5620 }
5621 if (String(detail.userId) !== String(userId)) {
5622 return;
5623 }
5624 if (detail.newStatus && VALID_PRESENCE.has(detail.newStatus)) {
5625 this.setAttribute("presence", detail.newStatus);
5626 }
5627 };
5628 document.addEventListener(
5629 "desktop-mode-presence-changed",
5630 this._presenceHandler
5631 );
5632 } else if (!wantsListener && this._presenceHandler) {
5633 document.removeEventListener(
5634 "desktop-mode-presence-changed",
5635 this._presenceHandler
5636 );
5637 this._presenceHandler = null;
5638 }
5639 }
5640 };
5641 _WpdAvatar.props = ["src", "alt", "name", "size", "presence", "userId", "clickable"];
5642 _WpdAvatar.styles = [avatarStyles];
5643 _WpdAvatar.help = {
5644 title: "Avatar",
5645 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.",
5646 status: "stable",
5647 since: "0.6.0",
5648 props: [
5649 { name: "src", type: "string", description: "Image URL. Falls back to initials when empty or load fails." },
5650 { name: "alt", type: "string", description: "Alt text for the image. Defaults to `name` when omitted." },
5651 { name: "name", type: "string", description: "Used for initials + hue fallback when no src." },
5652 {
5653 name: "size",
5654 type: 'number | "xs" | "sm" | "md" | "lg" | "xl"',
5655 description: "Pixel size or named preset. Default 32 (sm-ish). Sets --wpd-avatar-size."
5656 },
5657 {
5658 name: "presence",
5659 type: '"online" | "inactive" | "offline"',
5660 description: "Presence dot color. Omit for no dot."
5661 },
5662 {
5663 name: "user-id",
5664 type: "number",
5665 description: "When set AND presence is unset, auto-subscribes to desktop-mode-presence-changed and updates the dot."
5666 },
5667 {
5668 name: "clickable",
5669 type: "boolean attribute",
5670 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."
5671 }
5672 ],
5673 events: [
5674 {
5675 name: "wpd-avatar-click",
5676 description: "Fires on click when the `clickable` attribute is set. Detail carries userId when set.",
5677 detail: "{ userId: number | null }"
5678 }
5679 ],
5680 cssProps: [
5681 { name: "--wpd-avatar-size", description: "Tile size in any CSS length. Set automatically by the size attribute." },
5682 { name: "--wpd-avatar-dot-ring", description: "Background color used as the dot ring (matches surrounding panel by default)." }
5683 ],
5684 example: html`
5685 <wpd-avatar name="Daniel" size="40" presence="online"></wpd-avatar>
5686 `
5687 };
5688 let WpdAvatar = _WpdAvatar;
5689 defineComponent("wpd-avatar", WpdAvatar);
5690 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 )}`;
5691 const optionStyles = css`:host{display:none}`;
5692 const _WpdOption = class _WpdOption extends Component {
5693 render() {
5694 return html``;
5695 }
5696 };
5697 _WpdOption.props = ["value", "disabled"];
5698 _WpdOption.styles = [optionStyles];
5699 _WpdOption.help = {
5700 title: "Option",
5701 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>.",
5702 status: "stable",
5703 since: "0.5.0",
5704 props: [
5705 {
5706 name: "value",
5707 type: "string",
5708 description: "Option identifier read by the parent <wpd-select>."
5709 },
5710 {
5711 name: "disabled",
5712 type: "boolean attribute",
5713 description: "Renders the option disabled in the parent <select>."
5714 }
5715 ],
5716 slots: [
5717 { name: "(default)", description: "Label text read from textContent." }
5718 ]
5719 };
5720 let WpdOption = _WpdOption;
5721 defineComponent("wpd-option", WpdOption);
5722 const _WpdSelect = class _WpdSelect extends Component {
5723 constructor() {
5724 super(...arguments);
5725 this._optionObserver = null;
5726 }
5727 /**
5728 * Declarative item-list setter. Replaces the existing
5729 * `<wpd-option>` children with a fresh set; preserves `value`
5730 * when it still matches, otherwise clears to the placeholder.
5731 *
5732 * Same shape as the setter on `<wpd-segmented>` so callers can
5733 * swap tag names (segmented ↔ select) without touching the
5734 * populate code when an option list outgrows the pill bar.
5735 *
5736 * ```js
5737 * select.items = [
5738 * { value: 'eur', label: 'Euro' },
5739 * { value: 'usd', label: 'US Dollar' },
5740 * ];
5741 * ```
5742 *
5743 * @since 0.5.0
5744 */
5745 set items(list) {
5746 const existing = this.querySelectorAll(":scope > wpd-option");
5747 for (const el of Array.from(existing)) {
5748 el.remove();
5749 }
5750 for (const item of list) {
5751 const opt = document.createElement("wpd-option");
5752 opt.setAttribute("value", item.value);
5753 opt.textContent = item.label;
5754 this.appendChild(opt);
5755 }
5756 const current = this.value;
5757 const stillValid = current !== null && list.some((i) => i.value === current);
5758 if (!stillValid && list.length > 0) {
5759 this.value = list[0].value;
5760 }
5761 this.requestUpdate();
5762 }
5763 connectedCallback() {
5764 super.connectedCallback();
5765 ensureAutoId(this);
5766 this._optionObserver = new MutationObserver(() => this.requestUpdate());
5767 this._optionObserver.observe(this, {
5768 childList: true,
5769 subtree: true,
5770 attributes: true,
5771 attributeFilter: ["value", "disabled"],
5772 characterData: true
5773 });
5774 }
5775 disconnectedCallback() {
5776 this._optionObserver?.disconnect();
5777 this._optionObserver = null;
5778 }
5779 render() {
5780 const label = this.label || "";
5781 const current = this.value;
5782 const placeholder = this.placeholder || "";
5783 const disabled = this.disabled !== null;
5784 const name = this.name || "";
5785 if (label) {
5786 this.setAttribute("aria-label", label);
5787 } else {
5788 this.removeAttribute("aria-label");
5789 }
5790 const selectAriaLabel = label || placeholder;
5791 const options = this._readOptions();
5792 const hostId = this.id || "wpd-unnamed";
5793 const selectId = `${hostId}__input`;
5794 return html`
5795 ${label ? html`<label
5796 class="wpd-select__label"
5797 for=${selectId}
5798 >${label}</label>` : html``}
5799 <span class="wpd-select__wrap">
5800 <select
5801 id=${selectId}
5802 ?disabled=${disabled}
5803 aria-label=${selectAriaLabel}
5804 name=${name}
5805 @change=${(e) => this._onChange(e)}
5806 >
5807 ${placeholder && !current ? html`<option value="" disabled selected>
5808 ${placeholder}
5809 </option>` : html``}
5810 ${options.map(
5811 (o) => html`
5812 <option
5813 value=${o.value}
5814 ?disabled=${o.disabled}
5815 ?selected=${o.value === current}
5816 >
5817 ${o.label}
5818 </option>
5819 `
5820 )}
5821 </select>
5822 <!--
5823 Inline SVG — the previous dashicons-classed span
5824 never painted because the global Dashicons font
5825 stylesheet cannot cross the shadow-root boundary.
5826 An inline SVG lives inside the shadow tree, inherits
5827 currentColor via the stroke attribute, and needs
5828 no external CSS.
5829 -->
5830 <svg
5831 class="wpd-select__chevron"
5832 viewBox="0 0 12 12"
5833 width="12"
5834 height="12"
5835 aria-hidden="true"
5836 focusable="false"
5837 >
5838 <path
5839 d="M3 5l3 3 3-3"
5840 stroke="currentColor"
5841 stroke-width="1.4"
5842 stroke-linecap="round"
5843 stroke-linejoin="round"
5844 fill="none"
5845 ></path>
5846 </svg>
5847 </span>
5848 `;
5849 }
5850 _readOptions() {
5851 const out = [];
5852 const children = this.querySelectorAll(":scope > wpd-option");
5853 for (const child of Array.from(children)) {
5854 const value = child.getAttribute("value");
5855 if (value === null) {
5856 continue;
5857 }
5858 out.push({
5859 value,
5860 label: (child.textContent || value).trim(),
5861 disabled: child.hasAttribute("disabled")
5862 });
5863 }
5864 return out;
5865 }
5866 _onChange(e) {
5867 const sel = e.target;
5868 const next = sel.value;
5869 this.value = next;
5870 this.emit("wpd-pick", { value: next });
5871 }
5872 };
5873 _WpdSelect.props = [
5874 "value",
5875 "label",
5876 "placeholder",
5877 "disabled",
5878 "name"
5879 ];
5880 _WpdSelect.styles = [selectStyles];
5881 _WpdSelect.help = {
5882 title: "Select",
5883 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.",
5884 status: "stable",
5885 since: "0.5.0",
5886 props: [
5887 {
5888 name: "value",
5889 type: "string",
5890 description: "Currently selected option value."
5891 },
5892 {
5893 name: "label",
5894 type: "string",
5895 description: "Visible label rendered above the select and forwarded to the native control as aria-label."
5896 },
5897 {
5898 name: "placeholder",
5899 type: "string",
5900 description: "Disabled leading option shown when no value is set."
5901 },
5902 {
5903 name: "disabled",
5904 type: "boolean attribute",
5905 description: "Disables the native select and dims the chrome."
5906 },
5907 {
5908 name: "name",
5909 type: "string",
5910 description: "Forwarded to the native <select name=…> for form submission."
5911 }
5912 ],
5913 slots: [
5914 { name: "(default)", description: '<wpd-option value="…"> children.' }
5915 ],
5916 events: [
5917 {
5918 name: "wpd-pick",
5919 description: "Fires when the user picks a new option.",
5920 detail: "{ value: string }"
5921 }
5922 ],
5923 cssProps: [
5924 { name: "--desktop-mode-text", description: "Label + value colour." },
5925 { name: "--desktop-mode-muted", description: "Placeholder + chevron colour." }
5926 ],
5927 example: html`
5928 <wpd-select value="eur" label="Currency">
5929 <wpd-option value="eur">Euro</wpd-option>
5930 <wpd-option value="usd">US Dollar</wpd-option>
5931 <wpd-option value="jpy">Japanese Yen</wpd-option>
5932 </wpd-select>
5933 `
5934 };
5935 let WpdSelect = _WpdSelect;
5936 defineComponent("wpd-select", WpdSelect);
5937 const multiselectStyles = css`
5938 :host {
5939 display: flex;
5940 flex-direction: column;
5941 gap: 4px;
5942 font-size: 13px;
5943 color: var( --desktop-mode-text, #1d2327 );
5944 min-width: 0;
5945 }
5946
5947 :host( [ hidden ] ) {
5948 display: none;
5949 }
5950
5951 .wpd-multiselect__label {
5952 font-size: 12px;
5953 color: var( --desktop-mode-muted, #646970 );
5954 }
5955
5956 .wpd-multiselect__trigger {
5957 appearance: none;
5958 display: inline-flex;
5959 align-items: center;
5960 justify-content: space-between;
5961 gap: 8px;
5962 width: 100%;
5963 min-width: 0;
5964 padding: 7px 12px 7px 12px;
5965 background: rgba( 0, 0, 0, 0.05 );
5966 border: 1px solid transparent;
5967 border-radius: 7px;
5968 font: inherit;
5969 font-size: 13px;
5970 color: var( --desktop-mode-text, #1d2327 );
5971 cursor: pointer;
5972 text-align: start;
5973 transition: background-color 0.12s ease, border-color 0.12s ease,
5974 box-shadow 0.12s ease;
5975 }
5976
5977 .wpd-multiselect__trigger:hover {
5978 background: rgba( 0, 0, 0, 0.08 );
5979 }
5980
5981 .wpd-multiselect__trigger:focus-visible {
5982 outline: none;
5983 border-color: var( --wp-admin-theme-color, #2271b1 );
5984 box-shadow: 0 0 0 1px var( --wp-admin-theme-color, #2271b1 );
5985 }
5986
5987 .wpd-multiselect__trigger:disabled {
5988 opacity: 0.5;
5989 cursor: not-allowed;
5990 }
5991
5992 .wpd-multiselect__trigger[ data-active='true' ] {
5993 color: var( --wp-admin-theme-color, #2271b1 );
5994 font-weight: 600;
5995 }
5996
5997 .wpd-multiselect__summary {
5998 flex: 1 1 auto;
5999 min-width: 0;
6000 overflow: hidden;
6001 text-overflow: ellipsis;
6002 white-space: nowrap;
6003 }
6004
6005 .wpd-multiselect__chevron {
6006 color: var( --desktop-mode-muted, #646970 );
6007 flex-shrink: 0;
6008 transition: color 0.12s ease, transform 0.18s ease;
6009 }
6010
6011 .wpd-multiselect__trigger:hover .wpd-multiselect__chevron,
6012 .wpd-multiselect__trigger:focus-visible .wpd-multiselect__chevron {
6013 color: var( --desktop-mode-text, #1d2327 );
6014 }
6015
6016 :host( [ open ] ) .wpd-multiselect__chevron {
6017 transform: rotate( 180deg );
6018 }
6019 `;
6020 function _installGlobalPopoverStyles() {
6021 const STYLE_ID = "wpd-multiselect-popover-styles";
6022 if (document.getElementById(STYLE_ID)) {
6023 return;
6024 }
6025 const style = document.createElement("style");
6026 style.id = STYLE_ID;
6027 style.textContent = `
6028 .wpd-multiselect__popover {
6029 position: fixed;
6030 z-index: 100000;
6031 max-height: 320px;
6032 overflow-y: auto;
6033 min-width: 200px;
6034 padding: 4px 0;
6035 background: var( --desktop-mode-window-bg, #fff );
6036 color: var( --desktop-mode-text, #1d2327 );
6037 border: 1px solid var( --desktop-mode-window-border, #c3c4c7 );
6038 border-radius: 8px;
6039 box-shadow: 0 8px 28px rgba( 0, 0, 0, 0.18 );
6040 font: inherit;
6041 font-size: 13px;
6042 }
6043
6044 .wpd-multiselect__clear {
6045 display: block;
6046 width: 100%;
6047 padding: 6px 12px;
6048 font: inherit;
6049 font-size: 11px;
6050 font-weight: 600;
6051 letter-spacing: 0.04em;
6052 text-transform: uppercase;
6053 text-align: start;
6054 border: 0;
6055 border-bottom: 1px solid var( --desktop-mode-window-border, #dcdcde );
6056 background: transparent;
6057 color: var( --wp-admin-theme-color, #2271b1 );
6058 cursor: pointer;
6059 }
6060
6061 .wpd-multiselect__clear:hover {
6062 background: color-mix(
6063 in srgb,
6064 var( --wp-admin-theme-color, #2271b1 ) 10%,
6065 transparent
6066 );
6067 }
6068
6069 .wpd-multiselect__option {
6070 display: flex;
6071 align-items: center;
6072 gap: 8px;
6073 padding: 6px 12px;
6074 cursor: pointer;
6075 user-select: none;
6076 }
6077
6078 .wpd-multiselect__option:hover {
6079 background: rgba( 0, 0, 0, 0.05 );
6080 }
6081
6082 .wpd-multiselect__option[ data-disabled='true' ] {
6083 opacity: 0.5;
6084 cursor: not-allowed;
6085 }
6086
6087 .wpd-multiselect__option > span {
6088 flex: 1 1 auto;
6089 min-width: 0;
6090 overflow: hidden;
6091 text-overflow: ellipsis;
6092 white-space: nowrap;
6093 }
6094
6095 .wpd-multiselect__option > input[ type='checkbox' ] {
6096 margin: 0;
6097 flex-shrink: 0;
6098 accent-color: var( --wp-admin-theme-color, #2271b1 );
6099 }
6100
6101 .wpd-multiselect__empty {
6102 padding: 8px 12px;
6103 color: var( --desktop-mode-muted, #646970 );
6104 font-style: italic;
6105 }
6106
6107 .wpd-multiselect__loading {
6108 display: flex;
6109 align-items: center;
6110 gap: 8px;
6111 padding: 8px 12px;
6112 color: var( --desktop-mode-muted, #646970 );
6113 font-size: 12px;
6114 }
6115
6116 .wpd-multiselect__spinner {
6117 display: inline-block;
6118 width: 12px;
6119 height: 12px;
6120 border-radius: 50%;
6121 border: 2px solid currentColor;
6122 border-top-color: transparent;
6123 animation: wpd-multiselect-spin 0.8s linear infinite;
6124 }
6125
6126 @keyframes wpd-multiselect-spin {
6127 to { transform: rotate( 360deg ); }
6128 }
6129 `;
6130 document.head.appendChild(style);
6131 }
6132 if (typeof document !== "undefined") {
6133 _installGlobalPopoverStyles();
6134 }
6135 const _WpdMultiselect = class _WpdMultiselect extends Component {
6136 constructor() {
6137 super(...arguments);
6138 this._optionObserver = null;
6139 this._popover = null;
6140 this._teardownOpen = null;
6141 this._hasMore = false;
6142 this._loadingMore = false;
6143 }
6144 /**
6145 * Declarative item-list setter. Replaces the existing
6146 * `<wpd-option>` children with a fresh set; preserves any values
6147 * that still match.
6148 *
6149 * @since 0.8.0
6150 */
6151 set items(list) {
6152 const existing = this.querySelectorAll(":scope > wpd-option");
6153 for (const el of Array.from(existing)) {
6154 el.remove();
6155 }
6156 for (const item of list) {
6157 const opt = document.createElement("wpd-option");
6158 opt.setAttribute("value", item.value);
6159 opt.textContent = item.label;
6160 this.appendChild(opt);
6161 }
6162 this._loadingMore = false;
6163 const validSet = new Set(list.map((i) => i.value));
6164 const next = this._readValues().filter((v) => validSet.has(v));
6165 this._writeValueAttribute(next);
6166 this.requestUpdate();
6167 this._refreshPopover();
6168 }
6169 /** Programmatic getter for the parsed selection. */
6170 get values() {
6171 return this._readValues();
6172 }
6173 /**
6174 * Programmatic setter — accepts an array of values; serialises
6175 * back to the `value` attribute as a comma-joined string.
6176 */
6177 set values(next) {
6178 const arr = Array.isArray(next) ? next.map((v) => String(v)).filter((v) => v !== "") : [];
6179 this._writeValueAttribute(arr);
6180 this.requestUpdate();
6181 this._refreshPopover();
6182 }
6183 /** Whether more pages are available (drives the load-more emit). */
6184 get hasMore() {
6185 return this._hasMore;
6186 }
6187 set hasMore(next) {
6188 this._hasMore = !!next;
6189 this._refreshPopover();
6190 }
6191 /**
6192 * Whether a load-more fetch is currently in flight. While true,
6193 * the popover paints a small spinner row and suppresses further
6194 * `wpd-multiselect-load-more` emits.
6195 */
6196 get loadingMore() {
6197 return this._loadingMore;
6198 }
6199 set loadingMore(next) {
6200 this._loadingMore = !!next;
6201 this._refreshPopover();
6202 }
6203 /**
6204 * Append additional options without dropping any already in the
6205 * tree. Used by infinite-scroll consumers — call when the next
6206 * page lands, then set `loadingMore = false` and update
6207 * `hasMore` based on whether more pages remain.
6208 *
6209 * @since 0.8.0
6210 */
6211 appendItems(more) {
6212 this._loadingMore = false;
6213 if (!more || more.length === 0) {
6214 this._refreshPopover();
6215 return;
6216 }
6217 const existing = new Set(
6218 Array.from(this.querySelectorAll(":scope > wpd-option")).map(
6219 (el) => el.getAttribute("value")
6220 )
6221 );
6222 for (const item of more) {
6223 if (existing.has(item.value)) {
6224 continue;
6225 }
6226 const opt = document.createElement("wpd-option");
6227 opt.setAttribute("value", item.value);
6228 opt.textContent = item.label;
6229 this.appendChild(opt);
6230 }
6231 this.requestUpdate();
6232 this._refreshPopover();
6233 }
6234 connectedCallback() {
6235 super.connectedCallback();
6236 ensureAutoId(this);
6237 this._optionObserver = new MutationObserver(() => {
6238 this.requestUpdate();
6239 this._refreshPopover();
6240 });
6241 this._optionObserver.observe(this, {
6242 childList: true,
6243 subtree: true,
6244 attributes: true,
6245 attributeFilter: ["value", "disabled"],
6246 characterData: true
6247 });
6248 }
6249 disconnectedCallback() {
6250 this._optionObserver?.disconnect();
6251 this._optionObserver = null;
6252 this._closePopover();
6253 }
6254 render() {
6255 const label = this.label || "";
6256 const placeholder = this.placeholder || "All";
6257 const disabled = this.disabled !== null;
6258 if (label) {
6259 this.setAttribute("aria-label", label);
6260 } else {
6261 this.removeAttribute("aria-label");
6262 }
6263 const triggerAriaLabel = label || placeholder;
6264 const summary = this._summarize(placeholder);
6265 const isActive = this._readValues().length > 0;
6266 const hostId = this.id || "wpd-unnamed";
6267 const triggerId = `${hostId}__trigger`;
6268 return html`
6269 ${label ? html`<label
6270 class="wpd-multiselect__label"
6271 for=${triggerId}
6272 >${label}</label>` : html``}
6273 <button
6274 id=${triggerId}
6275 type="button"
6276 class="wpd-multiselect__trigger"
6277 aria-haspopup="listbox"
6278 aria-expanded=${this._isOpen() ? "true" : "false"}
6279 aria-label=${triggerAriaLabel}
6280 ?disabled=${disabled}
6281 data-active=${isActive ? "true" : "false"}
6282 @click=${(e) => this._onTriggerClick(e)}
6283 >
6284 <span class="wpd-multiselect__summary">${summary}</span>
6285 <svg
6286 class="wpd-multiselect__chevron"
6287 viewBox="0 0 12 12"
6288 width="12"
6289 height="12"
6290 aria-hidden="true"
6291 focusable="false"
6292 >
6293 <path
6294 d="M3 5l3 3 3-3"
6295 stroke="currentColor"
6296 stroke-width="1.4"
6297 stroke-linecap="round"
6298 stroke-linejoin="round"
6299 fill="none"
6300 />
6301 </svg>
6302 </button>
6303 `;
6304 }
6305 _readOptions() {
6306 const out = [];
6307 const children = this.querySelectorAll(":scope > wpd-option");
6308 for (const child of Array.from(children)) {
6309 const value = child.getAttribute("value");
6310 if (value === null) {
6311 continue;
6312 }
6313 out.push({
6314 value,
6315 label: (child.textContent || value).trim(),
6316 disabled: child.hasAttribute("disabled")
6317 });
6318 }
6319 return out;
6320 }
6321 _readValues() {
6322 const raw = this.value ?? "";
6323 return raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
6324 }
6325 _writeValueAttribute(vals) {
6326 const next = vals.join(",");
6327 this.value = next;
6328 }
6329 _summarize(placeholder) {
6330 const vals = this._readValues();
6331 if (vals.length === 0) {
6332 return placeholder;
6333 }
6334 const opts = this._readOptions();
6335 const byValue = new Map(opts.map((o) => [o.value, o.label]));
6336 if (vals.length === 1) {
6337 return byValue.get(vals[0]) ?? vals[0];
6338 }
6339 return `${vals.length} selected`;
6340 }
6341 _isOpen() {
6342 return this.open !== null;
6343 }
6344 _onTriggerClick(e) {
6345 e.stopPropagation();
6346 e.preventDefault();
6347 const disabled = this.disabled !== null;
6348 if (disabled) {
6349 return;
6350 }
6351 if (this._popover) {
6352 this._closePopover();
6353 } else {
6354 this._openPopover();
6355 }
6356 }
6357 _openPopover() {
6358 if (this._popover) {
6359 return;
6360 }
6361 const popover = document.createElement("div");
6362 popover.className = "wpd-multiselect__popover";
6363 popover.setAttribute("role", "listbox");
6364 popover.setAttribute("aria-multiselectable", "true");
6365 popover.style.setProperty(
6366 "--wp-admin-theme-color",
6367 getComputedStyle(this).getPropertyValue(
6368 "--wp-admin-theme-color"
6369 ) || "#2271b1"
6370 );
6371 document.body.appendChild(popover);
6372 this._popover = popover;
6373 this._refreshPopover();
6374 this._placePopover();
6375 const onDocPointer = (ev) => {
6376 const target = ev.target;
6377 if (!target) {
6378 return;
6379 }
6380 const trigger = this.shadowRoot?.querySelector(
6381 ".wpd-multiselect__trigger"
6382 );
6383 if (popover.contains(target)) {
6384 return;
6385 }
6386 if (trigger && trigger.contains(target)) {
6387 return;
6388 }
6389 this._closePopover();
6390 };
6391 const onKey = (ev) => {
6392 if (ev.key === "Escape") {
6393 ev.stopPropagation();
6394 this._closePopover();
6395 const trigger = this.shadowRoot?.querySelector(
6396 ".wpd-multiselect__trigger"
6397 );
6398 trigger?.focus();
6399 }
6400 };
6401 const onResizeScroll = () => this._placePopover();
6402 const onPopoverScroll = () => {
6403 if (!this._hasMore || this._loadingMore) {
6404 return;
6405 }
6406 const sh = popover.scrollHeight;
6407 const ch = popover.clientHeight;
6408 const st = popover.scrollTop;
6409 if (sh - (st + ch) < 64) {
6410 this.emit("wpd-multiselect-load-more", {});
6411 }
6412 };
6413 setTimeout(() => {
6414 document.addEventListener("pointerdown", onDocPointer, true);
6415 }, 0);
6416 document.addEventListener("keydown", onKey, true);
6417 window.addEventListener("resize", onResizeScroll);
6418 window.addEventListener("scroll", onResizeScroll, true);
6419 popover.addEventListener("scroll", onPopoverScroll);
6420 this._teardownOpen = () => {
6421 document.removeEventListener("pointerdown", onDocPointer, true);
6422 document.removeEventListener("keydown", onKey, true);
6423 window.removeEventListener("resize", onResizeScroll);
6424 window.removeEventListener("scroll", onResizeScroll, true);
6425 popover.removeEventListener("scroll", onPopoverScroll);
6426 };
6427 this.setAttribute("open", "");
6428 this.requestUpdate();
6429 this.emit("wpd-multiselect-open", {});
6430 }
6431 _closePopover() {
6432 if (this._teardownOpen) {
6433 this._teardownOpen();
6434 this._teardownOpen = null;
6435 }
6436 if (this._popover) {
6437 this._popover.remove();
6438 this._popover = null;
6439 this.removeAttribute("open");
6440 this.requestUpdate();
6441 this.emit("wpd-multiselect-close", {});
6442 }
6443 }
6444 _refreshPopover() {
6445 const popover = this._popover;
6446 if (!popover) {
6447 return;
6448 }
6449 const options = this._readOptions();
6450 const selected = new Set(this._readValues());
6451 popover.replaceChildren();
6452 if (options.length === 0) {
6453 const empty = document.createElement("div");
6454 empty.className = "wpd-multiselect__empty";
6455 empty.textContent = "No options";
6456 popover.appendChild(empty);
6457 return;
6458 }
6459 if (selected.size > 0) {
6460 const clear = document.createElement("button");
6461 clear.type = "button";
6462 clear.className = "wpd-multiselect__clear";
6463 clear.textContent = "Clear";
6464 clear.addEventListener("click", (e) => {
6465 e.preventDefault();
6466 e.stopPropagation();
6467 this._writeValueAttribute([]);
6468 this.requestUpdate();
6469 this._refreshPopover();
6470 this._emitPick();
6471 });
6472 popover.appendChild(clear);
6473 }
6474 for (const opt of options) {
6475 const row = document.createElement("label");
6476 row.className = "wpd-multiselect__option";
6477 row.setAttribute("role", "option");
6478 row.setAttribute(
6479 "aria-selected",
6480 selected.has(opt.value) ? "true" : "false"
6481 );
6482 if (opt.disabled) {
6483 row.setAttribute("aria-disabled", "true");
6484 row.dataset.disabled = "true";
6485 }
6486 const cb = document.createElement("input");
6487 cb.type = "checkbox";
6488 cb.checked = selected.has(opt.value);
6489 cb.disabled = opt.disabled;
6490 cb.addEventListener("change", () => {
6491 const cur = new Set(this._readValues());
6492 if (cb.checked) {
6493 cur.add(opt.value);
6494 } else {
6495 cur.delete(opt.value);
6496 }
6497 const ordered = options.map((o) => o.value).filter((v) => cur.has(v));
6498 this._writeValueAttribute(ordered);
6499 row.setAttribute(
6500 "aria-selected",
6501 cb.checked ? "true" : "false"
6502 );
6503 this.requestUpdate();
6504 this._refreshPopover();
6505 this._emitPick();
6506 });
6507 const labelText = document.createElement("span");
6508 labelText.textContent = opt.label;
6509 row.appendChild(cb);
6510 row.appendChild(labelText);
6511 popover.appendChild(row);
6512 }
6513 if (this._loadingMore) {
6514 const loading = document.createElement("div");
6515 loading.className = "wpd-multiselect__loading";
6516 const spinner = document.createElement("span");
6517 spinner.className = "wpd-multiselect__spinner";
6518 spinner.setAttribute("aria-hidden", "true");
6519 const text = document.createElement("span");
6520 text.textContent = "Loading…";
6521 loading.appendChild(spinner);
6522 loading.appendChild(text);
6523 popover.appendChild(loading);
6524 }
6525 }
6526 _placePopover() {
6527 const popover = this._popover;
6528 const trigger = this.shadowRoot?.querySelector(
6529 ".wpd-multiselect__trigger"
6530 );
6531 if (!popover || !trigger) {
6532 return;
6533 }
6534 const rect = trigger.getBoundingClientRect();
6535 const vw = window.innerWidth;
6536 const vh = window.innerHeight;
6537 const minW = Math.max(rect.width, 200);
6538 popover.style.minWidth = `${minW}px`;
6539 let left = rect.left;
6540 if (left + minW > vw - 8) {
6541 left = Math.max(8, vw - minW - 8);
6542 }
6543 popover.style.left = `${left}px`;
6544 popover.style.top = `${rect.bottom + 4}px`;
6545 const popH = popover.offsetHeight || 200;
6546 if (rect.bottom + 4 + popH > vh - 8) {
6547 popover.style.top = `${Math.max(8, rect.top - popH - 4)}px`;
6548 }
6549 }
6550 _emitPick() {
6551 const values = this._readValues();
6552 this.emit("wpd-pick", {
6553 value: values.join(","),
6554 values
6555 });
6556 }
6557 };
6558 _WpdMultiselect.props = [
6559 "value",
6560 "label",
6561 "placeholder",
6562 "disabled",
6563 "name",
6564 "open"
6565 ];
6566 _WpdMultiselect.styles = [multiselectStyles];
6567 _WpdMultiselect.help = {
6568 title: "Multi-select",
6569 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.",
6570 status: "experimental",
6571 since: "0.8.0",
6572 props: [
6573 {
6574 name: "value",
6575 type: "string (comma-joined ids)",
6576 description: 'Currently selected option values, joined by commas (e.g. "1,4"). Empty string means no selection.'
6577 },
6578 {
6579 name: "label",
6580 type: "string",
6581 description: "Visible label rendered above the trigger and forwarded as aria-label to the trigger button."
6582 },
6583 {
6584 name: "placeholder",
6585 type: "string",
6586 description: 'Trigger summary when no option is checked. Defaults to "All".'
6587 },
6588 {
6589 name: "disabled",
6590 type: "boolean attribute",
6591 description: "Disables the trigger and dims the chrome."
6592 },
6593 {
6594 name: "name",
6595 type: "string",
6596 description: "Reserved for HTML form submission; not yet wired to a form field."
6597 },
6598 {
6599 name: "open",
6600 type: "boolean attribute",
6601 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."
6602 }
6603 ],
6604 slots: [
6605 { name: "(default)", description: '<wpd-option value="…"> children.' }
6606 ],
6607 events: [
6608 {
6609 name: "wpd-pick",
6610 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.",
6611 detail: "{ value: string; values: string[] }"
6612 },
6613 {
6614 name: "wpd-multiselect-open",
6615 description: "Fires when the popover opens.",
6616 detail: "{}"
6617 },
6618 {
6619 name: "wpd-multiselect-close",
6620 description: "Fires when the popover closes.",
6621 detail: "{}"
6622 },
6623 {
6624 name: "wpd-multiselect-load-more",
6625 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.",
6626 detail: "{}"
6627 }
6628 ],
6629 cssProps: [
6630 { name: "--desktop-mode-text", description: "Label + value colour." },
6631 { name: "--desktop-mode-muted", description: "Placeholder + chevron colour." }
6632 ],
6633 example: html`
6634 <wpd-multiselect value="1,4" label="Authors">
6635 <wpd-option value="1">Daniel</wpd-option>
6636 <wpd-option value="4">Peter</wpd-option>
6637 <wpd-option value="9">Pat</wpd-option>
6638 </wpd-multiselect>
6639 `
6640 };
6641 let WpdMultiselect = _WpdMultiselect;
6642 defineComponent("wpd-multiselect", WpdMultiselect);
6643 const styles$3 = css`:host{display:inline;color:inherit;font:inherit}`;
6644 const _instances = /* @__PURE__ */ new Set();
6645 let _ticker = null;
6646 const TICK_INTERVAL_MS = 3e4;
6647 function startTicker() {
6648 if (_ticker !== null) {
6649 return;
6650 }
6651 _ticker = window.setInterval(() => {
6652 for (const i of _instances) {
6653 i.tick();
6654 }
6655 }, TICK_INTERVAL_MS);
6656 }
6657 function stopTickerIfIdle() {
6658 if (_ticker !== null && _instances.size === 0) {
6659 window.clearInterval(_ticker);
6660 _ticker = null;
6661 }
6662 }
6663 function parseDatetime(raw) {
6664 if (!raw) {
6665 return null;
6666 }
6667 const tryDate = (v) => {
6668 const d = new Date(v);
6669 return Number.isNaN(d.getTime()) ? null : d;
6670 };
6671 if (raw.includes("T") || raw.endsWith("Z")) {
6672 return tryDate(raw);
6673 }
6674 return tryDate(raw.replace(" ", "T") + "Z");
6675 }
6676 let _rtfCache = null;
6677 function getRtf() {
6678 if (!_rtfCache) {
6679 const lang = typeof navigator !== "undefined" && navigator.language || "en";
6680 _rtfCache = new Intl.RelativeTimeFormat(lang, { numeric: "auto" });
6681 }
6682 return _rtfCache;
6683 }
6684 function relativeText(date, now) {
6685 const rtf = getRtf();
6686 const diffMs = date.getTime() - now;
6687 const diffSec = Math.round(diffMs / 1e3);
6688 const abs = Math.abs;
6689 if (abs(diffSec) < 45) {
6690 return rtf.format(0, "second");
6691 }
6692 const diffMin = Math.round(diffSec / 60);
6693 if (abs(diffMin) < 45) {
6694 return rtf.format(diffMin, "minute");
6695 }
6696 const diffHour = Math.round(diffMin / 60);
6697 if (abs(diffHour) < 22) {
6698 return rtf.format(diffHour, "hour");
6699 }
6700 const diffDay = Math.round(diffHour / 24);
6701 if (abs(diffDay) < 26) {
6702 return rtf.format(diffDay, "day");
6703 }
6704 const diffMonth = Math.round(diffDay / 30);
6705 if (abs(diffMonth) < 11) {
6706 return rtf.format(diffMonth, "month");
6707 }
6708 const diffYear = Math.round(diffDay / 365);
6709 return rtf.format(diffYear, "year");
6710 }
6711 const _WpdRelativeTime = class _WpdRelativeTime extends Component {
6712 connectedCallback() {
6713 super.connectedCallback();
6714 _instances.add(this);
6715 startTicker();
6716 }
6717 disconnectedCallback() {
6718 _instances.delete(this);
6719 stopTickerIfIdle();
6720 }
6721 /** Public — the shared ticker calls this on every interval. */
6722 tick() {
6723 this.requestUpdate();
6724 }
6725 render() {
6726 const raw = this.datetime;
6727 const date = parseDatetime(raw);
6728 if (!date) {
6729 return html`<span>${raw ?? ""}</span>`;
6730 }
6731 const text = relativeText(date, Date.now());
6732 const absolute = date.toLocaleString();
6733 return html`<time datetime=${date.toISOString()} title=${absolute}
6734 >${text}</time
6735 >`;
6736 }
6737 };
6738 _WpdRelativeTime.props = ["datetime"];
6739 _WpdRelativeTime.styles = [styles$3];
6740 _WpdRelativeTime.help = {
6741 title: "Relative time",
6742 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.',
6743 status: "experimental",
6744 since: "0.6.0",
6745 props: [
6746 {
6747 name: "datetime",
6748 type: 'ISO 8601 string OR MySQL-style "Y-m-d H:i:s" (treated as UTC)',
6749 description: "The moment the relative copy is anchored to. Accepts the format WordPress hands back from `*_gmt` columns directly."
6750 }
6751 ],
6752 slots: [],
6753 cssProps: [],
6754 example: html`<wpd-relative-time
6755 datetime="${new Date(Date.now() - 1e3 * 60 * 5).toISOString()}"
6756 ></wpd-relative-time>`
6757 };
6758 let WpdRelativeTime = _WpdRelativeTime;
6759 defineComponent("wpd-relative-time", WpdRelativeTime);
6760 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}}`;
6761 const _WpdForm = class _WpdForm extends Component {
6762 constructor() {
6763 super(...arguments);
6764 this._initial = /* @__PURE__ */ new Map();
6765 this._captured = false;
6766 this._fieldChangeListener = null;
6767 this._enterSubmitListener = null;
6768 }
6769 connectedCallback() {
6770 super.connectedCallback();
6771 queueMicrotask(() => this._captureInitialValues());
6772 this._fieldChangeListener = (e) => this._onAnyFieldInput(e);
6773 this.addEventListener("wpd-input-change", this._fieldChangeListener);
6774 this.addEventListener("wpd-input-commit", this._fieldChangeListener);
6775 this.addEventListener("wpd-checkbox-change", this._fieldChangeListener);
6776 this.addEventListener("wpd-select-change", this._fieldChangeListener);
6777 this.addEventListener("change", this._fieldChangeListener);
6778 this._enterSubmitListener = () => this.submit();
6779 this.addEventListener("wpd-submit", this._enterSubmitListener);
6780 }
6781 disconnectedCallback() {
6782 if (this._fieldChangeListener) {
6783 this.removeEventListener("wpd-input-change", this._fieldChangeListener);
6784 this.removeEventListener("wpd-input-commit", this._fieldChangeListener);
6785 this.removeEventListener("wpd-checkbox-change", this._fieldChangeListener);
6786 this.removeEventListener("wpd-select-change", this._fieldChangeListener);
6787 this.removeEventListener("change", this._fieldChangeListener);
6788 this._fieldChangeListener = null;
6789 }
6790 if (this._enterSubmitListener) {
6791 this.removeEventListener("wpd-submit", this._enterSubmitListener);
6792 this._enterSubmitListener = null;
6793 }
6794 }
6795 render() {
6796 const submitLabel = this["submit-label"] || "Submit";
6797 const resetLabel = this["reset-label"] || "Reset";
6798 const error = this.error || "";
6799 const busy = this.busy !== null;
6800 const showResetRaw = this["show-reset"];
6801 const showReset = showResetRaw !== "false";
6802 return html`
6803 <div class="header" part="header">
6804 <slot name="header"></slot>
6805 </div>
6806 <div class="fields" part="fields">
6807 <slot></slot>
6808 </div>
6809 <slot name="error">
6810 ${error ? html`<p class="error" role="alert" part="error">${error}</p>` : html`<p class="error" role="alert" part="error" hidden></p>`}
6811 </slot>
6812 <footer class="footer" part="footer">
6813 <span class="footer-leading"
6814 ><slot name="footer-leading"></slot
6815 ></span>
6816 <span class="footer-actions">
6817 ${showReset ? html`<wpd-button
6818 variant="ghost"
6819 data-wpd-form-action="reset"
6820 ?disabled=${busy}
6821 @click=${() => this.reset()}
6822 >${resetLabel}</wpd-button>` : html``}
6823 <wpd-button
6824 variant="primary"
6825 data-wpd-form-action="submit"
6826 ?disabled=${busy}
6827 @click=${() => this.submit()}
6828 >
6829 ${busy ? html`<span class="busy-spinner" aria-hidden="true"></span>` : html``}
6830 ${submitLabel}
6831 </wpd-button>
6832 </span>
6833 <span class="footer-trailing"
6834 ><slot name="footer-trailing"></slot
6835 ></span>
6836 </footer>
6837 `;
6838 }
6839 // ─── Public API ──────────────────────────────────────────────────
6840 /**
6841 * Collect every named descendant's current value. Checkboxes
6842 * return `boolean`; everything else returns whatever the field
6843 * surfaces on its `value` property (or attribute as fallback).
6844 */
6845 getValues() {
6846 const out = {};
6847 for (const field of this._namedFields()) {
6848 const name = field.getAttribute("name");
6849 if (!name) {
6850 continue;
6851 }
6852 out[name] = this._readField(field);
6853 }
6854 return out;
6855 }
6856 /**
6857 * Apply a partial values map to the matching named fields.
6858 * Unknown names are skipped silently (fields may not be
6859 * mounted yet).
6860 */
6861 setValues(patch) {
6862 for (const [name, value] of Object.entries(patch)) {
6863 const field = this._fieldByName(name);
6864 if (!field) {
6865 continue;
6866 }
6867 this._writeField(field, value);
6868 }
6869 }
6870 /** Toggle the busy attribute (also re-renders to refresh the spinner). */
6871 setBusy(busy) {
6872 if (busy) {
6873 this.setAttribute("busy", "");
6874 } else {
6875 this.removeAttribute("busy");
6876 }
6877 }
6878 /**
6879 * Set the top-of-form error banner. Pass `null` (or empty
6880 * string) to clear. Equivalent to setting the `error` attribute.
6881 */
6882 setError(message) {
6883 if (message) {
6884 this.setAttribute("error", message);
6885 } else {
6886 this.removeAttribute("error");
6887 }
6888 }
6889 /**
6890 * Mark a single field invalid (or clear it). Useful for
6891 * server-returned per-field errors — e.g. "username already
6892 * exists". The optional `message` is set via the field's
6893 * `error` attribute when supported (currently a no-op for
6894 * fields that don't render one — falls back to the `invalid`
6895 * highlight only).
6896 */
6897 setFieldInvalid(name, invalid = true, message = null) {
6898 const field = this._fieldByName(name);
6899 if (!field) {
6900 return;
6901 }
6902 if (invalid) {
6903 field.setAttribute("invalid", "");
6904 if (message !== null) {
6905 field.setAttribute("error", message);
6906 }
6907 } else {
6908 field.removeAttribute("invalid");
6909 field.removeAttribute("error");
6910 }
6911 }
6912 /** Clear the form-level error AND every per-field invalid mark. */
6913 clearErrors() {
6914 this.setError(null);
6915 for (const field of this._namedFields()) {
6916 field.removeAttribute("invalid");
6917 field.removeAttribute("error");
6918 }
6919 }
6920 /**
6921 * Restore every field to its initial value (the snapshot taken
6922 * at first connection). Fires `wpd-form-reset` afterwards.
6923 */
6924 reset() {
6925 this.clearErrors();
6926 for (const [name, snap] of this._initial.entries()) {
6927 const field = this._fieldByName(name);
6928 if (!field) {
6929 continue;
6930 }
6931 if (snap.checked !== null) {
6932 field.checked = snap.checked;
6933 if (snap.checked) {
6934 field.setAttribute("checked", "");
6935 } else {
6936 field.removeAttribute("checked");
6937 }
6938 continue;
6939 }
6940 this._writeField(field, snap.value);
6941 }
6942 this.dispatchEvent(
6943 new CustomEvent("wpd-form-reset", {
6944 bubbles: true,
6945 composed: true,
6946 detail: { form: this }
6947 })
6948 );
6949 }
6950 /**
6951 * Programmatic submit. Same path the submit button + Enter key
6952 * take. Runs required-field validation, then dispatches a
6953 * cancellable `wpd-form-submit`.
6954 */
6955 submit() {
6956 const failures = [];
6957 for (const field of this._namedFields()) {
6958 const name = field.getAttribute("name");
6959 if (!name) {
6960 continue;
6961 }
6962 const required = field.hasAttribute("required");
6963 if (!required) {
6964 continue;
6965 }
6966 const value = this._readField(field);
6967 const empty = value === null || value === void 0 || value === "" || Array.isArray(value) && value.length === 0;
6968 if (empty) {
6969 field.setAttribute("invalid", "");
6970 const labelAttr = field.getAttribute("label");
6971 failures.push(labelAttr || name);
6972 }
6973 }
6974 if (failures.length > 0) {
6975 const list = failures.join(", ");
6976 this.setError(`Required: ${list}`);
6977 return;
6978 }
6979 const values = this.getValues();
6980 const event = new CustomEvent("wpd-form-submit", {
6981 bubbles: true,
6982 composed: true,
6983 cancelable: true,
6984 detail: { values, form: this }
6985 });
6986 this.dispatchEvent(event);
6987 }
6988 // ─── Internals ───────────────────────────────────────────────────
6989 _captureInitialValues() {
6990 if (this._captured) {
6991 return;
6992 }
6993 const fields = this._namedFields();
6994 if (fields.length === 0) {
6995 return;
6996 }
6997 for (const field of fields) {
6998 const name = field.getAttribute("name");
6999 if (!name) {
7000 continue;
7001 }
7002 const isCheckbox = field.tagName === "WPD-CHECKBOX" || field.tagName === "WPD-CHECKBOX-LABEL" || field.tagName === "INPUT" && field.type === "checkbox";
7003 this._initial.set(name, {
7004 value: this._readField(field),
7005 checked: isCheckbox ? Boolean(field.checked) : null
7006 });
7007 }
7008 this._captured = true;
7009 }
7010 _namedFields() {
7011 return Array.from(
7012 this.querySelectorAll("[name]")
7013 );
7014 }
7015 _fieldByName(name) {
7016 const safe = typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(name) : name.replace(/["\\]/g, "\\$&");
7017 return this.querySelector(`[name="${safe}"]`);
7018 }
7019 _readField(field) {
7020 const tag = field.tagName.toUpperCase();
7021 const isCheckbox = tag === "WPD-CHECKBOX" || tag === "WPD-CHECKBOX-LABEL" || tag === "INPUT" && field.type === "checkbox";
7022 if (isCheckbox) {
7023 if (typeof field.checked === "boolean") {
7024 return field.checked;
7025 }
7026 return field.hasAttribute("checked");
7027 }
7028 if (field.value !== void 0 && field.value !== null) {
7029 return field.value;
7030 }
7031 return field.getAttribute("value") ?? "";
7032 }
7033 _writeField(field, value) {
7034 const tag = field.tagName.toUpperCase();
7035 const isCheckbox = tag === "WPD-CHECKBOX" || tag === "WPD-CHECKBOX-LABEL" || tag === "INPUT" && field.type === "checkbox";
7036 if (isCheckbox) {
7037 const next = Boolean(value);
7038 field.checked = next;
7039 if (next) {
7040 field.setAttribute("checked", "");
7041 } else {
7042 field.removeAttribute("checked");
7043 }
7044 return;
7045 }
7046 const str = value === null || value === void 0 ? "" : String(value);
7047 field.value = str;
7048 field.setAttribute("value", str);
7049 }
7050 _onAnyFieldInput(e) {
7051 const target = e.target;
7052 if (!target) {
7053 return;
7054 }
7055 const name = target.getAttribute?.("name");
7056 if (!name) {
7057 return;
7058 }
7059 this.dispatchEvent(
7060 new CustomEvent("wpd-form-input", {
7061 bubbles: true,
7062 composed: true,
7063 detail: {
7064 name,
7065 value: this._readField(target),
7066 form: this
7067 }
7068 })
7069 );
7070 if (target.hasAttribute("invalid")) {
7071 target.removeAttribute("invalid");
7072 }
7073 }
7074 };
7075 _WpdForm.props = [
7076 "submit-label",
7077 "reset-label",
7078 "error",
7079 "busy",
7080 "columns",
7081 "min-column",
7082 "show-reset",
7083 "align"
7084 ];
7085 _WpdForm.styles = [wpdFormStyles];
7086 _WpdForm.help = {
7087 title: "Form",
7088 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.",
7089 status: "experimental",
7090 since: "0.8.1",
7091 props: [
7092 {
7093 name: "submit-label",
7094 type: "string",
7095 default: "Submit",
7096 description: "Label of the primary submit button."
7097 },
7098 {
7099 name: "reset-label",
7100 type: "string",
7101 default: "Reset",
7102 description: "Label of the reset button."
7103 },
7104 {
7105 name: "error",
7106 type: "string",
7107 description: "Top-of-form error banner. Show / hide via attribute OR setError(); equivalent."
7108 },
7109 {
7110 name: "busy",
7111 type: "boolean attribute",
7112 description: "Loading state — disables the form + flashes a spinner."
7113 },
7114 {
7115 name: "columns",
7116 type: '"auto" | "1" | "2" | "3"',
7117 default: "auto",
7118 description: 'Fixed column count, or "auto" for container-query 1↔2 (or up to 3 above 760px).'
7119 },
7120 {
7121 name: "show-reset",
7122 type: "boolean attribute",
7123 default: "true",
7124 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.'
7125 },
7126 {
7127 name: "align",
7128 type: '"end" | "start" | "stretch"',
7129 default: "end",
7130 description: "Footer button alignment."
7131 }
7132 ],
7133 slots: [
7134 { name: "(default)", description: "Form fields. `[name]` descendants are auto-collected." },
7135 { name: "header", description: "Heading / lede above the fields." },
7136 { name: "error", description: "Custom error UI; replaces the default banner when slotted." },
7137 { name: "footer-leading", description: "Extras left of the action buttons." },
7138 { name: "footer-trailing", description: "Extras right of the action buttons." }
7139 ],
7140 events: [
7141 {
7142 name: "wpd-form-submit",
7143 description: "Cancellable. Fires on submit after required-field validation passes.",
7144 detail: "{ values: Record<string, unknown>, form: WpdForm }"
7145 },
7146 {
7147 name: "wpd-form-reset",
7148 description: "Fires after fields have been restored to their initial values.",
7149 detail: "{ form: WpdForm }"
7150 },
7151 {
7152 name: "wpd-form-input",
7153 description: "Bubbles every keystroke / change inside any descendant field; useful for live validation.",
7154 detail: "{ name: string, value: unknown, form: WpdForm }"
7155 }
7156 ],
7157 example: html`
7158 <wpd-form submit-label="Add user">
7159 <wpd-text-field name="username" label="Username" required></wpd-text-field>
7160 <wpd-text-field name="email" type="email" label="Email" required></wpd-text-field>
7161 <wpd-text-field name="password" label="Password" full-width></wpd-text-field>
7162 </wpd-form>
7163 `
7164 };
7165 let WpdForm = _WpdForm;
7166 defineComponent("wpd-form", WpdForm);
7167 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}`;
7168 const _WpdTextarea = class _WpdTextarea extends Component {
7169 constructor() {
7170 super(...arguments);
7171 this._textareaEl = null;
7172 }
7173 connectedCallback() {
7174 super.connectedCallback();
7175 ensureAutoId(this);
7176 }
7177 render() {
7178 const label = this._attr("label") || "";
7179 const value = this._attr("value") ?? "";
7180 const placeholder = this._attr("placeholder") || "";
7181 const disabled = this._boolAttr("disabled");
7182 const readonly = this._boolAttr("readonly");
7183 const ariaLabel = this._attr("aria-label") || label;
7184 const name = this._attr("name") || "";
7185 const rows = Number(this._attr("rows")) || 3;
7186 const maxLength = this._attr("maxlength");
7187 const minLength = this._attr("minlength");
7188 const invalid = this._boolAttr("invalid");
7189 const hostId = this.id || "wpd-unnamed";
7190 const fieldId = `${hostId}__field`;
7191 return html`
7192 ${label ? html`<label class="wpd-textarea__label" for=${fieldId}>${label}</label>` : html``}
7193 <textarea
7194 id=${fieldId}
7195 part="textarea"
7196 .value=${value}
7197 placeholder=${placeholder}
7198 ?disabled=${disabled}
7199 ?readonly=${readonly}
7200 rows=${rows}
7201 maxlength=${maxLength ?? ""}
7202 minlength=${minLength ?? ""}
7203 name=${name}
7204 aria-invalid=${invalid ? "true" : "false"}
7205 aria-label=${ariaLabel || ""}
7206 @input=${(e) => this._onInput(e)}
7207 @change=${(e) => this._onChange(e)}
7208 @keydown=${(e) => this._onKeyDown(e)}
7209 ></textarea>
7210 `;
7211 }
7212 _attr(name) {
7213 return this.getAttribute(name);
7214 }
7215 _boolAttr(name) {
7216 return this.getAttribute(name) !== null;
7217 }
7218 _onInput(e) {
7219 const ta = e.target;
7220 this._textareaEl = ta;
7221 this.setAttribute("value", ta.value);
7222 this.emit("wpd-input-change", { value: ta.value });
7223 if (this._boolAttr("auto-grow")) {
7224 this._autosize(ta);
7225 }
7226 }
7227 _onChange(e) {
7228 const ta = e.target;
7229 this.emit("wpd-input-commit", { value: ta.value });
7230 }
7231 _onKeyDown(e) {
7232 if (!this._boolAttr("submit-on-enter")) {
7233 return;
7234 }
7235 if (e.key === "Enter" && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) {
7236 e.preventDefault();
7237 const ta = e.target;
7238 this.emit("wpd-submit", { value: ta.value });
7239 }
7240 }
7241 /**
7242 * Grow the textarea height to fit content, capped at `max-rows`.
7243 * Resets to scroll-height each input then clamps; cheap because
7244 * the browser caches layout.
7245 */
7246 _autosize(ta) {
7247 const maxRows = Number(this._attr("max-rows")) || 8;
7248 const cs = window.getComputedStyle(ta);
7249 const fontSize = parseFloat(cs.fontSize) || 13;
7250 const lineHeightRaw = cs.lineHeight;
7251 const lineHeight = lineHeightRaw === "normal" ? fontSize * 1.45 : parseFloat(lineHeightRaw) || fontSize * 1.45;
7252 const paddingTop = parseFloat(cs.paddingTop) || 0;
7253 const paddingBottom = parseFloat(cs.paddingBottom) || 0;
7254 const max = lineHeight * maxRows + paddingTop + paddingBottom;
7255 ta.style.height = "auto";
7256 const next = Math.min(ta.scrollHeight, max);
7257 ta.style.height = `${next}px`;
7258 }
7259 /** Public helper for callers that programmatically set `.value` and want autosize to re-run. */
7260 refreshAutosize() {
7261 if (this._textareaEl && this._boolAttr("auto-grow")) {
7262 this._autosize(this._textareaEl);
7263 }
7264 }
7265 /** Imperatively focus the underlying textarea. */
7266 focusInput() {
7267 const root = this.shadowRoot ?? this;
7268 const ta = root.querySelector("textarea");
7269 ta?.focus();
7270 }
7271 /** Imperatively clear the value. */
7272 clear() {
7273 this.setAttribute("value", "");
7274 const root = this.shadowRoot ?? this;
7275 const ta = root.querySelector("textarea");
7276 if (ta) {
7277 ta.value = "";
7278 if (this._boolAttr("auto-grow")) {
7279 this._autosize(ta);
7280 }
7281 }
7282 }
7283 };
7284 _WpdTextarea.props = [
7285 "label",
7286 "value",
7287 "placeholder",
7288 "disabled",
7289 "readonly",
7290 "ariaLabel",
7291 "name",
7292 "rows",
7293 "maxlength",
7294 "minlength",
7295 "invalid",
7296 "autoGrow",
7297 "maxRows",
7298 "submitOnEnter"
7299 ];
7300 _WpdTextarea.styles = [textareaStyles];
7301 _WpdTextarea.help = {
7302 title: "Textarea",
7303 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).",
7304 status: "stable",
7305 since: "0.6.0",
7306 props: [
7307 { name: "label", type: "string", description: "Visible label above the textarea." },
7308 { name: "value", type: "string", description: "Current value; reflected two-way." },
7309 { name: "placeholder", type: "string", description: "Native placeholder." },
7310 { name: "disabled", type: "boolean attribute" },
7311 { name: "readonly", type: "boolean attribute" },
7312 { name: "aria-label", type: "string", description: "Accessible label when no visible label is rendered." },
7313 { name: "name", type: "string", description: "Forwarded to native textarea for form submission." },
7314 { name: "rows", type: "integer (string)", default: "3", description: "Initial visible row count." },
7315 { name: "maxlength", type: "integer (string)" },
7316 { name: "minlength", type: "integer (string)" },
7317 { name: "invalid", type: "boolean attribute", description: "Sets aria-invalid + error styling." },
7318 { name: "auto-grow", type: "boolean attribute", description: "Grows up to max-rows as the user types." },
7319 { name: "max-rows", type: "integer (string)", default: "8" },
7320 {
7321 name: "submit-on-enter",
7322 type: "boolean attribute",
7323 description: "Enter fires wpd-submit; Shift+Enter inserts a newline."
7324 }
7325 ],
7326 events: [
7327 { name: "wpd-input-change", description: "Fires on every keystroke.", detail: "{ value: string }" },
7328 { name: "wpd-input-commit", description: "Fires on blur / native change.", detail: "{ value: string }" },
7329 {
7330 name: "wpd-submit",
7331 description: "Fires on Enter (without Shift) when submit-on-enter is set.",
7332 detail: "{ value: string }"
7333 }
7334 ],
7335 example: html`
7336 <wpd-textarea label="Message" rows="3" auto-grow max-rows="8" submit-on-enter></wpd-textarea>
7337 `
7338 };
7339 let WpdTextarea = _WpdTextarea;
7340 defineComponent("wpd-textarea", WpdTextarea);
7341 let _mountsPromise = null;
7342 function loadMounts() {
7343 if (!_mountsPromise) {
7344 _mountsPromise = Promise.resolve().then(() => userEditRender);
7345 }
7346 return _mountsPromise;
7347 }
7348 class WpdUserProfile extends HTMLElement {
7349 constructor() {
7350 super(...arguments);
7351 this._initialized = false;
7352 this._mountedFor = null;
7353 }
7354 static get observedAttributes() {
7355 return ["user-id"];
7356 }
7357 connectedCallback() {
7358 if (!this._initialized) {
7359 this._initialized = true;
7360 this._renderShell();
7361 }
7362 void this._mountIfNeeded();
7363 }
7364 attributeChangedCallback(name, oldValue, newValue) {
7365 if (name !== "user-id" || oldValue === newValue) {
7366 return;
7367 }
7368 if (this._initialized) {
7369 void this._mountIfNeeded();
7370 }
7371 }
7372 /**
7373 * Build the layout shell (sidebar + main column + activity
7374 * region). Same class names as the inline Profile tab in the
7375 * Users window so the existing posts-window.css rules style
7376 * both contexts identically.
7377 */
7378 _renderShell() {
7379 this.classList.add("desktop-mode-user-profile");
7380 this.innerHTML = `
7381 <div class="desktop-mode-users__edit-layout" data-wpd-user-profile-layout>
7382 <aside class="desktop-mode-users__edit-aside" data-wpd-user-profile-aside></aside>
7383 <main class="desktop-mode-users__edit-main">
7384 <div data-wpd-user-profile-form></div>
7385 <div class="desktop-mode-users__edit-activity" data-wpd-user-profile-activity></div>
7386 </main>
7387 </div>
7388 `;
7389 }
7390 async _mountIfNeeded() {
7391 const userIdAttr = this.getAttribute("user-id");
7392 const userId = userIdAttr ? parseInt(userIdAttr, 10) : 0;
7393 if (!Number.isFinite(userId) || userId <= 0) {
7394 return;
7395 }
7396 if (userId === this._mountedFor) {
7397 return;
7398 }
7399 this._mountedFor = userId;
7400 const formHost = this.querySelector(
7401 "[data-wpd-user-profile-form]"
7402 );
7403 const asideHost = this.querySelector(
7404 "[data-wpd-user-profile-aside]"
7405 );
7406 const activityHost = this.querySelector(
7407 "[data-wpd-user-profile-activity]"
7408 );
7409 if (!formHost || !asideHost || !activityHost) {
7410 return;
7411 }
7412 const mounts = await loadMounts();
7413 void mounts.mountProfileFormAt(formHost, userId);
7414 void mounts.mountProfileAsideAt(asideHost, userId, false);
7415 void mounts.mountProfileActivityAt(activityHost, userId, false);
7416 }
7417 }
7418 if (typeof customElements !== "undefined" && !customElements.get("wpd-user-profile")) {
7419 customElements.define("wpd-user-profile", WpdUserProfile);
7420 }
7421 const FALLBACK_BASE = "http://localhost/";
7422 function joinRestUrl(restRoot, path) {
7423 const base = typeof window !== "undefined" && window.location ? window.location.href : FALLBACK_BASE;
7424 const url = new URL(restRoot, base);
7425 const trimmed = path.replace(/^\/+/, "");
7426 const queryAt = trimmed.indexOf("?");
7427 const route = queryAt === -1 ? trimmed : trimmed.slice(0, queryAt);
7428 const extraQuery = queryAt === -1 ? "" : trimmed.slice(queryAt + 1);
7429 if (url.searchParams.has("rest_route")) {
7430 const existing = url.searchParams.get("rest_route") ?? "/";
7431 const prefix = existing.endsWith("/") ? existing : existing + "/";
7432 url.searchParams.set("rest_route", prefix + route);
7433 } else {
7434 const pathname = url.pathname.endsWith("/") ? url.pathname : url.pathname + "/";
7435 url.pathname = pathname + route;
7436 }
7437 if (extraQuery) {
7438 const extras = new URLSearchParams(extraQuery);
7439 extras.forEach((value, key) => {
7440 url.searchParams.append(key, value);
7441 });
7442 }
7443 return url.toString();
7444 }
7445 function broadcastTermChange(taxonomy, action, id) {
7446 const api = window.wp?.desktop;
7447 if (api && typeof api.broadcast === "function") {
7448 api.broadcast("desktop-mode.term.changed", {
7449 source: "posts-window",
7450 taxonomy,
7451 action,
7452 id
7453 });
7454 }
7455 }
7456 function createPostsWindowClient(windowId) {
7457 const getConfig = () => {
7458 const store = window.desktopModeWindowConfig;
7459 const cfg = store ? store[windowId] : void 0;
7460 if (!cfg) {
7461 throw new Error(
7462 `[${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\`.`
7463 );
7464 }
7465 return cfg;
7466 };
7467 const shellFetch = (input, init) => {
7468 return trackedFetch(input, init, { windowId });
7469 };
7470 const request = async (url, init = {}) => {
7471 const cfg = getConfig();
7472 const response = await shellFetch(url, {
7473 ...init,
7474 credentials: "same-origin",
7475 headers: {
7476 "X-WP-Nonce": cfg.restNonce,
7477 Accept: "application/json",
7478 ...init.body ? { "Content-Type": "application/json" } : {},
7479 ...init.headers ?? {}
7480 }
7481 });
7482 if (!response.ok) {
7483 let message = `${response.status} ${response.statusText}`;
7484 try {
7485 const json = await response.json();
7486 if (json && typeof json.message === "string") {
7487 message = json.message;
7488 }
7489 } catch {
7490 }
7491 throw new Error(message);
7492 }
7493 const data = init.expectJson === false ? null : await response.json();
7494 return { data, headers: response.headers };
7495 };
7496 const fetchPosts = async (params = {}) => {
7497 const cfg = getConfig();
7498 const url = new URL(cfg.postsUrl);
7499 for (const [key, value] of Object.entries(cfg.queryArgs ?? {})) {
7500 if (typeof value === "string" && value !== "") {
7501 url.searchParams.set(key, value);
7502 }
7503 }
7504 if (params.page) {
7505 url.searchParams.set("page", String(params.page));
7506 }
7507 if (params.perPage) {
7508 url.searchParams.set("per_page", String(params.perPage));
7509 }
7510 if (params.search) {
7511 url.searchParams.set("search", params.search);
7512 }
7513 if (params.status) {
7514 url.searchParams.set("status", params.status);
7515 } else {
7516 url.searchParams.set("status", "any");
7517 }
7518 if (params.orderby) {
7519 url.searchParams.set("orderby", params.orderby);
7520 }
7521 if (params.order) {
7522 url.searchParams.set("order", params.order);
7523 }
7524 const appendIds = (key, v) => {
7525 const list = Array.isArray(v) ? v : [v];
7526 for (const id of list) {
7527 if (Number.isFinite(id) && id > 0) {
7528 url.searchParams.append(`${key}[]`, String(id));
7529 }
7530 }
7531 };
7532 if (params.author) {
7533 appendIds("author", params.author);
7534 }
7535 if (params.tag) {
7536 appendIds("tags", params.tag);
7537 }
7538 const { data, headers } = await request(
7539 url.toString(),
7540 { method: "GET" }
7541 );
7542 return {
7543 items: Array.isArray(data) ? data : [],
7544 total: parseInt(headers.get("X-WP-Total") ?? "0", 10) || 0,
7545 totalPages: parseInt(headers.get("X-WP-TotalPages") ?? "0", 10) || 0
7546 };
7547 };
7548 const trashPost = async (id) => {
7549 const cfg = getConfig();
7550 try {
7551 await request(`${cfg.postsUrl}/${id}`, {
7552 method: "DELETE"
7553 });
7554 return { id, ok: true };
7555 } catch (err) {
7556 return {
7557 id,
7558 ok: false,
7559 error: err instanceof Error ? err.message : String(err)
7560 };
7561 }
7562 };
7563 const buildEditPostUrl = (id) => {
7564 const cfg = getConfig();
7565 const sep = cfg.editPostUrlBase.includes("?") ? "&" : "?";
7566 return `${cfg.editPostUrlBase}${sep}post=${id}&action=edit`;
7567 };
7568 const searchTags = async (query, signal) => {
7569 const cfg = getConfig();
7570 const url = new URL(joinRestUrl(cfg.restRoot, "wp/v2/tags"));
7571 url.searchParams.set("per_page", "20");
7572 url.searchParams.set("_fields", "id,name,slug,count");
7573 url.searchParams.set("orderby", "count");
7574 url.searchParams.set("order", "desc");
7575 if (query) {
7576 url.searchParams.set("search", query);
7577 url.searchParams.set("orderby", "name");
7578 url.searchParams.set("order", "asc");
7579 }
7580 const { data } = await request(url.toString(), {
7581 method: "GET",
7582 signal
7583 });
7584 return Array.isArray(data) ? data : [];
7585 };
7586 const createTag = async (name) => {
7587 const cfg = getConfig();
7588 const url = joinRestUrl(cfg.restRoot, "wp/v2/tags");
7589 try {
7590 const { data } = await request(url, {
7591 method: "POST",
7592 body: JSON.stringify({ name })
7593 });
7594 broadcastTermChange("post_tag", "created", data.id);
7595 return data;
7596 } catch (err) {
7597 const message = err instanceof Error ? err.message : String(err);
7598 if (/term[\s_]?exists/i.test(message)) {
7599 const matches = await searchTags(name);
7600 const exact = matches.find(
7601 (t) => t.name.toLowerCase() === name.toLowerCase()
7602 );
7603 if (exact) {
7604 return exact;
7605 }
7606 }
7607 throw err;
7608 }
7609 };
7610 const updatePostTags = async (postId, tagIds) => {
7611 const cfg = getConfig();
7612 const url = `${cfg.postsUrl}/${postId}`;
7613 const { data } = await request(url, {
7614 method: "POST",
7615 body: JSON.stringify({ tags: tagIds })
7616 });
7617 return data;
7618 };
7619 const fetchAllCategories = async (signal) => {
7620 const cfg = getConfig();
7621 const url = new URL(joinRestUrl(cfg.restRoot, "wp/v2/categories"));
7622 url.searchParams.set("per_page", "100");
7623 url.searchParams.set("_fields", "id,name,slug,parent");
7624 url.searchParams.set("orderby", "name");
7625 url.searchParams.set("order", "asc");
7626 const { data } = await request(url.toString(), {
7627 method: "GET",
7628 signal
7629 });
7630 return Array.isArray(data) ? data : [];
7631 };
7632 const fetchAuthorOptions = async (signal) => {
7633 const cfg = getConfig();
7634 const url = new URL(joinRestUrl(cfg.restRoot, "wp/v2/users"));
7635 url.searchParams.set("per_page", "100");
7636 url.searchParams.set("who", "authors");
7637 url.searchParams.set("_fields", "id,name");
7638 url.searchParams.set("orderby", "name");
7639 url.searchParams.set("order", "asc");
7640 try {
7641 const { data } = await request(url.toString(), {
7642 method: "GET",
7643 signal
7644 });
7645 return Array.isArray(data) ? data : [];
7646 } catch {
7647 return [];
7648 }
7649 };
7650 const fetchTagOptions = async (page = 1, perPage = 50, signal) => {
7651 const cfg = getConfig();
7652 const url = new URL(joinRestUrl(cfg.restRoot, "wp/v2/tags"));
7653 url.searchParams.set("per_page", String(Math.max(1, perPage)));
7654 url.searchParams.set("page", String(Math.max(1, page)));
7655 url.searchParams.set("_fields", "id,name,count");
7656 url.searchParams.set("orderby", "count");
7657 url.searchParams.set("order", "desc");
7658 try {
7659 const { data, headers } = await request(
7660 url.toString(),
7661 { method: "GET", signal }
7662 );
7663 return {
7664 items: Array.isArray(data) ? data : [],
7665 totalPages: parseInt(headers.get("X-WP-TotalPages") ?? "0", 10) || 0
7666 };
7667 } catch {
7668 return { items: [], totalPages: 0 };
7669 }
7670 };
7671 const createCategory = async (name, parent = 0, opts = {}) => {
7672 const cfg = getConfig();
7673 const url = joinRestUrl(cfg.restRoot, "wp/v2/categories");
7674 const body = { name, parent };
7675 if (opts.slug) {
7676 body.slug = opts.slug;
7677 }
7678 if (opts.description) {
7679 body.description = opts.description;
7680 }
7681 try {
7682 const { data } = await request(url, {
7683 method: "POST",
7684 body: JSON.stringify(body)
7685 });
7686 broadcastTermChange("category", "created", data.id);
7687 return data;
7688 } catch (err) {
7689 const message = err instanceof Error ? err.message : String(err);
7690 if (/term[\s_]?exists/i.test(message)) {
7691 const matches = await fetchAllCategories();
7692 const exact = matches.find(
7693 (t) => t.name.toLowerCase() === name.toLowerCase() && t.parent === parent
7694 );
7695 if (exact) {
7696 return exact;
7697 }
7698 }
7699 throw err;
7700 }
7701 };
7702 const updatePostCategories = async (postId, categoryIds) => {
7703 const cfg = getConfig();
7704 const url = `${cfg.postsUrl}/${postId}`;
7705 const { data } = await request(
7706 url,
7707 {
7708 method: "POST",
7709 body: JSON.stringify({ categories: categoryIds })
7710 }
7711 );
7712 return data;
7713 };
7714 const fetchTerms = async (taxonomy, params = {}) => {
7715 const cfg = getConfig();
7716 const url = new URL(joinRestUrl(cfg.restRoot, `wp/v2/${taxonomy}`));
7717 url.searchParams.set("per_page", String(params.perPage ?? 50));
7718 url.searchParams.set("page", String(params.page ?? 1));
7719 url.searchParams.set(
7720 "_fields",
7721 "id,name,slug,parent,count,description,desktop_mode_count,desktop_mode_is_default"
7722 );
7723 url.searchParams.set("orderby", params.orderby ?? "name");
7724 url.searchParams.set("order", params.order ?? "asc");
7725 if (params.search) {
7726 url.searchParams.set("search", params.search);
7727 }
7728 if (typeof params.parent === "number" && params.parent >= 0) {
7729 url.searchParams.set("parent", String(params.parent));
7730 }
7731 const { data, headers } = await request(
7732 url.toString(),
7733 { method: "GET" }
7734 );
7735 const items = Array.isArray(data) ? data.map((t) => {
7736 const anyCount = t.desktop_mode_count;
7737 const isDefault = t.desktop_mode_is_default === true;
7738 return {
7739 id: t.id ?? 0,
7740 name: t.name ?? "",
7741 slug: t.slug ?? "",
7742 parent: t.parent ?? 0,
7743 count: typeof anyCount === "number" ? anyCount : t.count ?? 0,
7744 description: t.description ?? "",
7745 isDefault
7746 };
7747 }) : [];
7748 return {
7749 items,
7750 total: parseInt(headers.get("X-WP-Total") ?? "0", 10) || 0,
7751 totalPages: parseInt(headers.get("X-WP-TotalPages") ?? "0", 10) || 0
7752 };
7753 };
7754 const fetchTagCooccurrence = async (taxonomy = "tags", limit = 8) => {
7755 const cfg = getConfig();
7756 const url = new URL(
7757 joinRestUrl(
7758 cfg.restRoot,
7759 "desktop-mode/v1/tag-cooccurrence"
7760 )
7761 );
7762 url.searchParams.set(
7763 "taxonomy",
7764 taxonomy === "tags" ? "post_tag" : "category"
7765 );
7766 url.searchParams.set("limit", String(limit));
7767 const { data } = await request(url.toString(), { method: "GET" });
7768 const out = /* @__PURE__ */ new Map();
7769 const pairs = data && typeof data === "object" && !Array.isArray(data) ? data.pairs : void 0;
7770 if (!pairs) {
7771 return out;
7772 }
7773 for (const [key, neighbors] of Object.entries(pairs)) {
7774 const id = parseInt(key, 10);
7775 if (!Number.isFinite(id) || id <= 0) {
7776 continue;
7777 }
7778 const clean = [];
7779 for (const raw of neighbors) {
7780 const nid = Number(raw?.id);
7781 const sh = Number(raw?.shared);
7782 if (Number.isFinite(nid) && nid > 0 && Number.isFinite(sh) && sh > 0) {
7783 clean.push({ id: nid, shared: sh });
7784 }
7785 }
7786 if (clean.length > 0) {
7787 out.set(id, clean);
7788 }
7789 }
7790 return out;
7791 };
7792 const updateTerm = async (taxonomy, id, patch) => {
7793 const cfg = getConfig();
7794 const url = joinRestUrl(cfg.restRoot, `wp/v2/${taxonomy}/${id}`);
7795 const { data } = await request(url, {
7796 method: "POST",
7797 body: JSON.stringify(patch)
7798 });
7799 broadcastTermChange(
7800 taxonomy === "categories" ? "category" : "post_tag",
7801 "updated",
7802 id
7803 );
7804 return {
7805 id: data.id ?? id,
7806 name: data.name ?? "",
7807 slug: data.slug ?? "",
7808 parent: data.parent ?? 0,
7809 count: data.count ?? 0,
7810 description: data.description ?? "",
7811 isDefault: data.isDefault ?? false
7812 };
7813 };
7814 const deleteTerm = async (taxonomy, id) => {
7815 const cfg = getConfig();
7816 const url = new URL(
7817 joinRestUrl(cfg.restRoot, `wp/v2/${taxonomy}/${id}`)
7818 );
7819 url.searchParams.set("force", "true");
7820 await request(url.toString(), { method: "DELETE" });
7821 broadcastTermChange(
7822 taxonomy === "categories" ? "category" : "post_tag",
7823 "deleted",
7824 id
7825 );
7826 };
7827 return {
7828 windowId,
7829 getConfig,
7830 fetchPosts,
7831 trashPost,
7832 buildEditPostUrl,
7833 searchTags,
7834 createTag,
7835 updatePostTags,
7836 fetchAllCategories,
7837 fetchAuthorOptions,
7838 fetchTagOptions,
7839 createCategory,
7840 updatePostCategories,
7841 fetchTerms,
7842 fetchTagCooccurrence,
7843 updateTerm,
7844 deleteTerm
7845 };
7846 }
7847 function createUsersWindowClient(windowId = "desktop-mode-users") {
7848 const getConfig = () => {
7849 const store = window.desktopModeWindowConfig;
7850 const cfg = store?.[windowId];
7851 if (!cfg) {
7852 throw new Error(
7853 `[${windowId}] config blob is missing — was the window opened without registration? See \`includes/users-window/window.php\`.`
7854 );
7855 }
7856 return cfg;
7857 };
7858 const shellFetch = (input, init, options) => {
7859 return trackedFetch(input, init, {
7860 windowId,
7861 source: options?.source ?? "users-window/rest",
7862 silent: options?.silent
7863 });
7864 };
7865 const fetchUsers = async (params) => {
7866 const cfg = getConfig();
7867 const baseUrl = cfg.usersUrl || cfg.postsUrl;
7868 const url = new URL(baseUrl);
7869 for (const [key, value] of Object.entries(cfg.queryArgs ?? {})) {
7870 if (typeof value === "string" && value !== "") {
7871 url.searchParams.set(key, value);
7872 }
7873 }
7874 url.searchParams.set("page", String(Math.max(1, params.page)));
7875 url.searchParams.set(
7876 "per_page",
7877 String(Math.max(1, params.perPage))
7878 );
7879 if (params.search) {
7880 url.searchParams.set("search", params.search);
7881 }
7882 if (params.roles && params.roles.length > 0) {
7883 for (const r of params.roles) {
7884 url.searchParams.append("roles", r);
7885 }
7886 }
7887 if (params.orderby) {
7888 url.searchParams.set("orderby", params.orderby);
7889 }
7890 if (params.order) {
7891 url.searchParams.set("order", params.order);
7892 }
7893 const res = await shellFetch(
7894 url.toString(),
7895 {
7896 method: "GET",
7897 credentials: "same-origin",
7898 headers: {
7899 Accept: "application/json",
7900 "X-WP-Nonce": cfg.restNonce
7901 }
7902 },
7903 { source: "users-window/list" }
7904 );
7905 if (!res.ok) {
7906 throw new Error(
7907 `[users-window] list fetch failed: ${res.status}`
7908 );
7909 }
7910 const items = await res.json();
7911 const total = parseInt(res.headers.get("X-WP-Total") ?? "0", 10);
7912 const totalPages = parseInt(
7913 res.headers.get("X-WP-TotalPages") ?? "0",
7914 10
7915 );
7916 return { items, total, totalPages };
7917 };
7918 const fetchOneUser = async (id) => {
7919 const cfg = getConfig();
7920 const baseUrl = cfg.usersUrl || cfg.postsUrl;
7921 const url = new URL(`${baseUrl.replace(/\/$/, "")}/${id}`);
7922 for (const [key, value] of Object.entries(cfg.queryArgs ?? {})) {
7923 if (typeof value === "string" && value !== "") {
7924 url.searchParams.set(key, value);
7925 }
7926 }
7927 const res = await shellFetch(
7928 url.toString(),
7929 {
7930 method: "GET",
7931 credentials: "same-origin",
7932 headers: {
7933 Accept: "application/json",
7934 "X-WP-Nonce": cfg.restNonce
7935 }
7936 },
7937 { source: "users-window/one", silent: true }
7938 );
7939 if (res.status === 404) {
7940 return null;
7941 }
7942 if (!res.ok) {
7943 throw new Error(
7944 `[users-window] one fetch failed: ${res.status}`
7945 );
7946 }
7947 return await res.json();
7948 };
7949 const bulkSetRole = async (ids, role) => {
7950 const cfg = getConfig();
7951 const url = cfg.bulkRoleUrl ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/bulk-role");
7952 const res = await shellFetch(
7953 url,
7954 {
7955 method: "POST",
7956 credentials: "same-origin",
7957 headers: {
7958 "Content-Type": "application/json",
7959 "X-WP-Nonce": cfg.restNonce
7960 },
7961 body: JSON.stringify({ ids, role })
7962 },
7963 { source: "users-window/bulk-role" }
7964 );
7965 if (!res.ok) {
7966 throw new Error(
7967 `[users-window] bulk-role failed: ${res.status}`
7968 );
7969 }
7970 return await res.json();
7971 };
7972 const sendPasswordReset = async (id) => {
7973 const cfg = getConfig();
7974 const base = cfg.sendResetUrlBase ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/");
7975 const res = await shellFetch(
7976 joinRestUrl(base, `${id}/send-password-reset`),
7977 {
7978 method: "POST",
7979 credentials: "same-origin",
7980 headers: {
7981 "Content-Type": "application/json",
7982 "X-WP-Nonce": cfg.restNonce
7983 }
7984 },
7985 { source: "users-window/send-password-reset" }
7986 );
7987 if (!res.ok) {
7988 const body = await res.json().catch(() => ({}));
7989 return {
7990 ok: false,
7991 error: typeof body.code === "string" ? body.code : `http_${res.status}`
7992 };
7993 }
7994 const data = await res.json();
7995 return { ok: data.ok === true, email: data.email };
7996 };
7997 const resendWelcome = async (id) => {
7998 const cfg = getConfig();
7999 const base = cfg.sendResetUrlBase ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/");
8000 const res = await shellFetch(
8001 joinRestUrl(base, `${id}/resend-welcome`),
8002 {
8003 method: "POST",
8004 credentials: "same-origin",
8005 headers: {
8006 "Content-Type": "application/json",
8007 "X-WP-Nonce": cfg.restNonce
8008 }
8009 },
8010 { source: "users-window/resend-welcome" }
8011 );
8012 if (!res.ok) {
8013 const body = await res.json().catch(() => ({}));
8014 return {
8015 ok: false,
8016 error: typeof body.code === "string" ? body.code : `http_${res.status}`
8017 };
8018 }
8019 const data = await res.json();
8020 return { ok: data.ok === true, email: data.email };
8021 };
8022 const createUser = async (body) => {
8023 const cfg = getConfig();
8024 const url = cfg.createUserUrl ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users");
8025 const res = await shellFetch(
8026 url,
8027 {
8028 method: "POST",
8029 credentials: "same-origin",
8030 headers: {
8031 "Content-Type": "application/json",
8032 "X-WP-Nonce": cfg.restNonce
8033 },
8034 body: JSON.stringify(body)
8035 },
8036 { source: "users-window/create" }
8037 );
8038 if (!res.ok) {
8039 const data2 = await res.json().catch(() => ({}));
8040 const code = data2.code;
8041 const message = data2.message;
8042 return {
8043 ok: false,
8044 error: typeof code === "string" ? code : `http_${res.status}`,
8045 message: typeof message === "string" ? message : void 0
8046 };
8047 }
8048 const data = await res.json();
8049 return {
8050 ok: data.ok === true,
8051 user_id: data.user_id,
8052 email: data.email
8053 };
8054 };
8055 const bulkDeleteUsers = async (ids, reassign) => {
8056 const cfg = getConfig();
8057 const url = cfg.bulkDeleteUrl ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/bulk-delete");
8058 const body = { ids };
8059 if (typeof reassign === "number" && reassign > 0) {
8060 body.reassign = reassign;
8061 }
8062 const res = await shellFetch(
8063 url,
8064 {
8065 method: "POST",
8066 credentials: "same-origin",
8067 headers: {
8068 "Content-Type": "application/json",
8069 "X-WP-Nonce": cfg.restNonce
8070 },
8071 body: JSON.stringify(body)
8072 },
8073 { source: "users-window/bulk-delete" }
8074 );
8075 if (!res.ok) {
8076 throw new Error(
8077 `[users-window] bulk-delete failed: ${res.status}`
8078 );
8079 }
8080 return await res.json();
8081 };
8082 return {
8083 windowId,
8084 getConfig,
8085 fetchUsers,
8086 fetchOneUser,
8087 bulkSetRole,
8088 sendPasswordReset,
8089 resendWelcome,
8090 createUser,
8091 bulkDeleteUsers
8092 };
8093 }
8094 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}`;
8095 const _WpdButton = class _WpdButton extends Component {
8096 render() {
8097 const disabled = this.disabled !== null;
8098 const type = this.type || "button";
8099 return html`
8100 <button part="button" type=${type} ?disabled=${disabled}>
8101 <slot></slot>
8102 </button>
8103 `;
8104 }
8105 };
8106 _WpdButton.props = ["variant", "disabled", "type", "busy", "fill-cell"];
8107 _WpdButton.styles = [styles$2];
8108 _WpdButton.help = {
8109 title: "Button",
8110 summary: "Thin wrapper around <button> with consistent variant styling and a slot for the label.",
8111 status: "stable",
8112 since: "0.9.0",
8113 props: [
8114 {
8115 name: "variant",
8116 type: "'primary' | 'secondary' | 'ghost' | 'danger' | 'link'",
8117 default: "ghost",
8118 description: "Visual weight of the button. Use primary for the single attention-grabbing action per surface."
8119 },
8120 {
8121 name: "disabled",
8122 type: "boolean attribute",
8123 description: "Disable pointer + keyboard interaction and dim the chrome."
8124 },
8125 {
8126 name: "type",
8127 type: "'button' | 'submit' | 'reset'",
8128 default: "button",
8129 description: "Forwarded to the underlying native <button>."
8130 },
8131 {
8132 name: "busy",
8133 type: "boolean attribute",
8134 description: "Marks the button as in-progress (e.g., awaiting a fetch)."
8135 },
8136 {
8137 name: "fill-cell",
8138 type: "boolean attribute",
8139 description: "Grow to fill the parent flex/grid cell. Useful for tiled keypads."
8140 }
8141 ],
8142 slots: [{ name: "(default)", description: "Button label." }],
8143 parts: [{ name: "button", description: "Underlying <button> element." }],
8144 cssProps: [
8145 { name: "--wpd-button-bg", description: "Background color." },
8146 {
8147 name: "--wpd-button-bg-hover",
8148 description: "Hover wash (ghost + secondary variants)."
8149 },
8150 { name: "--wpd-button-fg", description: "Text color." },
8151 { name: "--wpd-button-border", description: "Border shorthand." },
8152 { name: "--wpd-button-border-radius", default: "6px" },
8153 { name: "--wpd-button-padding", default: "6px 12px" },
8154 {
8155 name: "--wpd-button-min-height",
8156 description: "Minimum height when fill-cell is set."
8157 }
8158 ],
8159 example: html`
8160 <wpd-cluster gap="8">
8161 <wpd-button variant="primary">Primary</wpd-button>
8162 <wpd-button variant="secondary">Secondary</wpd-button>
8163 <wpd-button variant="ghost">Ghost</wpd-button>
8164 <wpd-button variant="danger">Danger</wpd-button>
8165 <wpd-button variant="link">Link</wpd-button>
8166 </wpd-cluster>
8167 `
8168 };
8169 let WpdButton = _WpdButton;
8170 defineComponent("wpd-button", WpdButton);
8171 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}`;
8172 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}`;
8173 const _WpdSegment = class _WpdSegment extends Component {
8174 render() {
8175 this.setAttribute("role", "radio");
8176 return html`
8177 <button type="button" @click=${() => this._onPick()}>
8178 <slot></slot>
8179 </button>
8180 `;
8181 }
8182 _onPick() {
8183 this.emit("wpd-segment-pick", {
8184 value: this.value
8185 });
8186 }
8187 };
8188 _WpdSegment.props = ["value"];
8189 _WpdSegment.styles = [segmentStyles];
8190 _WpdSegment.help = {
8191 title: "Segment",
8192 summary: "Single pill inside a <wpd-segmented> group. Value identifies it for selection; aria-checked is mirrored by the parent.",
8193 status: "stable",
8194 since: "0.9.0",
8195 props: [
8196 {
8197 name: "value",
8198 type: "string",
8199 description: "Identifier this segment contributes to the parent group selection."
8200 }
8201 ],
8202 slots: [
8203 { name: "(default)", description: "Visible segment label." }
8204 ],
8205 events: [
8206 {
8207 name: "wpd-segment-pick",
8208 description: "Internal event bubbled to the parent <wpd-segmented>. Consumers should listen for wpd-pick on the group instead.",
8209 detail: "{ value: string }"
8210 }
8211 ]
8212 };
8213 let WpdSegment = _WpdSegment;
8214 defineComponent("wpd-segment", WpdSegment);
8215 const _WpdSegmented = class _WpdSegmented extends Component {
8216 connectedCallback() {
8217 super.connectedCallback();
8218 this.addEventListener("wpd-segment-pick", (e) => {
8219 const detail = e.detail;
8220 e.stopPropagation();
8221 this.value = detail.value;
8222 this.emit("wpd-pick", { value: detail.value });
8223 });
8224 }
8225 /**
8226 * Declarative item-list setter. Replaces the existing
8227 * `<wpd-segment>` children with a fresh set built from a
8228 * `{ value, label }` array; preserves the current selection
8229 * when the value still matches an entry, otherwise falls back
8230 * to the first item.
8231 *
8232 * Collapses the pre-0.11 imperative dance (clear children,
8233 * `createElement`, set `textContent`, `appendChild`, then
8234 * `setAttribute('value', …)` on the group — order matters) to
8235 * a single assignment:
8236 *
8237 * ```js
8238 * segmented.items = [
8239 * { value: 'm', label: 'm' },
8240 * { value: 'km', label: 'km' },
8241 * ];
8242 * ```
8243 *
8244 * @since 0.5.0
8245 */
8246 set items(list) {
8247 const existing = this.querySelectorAll(":scope > wpd-segment");
8248 for (const el of Array.from(existing)) {
8249 el.remove();
8250 }
8251 for (const item of list) {
8252 const seg = document.createElement("wpd-segment");
8253 seg.setAttribute("value", item.value);
8254 seg.textContent = item.label;
8255 this.appendChild(seg);
8256 }
8257 const current = this.value;
8258 const stillValid = current !== null && list.some((i) => i.value === current);
8259 if (!stillValid && list.length > 0) {
8260 this.value = list[0].value;
8261 } else {
8262 this.requestUpdate();
8263 }
8264 }
8265 render() {
8266 const label = this.label || "";
8267 if (label) {
8268 this.setAttribute("aria-label", label);
8269 }
8270 this.setAttribute("role", "radiogroup");
8271 const current = this.value;
8272 queueMicrotask(() => {
8273 const segs = this.querySelectorAll("wpd-segment");
8274 for (const seg of Array.from(segs)) {
8275 const v = seg.getAttribute("value");
8276 seg.setAttribute(
8277 "aria-checked",
8278 v === current ? "true" : "false"
8279 );
8280 }
8281 });
8282 return html`<slot></slot>`;
8283 }
8284 };
8285 _WpdSegmented.props = ["value", "label"];
8286 _WpdSegmented.styles = [segmentedStyles];
8287 _WpdSegmented.help = {
8288 title: "Segmented",
8289 summary: "iOS-style segmented radio group. Pill-shaped bar of equal-width <wpd-segment> children where exactly one is active.",
8290 status: "stable",
8291 since: "0.9.0",
8292 props: [
8293 {
8294 name: "value",
8295 type: "string",
8296 description: "Currently selected segment value. Mirrored onto child aria-checked."
8297 },
8298 {
8299 name: "label",
8300 type: "string",
8301 description: "aria-label for the radiogroup."
8302 }
8303 ],
8304 slots: [
8305 { name: "(default)", description: '<wpd-segment value="…"> children.' }
8306 ],
8307 events: [
8308 {
8309 name: "wpd-pick",
8310 description: "Fires when the selected segment changes.",
8311 detail: "{ value: string }"
8312 }
8313 ],
8314 cssProps: [
8315 { name: "--desktop-mode-window-bg", description: "Pill background." },
8316 { name: "--desktop-mode-text", description: "Active label colour." },
8317 { name: "--desktop-mode-muted", description: "Inactive label colour." }
8318 ],
8319 example: html`
8320 <wpd-segmented value="md" label="Dock size">
8321 <wpd-segment value="sm">Small</wpd-segment>
8322 <wpd-segment value="md">Medium</wpd-segment>
8323 <wpd-segment value="lg">Large</wpd-segment>
8324 </wpd-segmented>
8325 `
8326 };
8327 let WpdSegmented = _WpdSegmented;
8328 defineComponent("wpd-segmented", WpdSegmented);
8329 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}`;
8330 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 )}`;
8331 const _WpdMenu = class _WpdMenu extends Component {
8332 connectedCallback() {
8333 super.connectedCallback();
8334 this.setAttribute("role", "menu");
8335 }
8336 render() {
8337 return html`<slot></slot>`;
8338 }
8339 };
8340 _WpdMenu.styles = [menuStyles];
8341 _WpdMenu.help = {
8342 title: "Menu",
8343 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.",
8344 status: "stable",
8345 since: "0.9.0",
8346 slots: [
8347 { name: "(default)", description: "<wpd-menu-item> children." }
8348 ],
8349 cssProps: [
8350 { name: "--desktop-mode-window-bg", description: "Menu background." },
8351 { name: "--desktop-mode-window-border", description: "Menu border." },
8352 { name: "--desktop-mode-text", description: "Item text colour." }
8353 ],
8354 example: html`
8355 <wpd-menu>
8356 <wpd-menu-item value="new" icon="dashicons-plus">Open another window</wpd-menu-item>
8357 <wpd-menu-item value="startup" role="menuitemcheckbox" checked>Open on startup</wpd-menu-item>
8358 <wpd-menu-item value="close">Close window</wpd-menu-item>
8359 </wpd-menu>
8360 `
8361 };
8362 let WpdMenu = _WpdMenu;
8363 defineComponent("wpd-menu", WpdMenu);
8364 const _WpdMenuItem = class _WpdMenuItem extends Component {
8365 connectedCallback() {
8366 super.connectedCallback();
8367 if (!this.hasAttribute("role")) {
8368 this.setAttribute("role", "menuitem");
8369 }
8370 }
8371 render() {
8372 const icon = this.icon || "";
8373 const isCheckbox = this.getAttribute("role") === "menuitemcheckbox";
8374 const checked = this.checked !== null;
8375 if (isCheckbox) {
8376 this.setAttribute("aria-checked", checked ? "true" : "false");
8377 }
8378 return html`
8379 <button type="button" @click=${(e) => this._onPick(e)}>
8380 <span
8381 class="wpd-menu-item__check"
8382 ?hidden=${!isCheckbox}
8383 ></span>
8384 <span
8385 class="wpd-menu-item__icon dashicons ${icon}"
8386 aria-hidden="true"
8387 ?hidden=${isCheckbox || !icon}
8388 ></span>
8389 <span class="wpd-menu-item__label">
8390 <slot></slot>
8391 </span>
8392 </button>
8393 `;
8394 }
8395 _onPick(e) {
8396 e.preventDefault();
8397 this.emit("wpd-menu-item-click", {
8398 value: this.value
8399 });
8400 }
8401 };
8402 _WpdMenuItem.props = ["icon", "value", "checked"];
8403 _WpdMenuItem.styles = [menuItemStyles];
8404 _WpdMenuItem.help = {
8405 title: "Menu item",
8406 summary: 'Single row inside a <wpd-menu>. Supports three looks: plain label, left-aligned dashicon (icon="dashicons-…"), or a checkbox indicator (role="menuitemcheckbox" + checked).',
8407 status: "stable",
8408 since: "0.9.0",
8409 props: [
8410 {
8411 name: "icon",
8412 type: "string (dashicons class)",
8413 description: 'Dashicons class rendered on the left. Ignored when role="menuitemcheckbox".'
8414 },
8415 {
8416 name: "value",
8417 type: "string",
8418 description: "Identifier emitted in wpd-menu-item-click.detail.value."
8419 },
8420 {
8421 name: "checked",
8422 type: "boolean attribute",
8423 description: 'Visible check indicator. Only honoured when role="menuitemcheckbox".'
8424 }
8425 ],
8426 slots: [
8427 { name: "(default)", description: "Menu item label." }
8428 ],
8429 events: [
8430 {
8431 name: "wpd-menu-item-click",
8432 description: "Fires when the item is clicked; bubbles so the <wpd-menu> parent can delegate.",
8433 detail: "{ value: string | null }"
8434 }
8435 ]
8436 };
8437 let WpdMenuItem = _WpdMenuItem;
8438 defineComponent("wpd-menu-item", WpdMenuItem);
8439 function wpdConfirmGlobal$1(options) {
8440 const fn = window.wp?.desktop?.confirm;
8441 if (typeof fn !== "function") {
8442 return Promise.reject(
8443 new Error(
8444 "[desktop-mode] wp.desktop.confirm is missing — the main desktop bundle must load before the posts-window script."
8445 )
8446 );
8447 }
8448 return fn(options);
8449 }
8450 const _introShown = /* @__PURE__ */ Object.create(null);
8451 document.addEventListener("desktop-mode-intros-reset", () => {
8452 for (const slug of Object.keys(_introShown)) {
8453 _introShown[slug] = false;
8454 }
8455 });
8456 function maybeShowIntro(client) {
8457 let cfg;
8458 try {
8459 cfg = client.getConfig();
8460 } catch {
8461 return;
8462 }
8463 const slug = cfg.introSlug || cfg.mode || "posts";
8464 if (_introShown[slug]) {
8465 return;
8466 }
8467 if (cfg.introSeen) {
8468 return;
8469 }
8470 _introShown[slug] = true;
8471 const dialogPromise = slug === "pages" ? Promise.resolve().then(() => pagesIntroDialog).then(
8472 (m) => m.showPagesIntroDialog()
8473 ) : showPostsIntroDialog();
8474 void dialogPromise.then((result) => {
8475 if (result === "cancel") {
8476 _introShown[slug] = false;
8477 return;
8478 }
8479 void markIntroSeen(cfg, slug, client);
8480 if (result === "settings") {
8481 openOsSettingsFeatures();
8482 }
8483 }).catch(() => {
8484 _introShown[slug] = false;
8485 });
8486 }
8487 async function markIntroSeen(cfg, slug, client) {
8488 if (!cfg.introUrl) {
8489 return;
8490 }
8491 try {
8492 await trackedFetch(
8493 cfg.introUrl,
8494 {
8495 method: "POST",
8496 credentials: "same-origin",
8497 headers: {
8498 "Content-Type": "application/json",
8499 "X-WP-Nonce": cfg.restNonce
8500 },
8501 body: JSON.stringify({ slug })
8502 },
8503 {
8504 windowId: client.windowId,
8505 source: `${slug}-window/intro`
8506 }
8507 );
8508 cfg.introSeen = true;
8509 } catch {
8510 }
8511 }
8512 function openOsSettingsFeatures() {
8513 const api = window.wp?.desktop;
8514 api?.openOsSettings?.();
8515 }
8516 const ROOT$1 = "[data-desktop-mode-posts-root]";
8517 const STATUS$1 = "[data-desktop-mode-posts-status]";
8518 const SEARCH$1 = "[data-desktop-mode-posts-search]";
8519 const REFRESH$1 = "[data-desktop-mode-posts-refresh]";
8520 const NEW_BTN$1 = "[data-desktop-mode-posts-new]";
8521 const TABLE$1 = "[data-desktop-mode-posts-table]";
8522 const BULK$1 = "[data-desktop-mode-posts-bulk]";
8523 const COUNT$1 = "[data-desktop-mode-posts-count]";
8524 const PAGE_INDICATOR$1 = "[data-desktop-mode-posts-page-indicator]";
8525 const PREV$1 = "[data-desktop-mode-posts-prev]";
8526 const NEXT$1 = "[data-desktop-mode-posts-next]";
8527 const PER_PAGE$1 = "[data-desktop-mode-posts-per-page]";
8528 const TOOLBAR_TRAILING_EXTRAS = "[data-desktop-mode-posts-toolbar-extras]";
8529 const BULK_ACTIONS_HOST$1 = "[data-desktop-mode-posts-bulk-actions]";
8530 const HOOK_FILTER_COLUMNS = "desktop_mode.postsWindow.columns";
8531 const HOOK_FILTER_STATUS_SEGMENTS = "desktop_mode.postsWindow.statusSegments";
8532 const HOOK_FILTER_BULK_ACTIONS = "desktop_mode.postsWindow.bulkActions";
8533 const HOOK_FILTER_TOOLBAR_TRAILING = "desktop_mode.postsWindow.toolbarTrailing";
8534 const HOOK_ACTION_OPENED = "desktop_mode.postsWindow.opened";
8535 const HOOK_ACTION_DATA_LOADED = "desktop_mode.postsWindow.dataLoaded";
8536 const SEARCH_DEBOUNCE_MS$1 = 250;
8537 const STATUS_LABELS = {
8538 publish: __("Published"),
8539 future: __("Scheduled"),
8540 draft: __("Draft"),
8541 pending: __("Pending"),
8542 private: __("Private"),
8543 trash: __("Trash")
8544 };
8545 function statusBadgeColor(status) {
8546 switch (status) {
8547 case "publish":
8548 return { bg: "#e6f4ea", fg: "#1d6f42" };
8549 case "draft":
8550 return { bg: "#fdecea", fg: "#a02622" };
8551 case "pending":
8552 return { bg: "#fef7e0", fg: "#8a6d00" };
8553 case "private":
8554 return { bg: "#e8f0fe", fg: "#1a52a8" };
8555 case "future":
8556 return { bg: "#ede7f6", fg: "#5b3aa0" };
8557 case "trash":
8558 return { bg: "#f1f1f2", fg: "#50575e" };
8559 default:
8560 return { bg: "#f1f1f2", fg: "#50575e" };
8561 }
8562 }
8563 function decodeTitle(raw) {
8564 const ta = document.createElement("textarea");
8565 ta.innerHTML = raw;
8566 return ta.value;
8567 }
8568 function authorOf(row) {
8569 const embedded = row._embedded?.author?.[0];
8570 if (embedded) {
8571 const avatars = embedded.avatar_urls ?? {};
8572 return {
8573 id: embedded.id,
8574 name: embedded.name,
8575 avatar: avatars["48"] ?? avatars["96"] ?? avatars["24"]
8576 };
8577 }
8578 return { id: row.author, name: __("Unknown") };
8579 }
8580 function termRecordsOf(row, taxonomy) {
8581 const groups = row._embedded?.["wp:term"] ?? [];
8582 for (const group of groups) {
8583 if (group.length === 0) {
8584 continue;
8585 }
8586 if (group[0].taxonomy === taxonomy) {
8587 return group.map((t) => ({ id: t.id, name: t.name }));
8588 }
8589 }
8590 return [];
8591 }
8592 function featuredMediaOf(row) {
8593 const media = row._embedded?.["wp:featuredmedia"]?.[0];
8594 if (!media) {
8595 return null;
8596 }
8597 const sizes = media.media_details?.sizes ?? {};
8598 const small = sizes.thumbnail?.source_url ?? sizes.medium?.source_url ?? media.source_url;
8599 return { url: small, alt: media.alt_text ?? "" };
8600 }
8601 function cacheKey(rowId, columnKey) {
8602 return `${rowId}|${columnKey}`;
8603 }
8604 function memoCell(cache, rowId, columnKey, build) {
8605 const key = cacheKey(rowId, columnKey);
8606 const cached = cache.get(key);
8607 if (cached) {
8608 return cached;
8609 }
8610 const built = build();
8611 cache.set(key, built);
8612 return built;
8613 }
8614 const REQUIRED_COLUMN_KEYS = /* @__PURE__ */ new Set(["title"]);
8615 function getHiddenColumns() {
8616 try {
8617 const api = window.wp?.desktop;
8618 if (api && typeof api.getOsSettings === "function") {
8619 const snap = api.getOsSettings();
8620 if (Array.isArray(snap.nativePostsHiddenColumns)) {
8621 return new Set(snap.nativePostsHiddenColumns);
8622 }
8623 }
8624 } catch {
8625 }
8626 return /* @__PURE__ */ new Set();
8627 }
8628 const EMPTY_FILTER_DATA = { authors: [], tags: [] };
8629 function buildAllColumns(cache, client, filterData = EMPTY_FILTER_DATA) {
8630 const cols = _buildBaseColumns(cache, filterData, client);
8631 const hooks = window.wp?.hooks;
8632 return hooks && typeof hooks.applyFilters === "function" ? hooks.applyFilters(
8633 HOOK_FILTER_COLUMNS,
8634 cols
8635 ) : cols;
8636 }
8637 function buildColumns$1(cache, client, filterData = EMPTY_FILTER_DATA) {
8638 const all = buildAllColumns(cache, client, filterData);
8639 const hidden = getHiddenColumns();
8640 if (hidden.size === 0) {
8641 return all;
8642 }
8643 return all.filter(
8644 (col) => REQUIRED_COLUMN_KEYS.has(col.key) || !hidden.has(col.key)
8645 );
8646 }
8647 function _buildBaseColumns(cache, filterData, client) {
8648 let mode = "posts";
8649 try {
8650 const cfg = client.getConfig();
8651 if (cfg.mode === "pages") {
8652 mode = "pages";
8653 }
8654 } catch {
8655 }
8656 const titleCol = {
8657 key: "title",
8658 label: __("Title"),
8659 sortable: true,
8660 sticky: true,
8661 render: (_v, row) => memoCell(cache, row.id, "title", () => buildTitleCell(row, client))
8662 };
8663 const authorCol = {
8664 key: "author",
8665 label: __("Author"),
8666 sortable: true,
8667 width: "180px",
8668 filterRender: (host, ctx) => renderMultiSelectFilter(host, ctx, filterData.authors, {
8669 label: __("All authors"),
8670 ariaLabel: __("Filter by author")
8671 }),
8672 render: (_v, row) => memoCell(cache, row.id, "author", () => buildAuthorCell(row))
8673 };
8674 const dateCol = {
8675 key: "date",
8676 label: __("Date"),
8677 sortable: true,
8678 width: "170px",
8679 sortValue: (row) => Date.parse(row.date_gmt + "Z") || 0,
8680 render: (_v, row) => memoCell(cache, row.id, "date", () => buildDateCell(row))
8681 };
8682 if (mode === "pages") {
8683 const parentCol = {
8684 key: "parent",
8685 label: __("Parent"),
8686 width: "200px",
8687 render: (_v, row) => memoCell(cache, row.id, "parent", () => buildParentCell(row))
8688 };
8689 const templateCol = {
8690 key: "template",
8691 label: __("Template"),
8692 width: "180px",
8693 render: (_v, row) => memoCell(cache, row.id, "template", () => buildTemplateCell(row, client))
8694 };
8695 const slugCol = {
8696 key: "slug",
8697 label: __("Slug"),
8698 width: "200px",
8699 render: (_v, row) => memoCell(cache, row.id, "slug", () => buildSlugCell(row))
8700 };
8701 const commentsCol = {
8702 key: "comments",
8703 label: __("Comments"),
8704 width: "110px",
8705 sortValue: (row) => typeof row.desktop_mode_comment_count === "number" ? row.desktop_mode_comment_count : 0,
8706 render: (_v, row) => memoCell(
8707 cache,
8708 row.id,
8709 "comments",
8710 () => buildCommentsCell(row)
8711 )
8712 };
8713 return [
8714 titleCol,
8715 authorCol,
8716 parentCol,
8717 templateCol,
8718 slugCol,
8719 commentsCol,
8720 dateCol
8721 ];
8722 }
8723 return [
8724 titleCol,
8725 authorCol,
8726 {
8727 key: "categories",
8728 label: __("Categories"),
8729 width: "260px",
8730 render: (_v, row) => memoCell(
8731 cache,
8732 row.id,
8733 "categories",
8734 () => buildCategoriesCell(row, client)
8735 )
8736 },
8737 {
8738 key: "tags",
8739 // Drop the fixed width so the column flexes with the
8740 // available space; pin a minimum that comfortably holds
8741 // ~4 chips on one line so the cell doesn't collapse the
8742 // tags into a vertical stack on narrow tables.
8743 label: __("Tags"),
8744 minWidth: "360px",
8745 filterRender: (host, ctx) => renderMultiSelectFilter(
8746 host,
8747 ctx,
8748 filterData.tags.map((t) => ({ id: t.id, name: t.name })),
8749 {
8750 label: __("All tags"),
8751 ariaLabel: __("Filter by tag"),
8752 dataKey: "tags",
8753 hasMore: !!filterData.tagsHasMore,
8754 onLoadMore: filterData.loadMoreTags
8755 }
8756 ),
8757 render: (_v, row) => memoCell(cache, row.id, "tags", () => buildTagsCell(row, client))
8758 },
8759 dateCol
8760 ];
8761 }
8762 const _parentTitleByPageRoster = /* @__PURE__ */ new Map();
8763 function buildParentCell(row) {
8764 const cell = document.createElement("span");
8765 cell.className = "desktop-mode-posts__parent";
8766 const pid = typeof row.parent === "number" ? row.parent : 0;
8767 if (pid === 0) {
8768 cell.classList.add("desktop-mode-posts__parent--top");
8769 cell.textContent = "—";
8770 cell.setAttribute("aria-label", __("Top-level page"));
8771 return cell;
8772 }
8773 cell.classList.add("desktop-mode-posts__parent--child");
8774 const titleFromRoster = _parentTitleByPageRoster.get(pid);
8775 if (titleFromRoster) {
8776 cell.textContent = `↳ ${titleFromRoster}`;
8777 } else {
8778 cell.textContent = sprintf(__("↳ #%d"), pid);
8779 }
8780 return cell;
8781 }
8782 function refreshParentTitleRoster(rows) {
8783 _parentTitleByPageRoster.clear();
8784 for (const row of rows) {
8785 _parentTitleByPageRoster.set(row.id, decodeTitle(row.title.rendered));
8786 }
8787 }
8788 function buildTemplateCell(row, client) {
8789 const cell = document.createElement("span");
8790 cell.className = "desktop-mode-posts__template";
8791 const slug = typeof row.template === "string" ? row.template : "";
8792 let label = slug;
8793 try {
8794 const cfg = client.getConfig();
8795 const map = cfg.pageTemplates ?? {};
8796 label = map[slug] ?? (slug === "" ? __("Default template") : slug);
8797 } catch {
8798 label = slug === "" ? __("Default template") : slug;
8799 }
8800 cell.textContent = label;
8801 if (slug !== "") {
8802 cell.title = slug;
8803 }
8804 return cell;
8805 }
8806 function buildSlugCell(row) {
8807 const cell = document.createElement("button");
8808 cell.type = "button";
8809 cell.className = "desktop-mode-posts__slug";
8810 const slug = typeof row.slug === "string" ? row.slug : "";
8811 cell.textContent = slug || "—";
8812 cell.disabled = slug === "";
8813 cell.title = slug ? __("Click to copy slug") : "";
8814 Object.assign(cell.style, {
8815 appearance: "none",
8816 background: "transparent",
8817 border: "none",
8818 padding: "2px 6px",
8819 font: "inherit",
8820 color: "inherit",
8821 cursor: slug ? "copy" : "default",
8822 textAlign: "left",
8823 fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
8824 fontSize: "12px",
8825 borderRadius: "4px",
8826 maxWidth: "100%",
8827 overflow: "hidden",
8828 textOverflow: "ellipsis",
8829 whiteSpace: "nowrap"
8830 });
8831 cell.addEventListener("click", (e) => {
8832 e.stopPropagation();
8833 if (!slug) {
8834 return;
8835 }
8836 void navigator.clipboard?.writeText(slug).then(() => {
8837 cell.textContent = __("Copied!");
8838 cell.style.color = "var(--wp-admin-theme-color, #2271b1)";
8839 setTimeout(() => {
8840 cell.textContent = slug;
8841 cell.style.color = "";
8842 }, 1200);
8843 }).catch(() => {
8844 });
8845 });
8846 return cell;
8847 }
8848 function buildCommentsCell(row) {
8849 const cell = document.createElement("span");
8850 cell.className = "desktop-mode-posts__comments";
8851 Object.assign(cell.style, {
8852 display: "inline-flex",
8853 alignItems: "center",
8854 gap: "6px",
8855 fontVariantNumeric: "tabular-nums"
8856 });
8857 const count = typeof row.desktop_mode_comment_count === "number" ? row.desktop_mode_comment_count : null;
8858 if (count === null) {
8859 cell.textContent = "—";
8860 cell.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
8861 return cell;
8862 }
8863 const icon = document.createElement("span");
8864 icon.className = "dashicons dashicons-admin-comments";
8865 icon.setAttribute("aria-hidden", "true");
8866 Object.assign(icon.style, {
8867 fontSize: "16px",
8868 width: "16px",
8869 height: "16px",
8870 color: count > 0 ? "var(--wp-admin-theme-color, #2271b1)" : "var(--wp-admin-theme-fg-muted, #8c8f94)"
8871 });
8872 const label = document.createElement("span");
8873 label.textContent = String(count);
8874 if (count === 0) {
8875 label.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
8876 }
8877 cell.appendChild(icon);
8878 cell.appendChild(label);
8879 cell.setAttribute(
8880 "aria-label",
8881 // translators: %d is the comment count for a row.
8882 `${sprintf(_n("%d comment", "%d comments", count), count)}`
8883 );
8884 return cell;
8885 }
8886 function renderMultiSelectFilter(host, ctx, all, opts) {
8887 const HOST_KEY = "wpdPostsFilterMounted";
8888 const tagged = host;
8889 const optionsForPicker = all.map((o) => ({
8890 value: String(o.id),
8891 label: o.name
8892 }));
8893 const nextSig = optionsForPicker.map((o) => `${o.value}:${o.label}`).join("|");
8894 if (tagged[HOST_KEY]) {
8895 const state = tagged[HOST_KEY];
8896 if (state.listSig !== nextSig) {
8897 state.picker.items = optionsForPicker;
8898 state.listSig = nextSig;
8899 }
8900 if (state.picker.getAttribute("value") !== ctx.value) {
8901 state.picker.setAttribute("value", ctx.value);
8902 }
8903 state.picker.hasMore = !!opts.hasMore;
8904 return;
8905 }
8906 const picker = document.createElement("wpd-multiselect");
8907 picker.setAttribute("placeholder", opts.label);
8908 picker.setAttribute("aria-label", opts.ariaLabel);
8909 picker.setAttribute("data-noclick", "");
8910 picker.setAttribute("value", ctx.value);
8911 if (opts.dataKey) {
8912 picker.setAttribute("data-key", opts.dataKey);
8913 }
8914 host.appendChild(picker);
8915 picker.items = optionsForPicker;
8916 picker.hasMore = !!opts.hasMore;
8917 picker.addEventListener("wpd-pick", (e) => {
8918 const detail = e.detail;
8919 const next = detail?.value ?? "";
8920 ctx.value = next;
8921 ctx.setValue(next);
8922 });
8923 if (opts.onLoadMore) {
8924 const onLoadMore = opts.onLoadMore;
8925 picker.addEventListener("wpd-multiselect-load-more", () => {
8926 picker.loadingMore = true;
8927 onLoadMore();
8928 });
8929 }
8930 tagged[HOST_KEY] = { picker, listSig: nextSig };
8931 }
8932 function mountKebabColumnToggles(body, cache, repaintColumns, client) {
8933 const winEl = body.closest(".desktop-mode-window");
8934 const panel = winEl?.querySelector(
8935 ".desktop-mode-window__menu-panel"
8936 );
8937 if (!panel) {
8938 return null;
8939 }
8940 const SECTION_CLASS = "desktop-mode-posts-window__menu-columns";
8941 const ITEM_CLASS = "desktop-mode-posts-window__menu-column-item";
8942 const VALUE_PREFIX = "desktop-mode-posts-column:";
8943 panel.querySelectorAll(`.${SECTION_CLASS}, .${ITEM_CLASS}`).forEach((n) => n.remove());
8944 const allCols = buildAllColumns(cache, client);
8945 const togglable = allCols.filter(
8946 (c) => !REQUIRED_COLUMN_KEYS.has(c.key)
8947 );
8948 if (togglable.length === 0) {
8949 return null;
8950 }
8951 const sectionLabel = document.createElement("div");
8952 sectionLabel.className = SECTION_CLASS;
8953 sectionLabel.setAttribute("role", "presentation");
8954 sectionLabel.textContent = __("Show columns");
8955 panel.appendChild(sectionLabel);
8956 const itemEls = /* @__PURE__ */ new Map();
8957 for (const col of togglable) {
8958 const item = document.createElement("wpd-menu-item");
8959 item.setAttribute("role", "menuitemcheckbox");
8960 item.setAttribute("value", VALUE_PREFIX + col.key);
8961 item.classList.add("desktop-mode-window__menu-item");
8962 item.classList.add(ITEM_CLASS);
8963 item.textContent = col.label || col.key;
8964 panel.appendChild(item);
8965 itemEls.set(col.key, item);
8966 }
8967 const paintChecked = () => {
8968 const hidden = getHiddenColumns();
8969 for (const [key, el] of itemEls) {
8970 if (hidden.has(key)) {
8971 el.removeAttribute("checked");
8972 } else {
8973 el.setAttribute("checked", "");
8974 }
8975 }
8976 };
8977 paintChecked();
8978 const onClick = (e) => {
8979 const detail = e.detail;
8980 const value = detail?.value;
8981 if (typeof value !== "string" || !value.startsWith(VALUE_PREFIX)) {
8982 return;
8983 }
8984 const key = value.slice(VALUE_PREFIX.length);
8985 if (!itemEls.has(key) || REQUIRED_COLUMN_KEYS.has(key)) {
8986 return;
8987 }
8988 const hidden = getHiddenColumns();
8989 if (hidden.has(key)) {
8990 hidden.delete(key);
8991 } else {
8992 hidden.add(key);
8993 }
8994 const next = Array.from(hidden).sort();
8995 const api = window.wp?.desktop;
8996 if (api && typeof api.updateOsSettings === "function") {
8997 api.updateOsSettings(
8998 { nativePostsHiddenColumns: next },
8999 { windowId: "desktop-mode-posts" }
9000 );
9001 }
9002 paintChecked();
9003 repaintColumns();
9004 };
9005 panel.addEventListener("wpd-menu-item-click", onClick);
9006 return {
9007 refresh: paintChecked,
9008 dispose: () => {
9009 panel.removeEventListener("wpd-menu-item-click", onClick);
9010 sectionLabel.remove();
9011 for (const el of itemEls.values()) {
9012 el.remove();
9013 }
9014 itemEls.clear();
9015 }
9016 };
9017 }
9018 function defaultStatusSegments$1() {
9019 return [
9020 { value: "", label: __("All") },
9021 { value: "publish", label: __("Published") },
9022 { value: "draft", label: __("Drafts") },
9023 { value: "pending", label: __("Pending") },
9024 { value: "future", label: __("Scheduled") },
9025 { value: "trash", label: __("Trash") }
9026 ];
9027 }
9028 function defaultBulkActions(client) {
9029 return [
9030 {
9031 id: "trash",
9032 label: __("Move to trash"),
9033 icon: "dashicons-trash",
9034 variant: "danger",
9035 /* translators: %d: row count. */
9036 confirm: __("Move %d post(s) to the trash?"),
9037 run: async (ids, ctx) => {
9038 const data = ctx.table.data ?? [];
9039 const trashable = ids.filter((id) => {
9040 const row = data.find((r) => r.id === id);
9041 return row && row.status !== "trash";
9042 });
9043 if (trashable.length === 0) {
9044 return;
9045 }
9046 const results = await Promise.all(
9047 trashable.map((id) => client.trashPost(id))
9048 );
9049 const errors = results.filter((r) => !r.ok);
9050 if (errors.length > 0) {
9051 console.error("[posts-window] some trashes failed", errors);
9052 }
9053 const okIds = results.filter((r) => r.ok).map((r) => r.id);
9054 const api = window.wp?.desktop;
9055 if (api && typeof api.broadcast === "function") {
9056 api.broadcast("desktop-mode.post.changed", {
9057 source: "posts-window",
9058 action: "trashed",
9059 ids: okIds
9060 });
9061 }
9062 }
9063 }
9064 ];
9065 }
9066 function resolveBulkActions(client) {
9067 const hooks = window.wp?.hooks;
9068 const defaults = defaultBulkActions(client);
9069 if (!hooks || typeof hooks.applyFilters !== "function") {
9070 return defaults;
9071 }
9072 try {
9073 const out = hooks.applyFilters(HOOK_FILTER_BULK_ACTIONS, defaults);
9074 return Array.isArray(out) ? out : defaults;
9075 } catch (err) {
9076 console.error(
9077 "[posts-window] bulk-actions filter threw; falling back to defaults:",
9078 err
9079 );
9080 return defaults;
9081 }
9082 }
9083 function resolveStatusSegments() {
9084 const hooks = window.wp?.hooks;
9085 const defaults = defaultStatusSegments$1();
9086 if (!hooks || typeof hooks.applyFilters !== "function") {
9087 return defaults;
9088 }
9089 try {
9090 const out = hooks.applyFilters(HOOK_FILTER_STATUS_SEGMENTS, defaults);
9091 return Array.isArray(out) && out.length > 0 ? out : defaults;
9092 } catch (err) {
9093 console.error(
9094 "[posts-window] status-segments filter threw; falling back to defaults:",
9095 err
9096 );
9097 return defaults;
9098 }
9099 }
9100 function resolveToolbarTrailing(ctx) {
9101 const hooks = window.wp?.hooks;
9102 if (!hooks || typeof hooks.applyFilters !== "function") {
9103 return [];
9104 }
9105 try {
9106 const out = hooks.applyFilters(HOOK_FILTER_TOOLBAR_TRAILING, [], ctx);
9107 if (!Array.isArray(out)) {
9108 return [];
9109 }
9110 return out.filter((el) => el instanceof HTMLElement);
9111 } catch (err) {
9112 console.error(
9113 "[posts-window] toolbar-trailing filter threw; ignoring:",
9114 err
9115 );
9116 return [];
9117 }
9118 }
9119 function buildTitleCell(row, client) {
9120 const cell = document.createElement("span");
9121 cell.style.cssText = "display:flex;flex-direction:column;gap:4px;min-width:0;";
9122 const titleRow = document.createElement("span");
9123 titleRow.style.cssText = "display:flex;align-items:center;gap:8px;min-width:0;";
9124 const link = document.createElement("a");
9125 link.href = client.buildEditPostUrl(row.id);
9126 link.setAttribute("data-noclick", "");
9127 const title = decodeTitle(row.title.rendered) || __("(no title)");
9128 link.textContent = title;
9129 link.title = title;
9130 link.style.cssText = "font-weight:600;color:inherit;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:340px;";
9131 link.addEventListener("mouseenter", () => {
9132 link.style.textDecoration = "underline";
9133 });
9134 link.addEventListener("mouseleave", () => {
9135 link.style.textDecoration = "none";
9136 });
9137 link.addEventListener("click", (e) => {
9138 e.preventDefault();
9139 e.stopPropagation();
9140 openAdminUrl(link.href, {
9141 title,
9142 icon: "dashicons-admin-post"
9143 });
9144 });
9145 titleRow.appendChild(link);
9146 const lock = row.desktop_mode_lock ?? null;
9147 if (lock) {
9148 const lockBadge = document.createElement("span");
9149 lockBadge.style.cssText = [
9150 "display:inline-flex",
9151 "align-items:center",
9152 "gap:4px",
9153 "padding:2px 8px",
9154 "border-radius:10px",
9155 "font-size:11px",
9156 "font-weight:600",
9157 "background:rgba(179, 45, 46, 0.1)",
9158 "color:#b32d2e",
9159 "white-space:nowrap",
9160 "flex-shrink:0"
9161 ].join(";");
9162 const lockIcon = document.createElement("span");
9163 lockIcon.setAttribute("aria-hidden", "true");
9164 lockIcon.style.cssText = [
9165 "font-family:dashicons",
9166 "font-size:14px",
9167 "line-height:1",
9168 "display:inline-block",
9169 "speak:none",
9170 "-webkit-font-smoothing:antialiased"
9171 ].join(";");
9172 lockIcon.textContent = "";
9173 lockBadge.appendChild(lockIcon);
9174 const lockText = document.createElement("span");
9175 lockText.textContent = lock.userName;
9176 lockBadge.appendChild(lockText);
9177 const tipFmt = __("%s is currently editing", "desktop-mode");
9178 lockBadge.title = sprintf(tipFmt, lock.userName);
9179 titleRow.appendChild(lockBadge);
9180 }
9181 let cfgForBadges = null;
9182 try {
9183 cfgForBadges = client.getConfig();
9184 } catch {
9185 cfgForBadges = null;
9186 }
9187 if (cfgForBadges && cfgForBadges.mode === "pages") {
9188 if (typeof cfgForBadges.frontPageId === "number" && cfgForBadges.frontPageId === row.id) {
9189 titleRow.appendChild(
9190 buildAssignmentBadge(
9191 __("Front page"),
9192 "dashicons-admin-home",
9193 "#0a4b78",
9194 "rgba(34,113,177,0.12)"
9195 )
9196 );
9197 }
9198 if (typeof cfgForBadges.postsPageId === "number" && cfgForBadges.postsPageId === row.id) {
9199 titleRow.appendChild(
9200 buildAssignmentBadge(
9201 __("Posts page"),
9202 "dashicons-admin-post",
9203 "#5b3aa0",
9204 "rgba(91,58,160,0.12)"
9205 )
9206 );
9207 }
9208 }
9209 if (row.status && row.status !== "publish") {
9210 const badge = document.createElement("span");
9211 const colors = statusBadgeColor(row.status);
9212 badge.textContent = STATUS_LABELS[row.status] ?? row.status;
9213 badge.style.cssText = [
9214 "display:inline-flex",
9215 "align-items:center",
9216 "padding:2px 8px",
9217 "border-radius:10px",
9218 "font-size:11px",
9219 "font-weight:600",
9220 "text-transform:uppercase",
9221 "letter-spacing:0.04em",
9222 `background:${colors.bg}`,
9223 `color:${colors.fg}`,
9224 "white-space:nowrap",
9225 "flex-shrink:0"
9226 ].join(";");
9227 titleRow.appendChild(badge);
9228 }
9229 if (cfgForBadges?.mode === "pages" && typeof row.link === "string" && row.link && row.status === "publish") {
9230 const view = document.createElement("a");
9231 view.href = row.link;
9232 view.target = "_blank";
9233 view.rel = "noreferrer noopener";
9234 view.textContent = __("View");
9235 view.title = row.link;
9236 view.setAttribute("data-noclick", "");
9237 view.style.cssText = [
9238 "font-size:11px",
9239 "color:var(--wp-admin-theme-color, #2271b1)",
9240 "text-decoration:none",
9241 "flex-shrink:0"
9242 ].join(";");
9243 view.addEventListener("click", (e) => e.stopPropagation());
9244 view.addEventListener("mouseenter", () => {
9245 view.style.textDecoration = "underline";
9246 });
9247 view.addEventListener("mouseleave", () => {
9248 view.style.textDecoration = "none";
9249 });
9250 titleRow.appendChild(view);
9251 }
9252 cell.appendChild(titleRow);
9253 return cell;
9254 }
9255 function buildAssignmentBadge(label, dashicon, fg, bg) {
9256 const badge = document.createElement("span");
9257 badge.style.cssText = [
9258 "display:inline-flex",
9259 "align-items:center",
9260 "gap:4px",
9261 "padding:2px 8px",
9262 "border-radius:10px",
9263 "font-size:11px",
9264 "font-weight:600",
9265 `background:${bg}`,
9266 `color:${fg}`,
9267 "white-space:nowrap",
9268 "flex-shrink:0"
9269 ].join(";");
9270 const icon = document.createElement("span");
9271 icon.className = `dashicons ${dashicon}`;
9272 icon.setAttribute("aria-hidden", "true");
9273 icon.style.cssText = "font-size:13px;width:13px;height:13px;line-height:1;";
9274 const text = document.createElement("span");
9275 text.textContent = label;
9276 badge.appendChild(icon);
9277 badge.appendChild(text);
9278 return badge;
9279 }
9280 function buildAuthorCell(row) {
9281 const a = authorOf(row);
9282 const wrap = document.createElement("span");
9283 wrap.style.cssText = "display:inline-flex;align-items:center;gap:8px;min-width:0;";
9284 const avatar = document.createElement("wpd-avatar");
9285 avatar.setAttribute("size", "24");
9286 if (a.name) {
9287 avatar.setAttribute("name", a.name);
9288 }
9289 if (a.id > 0) {
9290 avatar.setAttribute("user-id", String(a.id));
9291 }
9292 if (a.avatar) {
9293 applyAvatarSrc(avatar, a.avatar);
9294 }
9295 wrap.appendChild(avatar);
9296 const name = document.createElement("span");
9297 name.textContent = a.name;
9298 name.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
9299 wrap.appendChild(name);
9300 return wrap;
9301 }
9302 function buildTagsCell(row, client) {
9303 const wrap = document.createElement("span");
9304 wrap.style.cssText = "display:inline-flex;align-items:center;width:100%;min-width:0;";
9305 const picker = document.createElement("wpd-tag-input");
9306 picker.setAttribute("creatable", "");
9307 picker.setAttribute("removable", "");
9308 picker.setAttribute("min-query", "0");
9309 picker.setAttribute("placeholder", __("Add tag…"));
9310 picker.setAttribute("add-label", __("Tag"));
9311 picker.setAttribute("data-noclick", "");
9312 const seed = termRecordsOf(row, "post_tag").map((t) => ({
9313 id: t.id,
9314 label: t.name
9315 }));
9316 picker.value = seed;
9317 const cellState = {
9318 // Mirror of `picker.value` we mutate optimistically. Keeping
9319 // it here (rather than reading back from the picker) avoids
9320 // double-source-of-truth bugs when two events fire in the
9321 // same tick.
9322 tags: seed.slice(),
9323 // AbortController for the in-flight suggest fetch.
9324 suggestAbort: null,
9325 suggestDebounce: null,
9326 // Last query the user typed — used to drop stale responses
9327 // even after AbortController has fired.
9328 lastQuery: ""
9329 };
9330 const setValue = (next) => {
9331 cellState.tags = next.slice();
9332 picker.value = next;
9333 };
9334 picker.addEventListener("wpd-tag-suggest", (e) => {
9335 const detail = e.detail;
9336 const query = detail?.query ?? "";
9337 cellState.lastQuery = query;
9338 if (cellState.suggestDebounce !== null) {
9339 window.clearTimeout(cellState.suggestDebounce);
9340 cellState.suggestDebounce = null;
9341 }
9342 cellState.suggestDebounce = window.setTimeout(async () => {
9343 cellState.suggestDebounce = null;
9344 if (cellState.suggestAbort) {
9345 cellState.suggestAbort.abort();
9346 }
9347 const ac = new AbortController();
9348 cellState.suggestAbort = ac;
9349 try {
9350 const matches = await client.searchTags(query, ac.signal);
9351 if (cellState.lastQuery !== query) {
9352 return;
9353 }
9354 const existingIds = new Set(cellState.tags.map((t) => t.id));
9355 picker.suggestions = matches.filter((m) => !existingIds.has(m.id)).map((m) => ({ id: m.id, label: m.name }));
9356 } catch (err) {
9357 if (err?.name === "AbortError") {
9358 return;
9359 }
9360 picker.suggestions = [];
9361 console.warn(
9362 "[posts-window] tag search failed",
9363 err
9364 );
9365 } finally {
9366 picker.suggestionsLoading = false;
9367 }
9368 }, 200);
9369 });
9370 picker.addEventListener("wpd-tag-add", async (e) => {
9371 const detail = e.detail;
9372 if (!detail?.tag) {
9373 return;
9374 }
9375 const optimistic = {
9376 id: detail.tag.id,
9377 label: detail.tag.label,
9378 pending: true
9379 };
9380 const next = [...cellState.tags, optimistic];
9381 setValue(next);
9382 try {
9383 let resolvedTag = null;
9384 if (detail.isNew || typeof detail.tag.id !== "number") {
9385 resolvedTag = await client.createTag(detail.tag.label);
9386 } else {
9387 resolvedTag = {
9388 id: Number(detail.tag.id),
9389 name: detail.tag.label,
9390 slug: ""
9391 };
9392 }
9393 const desiredIds = [
9394 ...cellState.tags.filter((t) => !t.pending).map((t) => Number(t.id)),
9395 resolvedTag.id
9396 ];
9397 await client.updatePostTags(row.id, desiredIds);
9398 setValue(
9399 cellState.tags.map((t) => {
9400 if (t.label.toLowerCase() === detail.tag.label.toLowerCase()) {
9401 return {
9402 id: resolvedTag.id,
9403 label: resolvedTag.name
9404 };
9405 }
9406 return t;
9407 })
9408 );
9409 const api = window.wp?.desktop;
9410 if (api && typeof api.broadcast === "function") {
9411 api.broadcast("desktop-mode.post.changed", {
9412 source: "posts-window",
9413 action: "tagged",
9414 ids: [row.id]
9415 });
9416 }
9417 } catch (err) {
9418 setValue(
9419 cellState.tags.filter(
9420 (t) => t.label.toLowerCase() !== detail.tag.label.toLowerCase()
9421 )
9422 );
9423 showTagError(
9424 sprintf(
9425 /* translators: %s: tag label */
9426 __('Couldn’t add tag "%s".'),
9427 detail.tag.label
9428 ),
9429 err
9430 );
9431 }
9432 });
9433 picker.addEventListener("wpd-tag-remove", async (e) => {
9434 const detail = e.detail;
9435 if (!detail?.tag) {
9436 return;
9437 }
9438 const removed = detail.tag;
9439 const previous = cellState.tags.slice();
9440 setValue(
9441 cellState.tags.map(
9442 (t) => t.label === removed.label ? { ...t, pending: true } : t
9443 )
9444 );
9445 try {
9446 const desiredIds = previous.filter((t) => t.label !== removed.label).map((t) => Number(t.id)).filter((n) => Number.isFinite(n));
9447 await client.updatePostTags(row.id, desiredIds);
9448 setValue(
9449 previous.filter((t) => t.label !== removed.label)
9450 );
9451 const api = window.wp?.desktop;
9452 if (api && typeof api.broadcast === "function") {
9453 api.broadcast("desktop-mode.post.changed", {
9454 source: "posts-window",
9455 action: "untagged",
9456 ids: [row.id]
9457 });
9458 }
9459 } catch (err) {
9460 setValue(previous);
9461 showTagError(
9462 sprintf(
9463 /* translators: %s: tag label */
9464 __('Couldn’t remove tag "%s".'),
9465 removed.label
9466 ),
9467 err
9468 );
9469 }
9470 });
9471 wrap.appendChild(picker);
9472 return wrap;
9473 }
9474 function showTagError(title, err) {
9475 const reason = err instanceof Error ? err.message : String(err);
9476 const api = window.wp?.desktop;
9477 if (api && typeof api.showToast === "function") {
9478 api.showToast({
9479 message: `${title} ${reason}`.trim(),
9480 duration: 6e3
9481 });
9482 return;
9483 }
9484 console.error(title, err);
9485 }
9486 function buildCategoriesCell(row, client) {
9487 const wrap = document.createElement("span");
9488 wrap.className = "wpd-cat-cell-dropzone";
9489 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;";
9490 const picker = document.createElement(
9491 "wpd-category-picker"
9492 );
9493 picker.setAttribute("placeholder", __("Search categories…"));
9494 picker.setAttribute("add-label", __("Categorize"));
9495 picker.setAttribute("data-noclick", "");
9496 _activePickers.add(picker);
9497 picker.value = row.categories ?? [];
9498 const seedItems = termRecordsOf(row, "category").map(
9499 (t) => ({ id: t.id, name: t.name, parent: 0 })
9500 );
9501 picker.items = seedItems;
9502 const cellState = {
9503 categoryIds: (row.categories ?? []).slice()
9504 };
9505 const setValue = (next) => {
9506 cellState.categoryIds = next.slice();
9507 picker.value = next;
9508 };
9509 void getCategoriesTree(client).then((tree) => {
9510 if (!picker.isConnected) {
9511 return;
9512 }
9513 picker.items = tree;
9514 }).catch((err) => {
9515 console.warn("[posts-window] category tree fetch failed", err);
9516 });
9517 picker.addEventListener("wpd-categories-open", () => {
9518 void primePickerFromCache(picker);
9519 });
9520 picker.addEventListener(
9521 "wpd-categories-create",
9522 async (e) => {
9523 const detail = e.detail;
9524 const parent = detail?.parent ?? 0;
9525 if (!detail || !detail.name) {
9526 picker.failCreating(parent);
9527 return;
9528 }
9529 try {
9530 const created = await client.createCategory(detail.name, parent);
9531 _categoryTreePromise = null;
9532 const nextItems = [
9533 ...picker.items,
9534 {
9535 id: created.id,
9536 name: created.name,
9537 parent: created.parent
9538 }
9539 ];
9540 picker.items = nextItems;
9541 const nextValue = [...cellState.categoryIds, created.id];
9542 setValue(nextValue);
9543 picker.endCreating(parent);
9544 try {
9545 await client.updatePostCategories(row.id, nextValue);
9546 const api = window.wp?.desktop;
9547 if (api && typeof api.broadcast === "function") {
9548 api.broadcast("desktop-mode.post.changed", {
9549 source: "posts-window",
9550 action: "categorized",
9551 ids: [row.id]
9552 });
9553 }
9554 } catch (err) {
9555 setValue(cellState.categoryIds.filter((id) => id !== created.id));
9556 showTagError(__("Couldn’t assign new category."), err);
9557 }
9558 } catch (err) {
9559 picker.failCreating(
9560 parent,
9561 err instanceof Error ? err.message : String(err)
9562 );
9563 showTagError(__("Couldn’t create category."), err);
9564 }
9565 }
9566 );
9567 picker.addEventListener("wpd-categories-change", async (e) => {
9568 const detail = e.detail;
9569 if (!detail || !Array.isArray(detail.value)) {
9570 return;
9571 }
9572 const previous = cellState.categoryIds.slice();
9573 const next = detail.value.slice();
9574 setValue(next);
9575 try {
9576 await client.updatePostCategories(row.id, next);
9577 const api = window.wp?.desktop;
9578 if (api && typeof api.broadcast === "function") {
9579 api.broadcast("desktop-mode.post.changed", {
9580 source: "posts-window",
9581 action: "categorized",
9582 ids: [row.id]
9583 });
9584 }
9585 } catch (err) {
9586 setValue(previous);
9587 showTagError(__("Couldn’t update categories."), err);
9588 }
9589 });
9590 picker.addEventListener("wpd-categories-delete", async (e) => {
9591 const detail = e.detail;
9592 if (!detail || typeof detail.id !== "number") {
9593 return;
9594 }
9595 const ok = await wpdConfirmGlobal$1({
9596 title: __("Delete category?"),
9597 message: sprintf(
9598 /* translators: %s: category name. */
9599 __(
9600 'Delete the category "%s"? Posts assigned only to it will fall back to Uncategorized.'
9601 ),
9602 detail.name
9603 ),
9604 confirmLabel: __("Delete"),
9605 danger: true
9606 });
9607 if (!ok) {
9608 return;
9609 }
9610 try {
9611 await client.deleteTerm("categories", detail.id);
9612 if (cellState.categoryIds.includes(detail.id)) {
9613 const next = cellState.categoryIds.filter(
9614 (id) => id !== detail.id
9615 );
9616 setValue(next);
9617 try {
9618 await client.updatePostCategories(row.id, next);
9619 } catch (err) {
9620 showTagError(
9621 __("Couldn’t update post categories after delete."),
9622 err
9623 );
9624 }
9625 }
9626 } catch (err) {
9627 showTagError(__("Couldn’t delete category."), err);
9628 }
9629 });
9630 picker.addEventListener("wpd-chain-segment-dragstart", (e) => {
9631 const detail = e.detail;
9632 if (!detail || !detail.dragEvent || !detail.dragEvent.dataTransfer) {
9633 return;
9634 }
9635 const ids = [];
9636 for (const seg of detail.segments) {
9637 if (typeof seg.id === "number") {
9638 ids.push(seg.id);
9639 }
9640 }
9641 if (ids.length === 0) {
9642 return;
9643 }
9644 const dt = detail.dragEvent.dataTransfer;
9645 dt.setData(
9646 "application/x-desktop-mode-categories",
9647 JSON.stringify({
9648 ids,
9649 source: "posts-window",
9650 sourcePostId: row.id
9651 })
9652 );
9653 dt.setData("text/plain", ids.join(","));
9654 dt.effectAllowed = "copy";
9655 });
9656 let dropEnterCount = 0;
9657 const setDropTargetActive = (on) => {
9658 if (on) {
9659 wrap.style.backgroundColor = "color-mix(in srgb, var(--wp-admin-theme-color, #2271b1) 12%, transparent)";
9660 wrap.style.boxShadow = "inset 0 0 0 2px var(--wp-admin-theme-color, #2271b1)";
9661 } else {
9662 wrap.style.backgroundColor = "";
9663 wrap.style.boxShadow = "";
9664 }
9665 };
9666 const acceptsCategoriesDrag = (e) => {
9667 const types = e.dataTransfer?.types;
9668 if (!types) {
9669 return false;
9670 }
9671 return Array.from(types).includes(
9672 "application/x-desktop-mode-categories"
9673 );
9674 };
9675 wrap.addEventListener("dragenter", (e) => {
9676 if (!acceptsCategoriesDrag(e)) {
9677 return;
9678 }
9679 e.preventDefault();
9680 dropEnterCount++;
9681 setDropTargetActive(true);
9682 });
9683 wrap.addEventListener("dragover", (e) => {
9684 if (!acceptsCategoriesDrag(e)) {
9685 return;
9686 }
9687 e.preventDefault();
9688 if (e.dataTransfer) {
9689 e.dataTransfer.dropEffect = "copy";
9690 }
9691 });
9692 wrap.addEventListener("dragleave", () => {
9693 if (dropEnterCount > 0) {
9694 dropEnterCount--;
9695 }
9696 if (dropEnterCount === 0) {
9697 setDropTargetActive(false);
9698 }
9699 });
9700 wrap.addEventListener("drop", async (e) => {
9701 dropEnterCount = 0;
9702 setDropTargetActive(false);
9703 if (!acceptsCategoriesDrag(e)) {
9704 return;
9705 }
9706 e.preventDefault();
9707 const json = e.dataTransfer?.getData(
9708 "application/x-desktop-mode-categories"
9709 );
9710 if (!json) {
9711 return;
9712 }
9713 let parsed;
9714 try {
9715 parsed = JSON.parse(json);
9716 } catch {
9717 return;
9718 }
9719 const payload = parsed;
9720 if (!payload || !Array.isArray(payload.ids)) {
9721 return;
9722 }
9723 const incoming = [];
9724 for (const v of payload.ids) {
9725 if (typeof v === "number" && Number.isFinite(v)) {
9726 incoming.push(v);
9727 }
9728 }
9729 if (incoming.length === 0) {
9730 return;
9731 }
9732 if (payload.sourcePostId === row.id && incoming.every((id) => cellState.categoryIds.includes(id))) {
9733 return;
9734 }
9735 const merged = Array.from(
9736 /* @__PURE__ */ new Set([...cellState.categoryIds, ...incoming])
9737 );
9738 if (merged.length === cellState.categoryIds.length) {
9739 return;
9740 }
9741 const previous = cellState.categoryIds.slice();
9742 setValue(merged);
9743 try {
9744 await client.updatePostCategories(row.id, merged);
9745 const api = window.wp?.desktop;
9746 if (api && typeof api.broadcast === "function") {
9747 api.broadcast("desktop-mode.post.changed", {
9748 source: "posts-window",
9749 action: "categorized",
9750 ids: [row.id]
9751 });
9752 }
9753 } catch (err) {
9754 setValue(previous);
9755 showTagError(__("Couldn’t add category."), err);
9756 }
9757 });
9758 wrap.appendChild(picker);
9759 return wrap;
9760 }
9761 let _categoryTreePromise = null;
9762 function getCategoriesTree(client) {
9763 if (!_categoryTreePromise) {
9764 _categoryTreePromise = client.fetchAllCategories().then(
9765 (terms) => terms.map((t) => ({
9766 id: t.id,
9767 name: t.name,
9768 parent: t.parent
9769 }))
9770 );
9771 }
9772 return _categoryTreePromise;
9773 }
9774 function clearCategoryTreeCache() {
9775 _categoryTreePromise = null;
9776 }
9777 const _activePickers = /* @__PURE__ */ new Set();
9778 function broadcastFreshCategoryTreeToPickers(client) {
9779 void getCategoriesTree(client).then((tree) => {
9780 for (const picker of _activePickers) {
9781 if (picker.isConnected) {
9782 picker.items = tree;
9783 } else {
9784 _activePickers.delete(picker);
9785 }
9786 }
9787 }).catch(() => {
9788 });
9789 }
9790 async function primePickerFromCache(picker) {
9791 if (!_categoryTreePromise) {
9792 return;
9793 }
9794 try {
9795 picker.items = await _categoryTreePromise;
9796 } catch {
9797 }
9798 }
9799 function buildDateCell(row) {
9800 const wrap = document.createElement("span");
9801 wrap.style.cssText = "display:flex;flex-direction:column;line-height:1.2;";
9802 const time = document.createElement("wpd-relative-time");
9803 time.setAttribute("datetime", row.date);
9804 wrap.appendChild(time);
9805 if (row.modified_gmt && row.modified_gmt !== row.date_gmt) {
9806 const meta = document.createElement("span");
9807 meta.textContent = __("modified");
9808 meta.style.cssText = "font-size:11px;color:#646970;";
9809 wrap.appendChild(meta);
9810 }
9811 return wrap;
9812 }
9813 function buildSubRow(row) {
9814 const wrap = document.createElement("div");
9815 wrap.style.cssText = "display:flex;gap:16px;padding:12px 16px;background:#fafafa;align-items:flex-start;";
9816 const featured = featuredMediaOf(row);
9817 if (featured) {
9818 const img = document.createElement("img");
9819 img.src = featured.url;
9820 img.alt = featured.alt;
9821 img.loading = "lazy";
9822 img.style.cssText = "width:96px;height:96px;border-radius:6px;object-fit:cover;flex-shrink:0;";
9823 wrap.appendChild(img);
9824 }
9825 const text = document.createElement("div");
9826 text.style.cssText = "flex:1;min-width:0;display:flex;flex-direction:column;gap:6px;";
9827 const heading = document.createElement("div");
9828 heading.style.cssText = "font-size:13px;color:#646970;text-transform:uppercase;letter-spacing:0.04em;";
9829 heading.textContent = __("Excerpt");
9830 text.appendChild(heading);
9831 const excerpt = document.createElement("div");
9832 excerpt.style.cssText = "color:#1d2327;line-height:1.5;";
9833 const raw = row.excerpt?.rendered ?? "";
9834 if (raw) {
9835 const stripped = raw.replace(/<[^>]+>/g, "").trim();
9836 excerpt.textContent = stripped || __("(no excerpt)");
9837 } else {
9838 excerpt.textContent = __("(no excerpt)");
9839 excerpt.style.color = "#a7aaad";
9840 }
9841 text.appendChild(excerpt);
9842 wrap.appendChild(text);
9843 return wrap;
9844 }
9845 async function renderPostsWindow(body, client) {
9846 const root = body.querySelector(ROOT$1);
9847 const table = body.querySelector(TABLE$1);
9848 if (!root || !table) {
9849 return;
9850 }
9851 maybeShowIntro(client);
9852 const catsHost = body.querySelector(
9853 "[data-desktop-mode-posts-cats-host]"
9854 );
9855 const tagsHost = body.querySelector(
9856 "[data-desktop-mode-posts-tags-host]"
9857 );
9858 let catsTeardown = null;
9859 let tagsTeardown = null;
9860 const tabsEl = body.querySelector(".desktop-mode-posts__tabs");
9861 if (tabsEl) {
9862 tabsEl.addEventListener("wpd-tab-change", (e) => {
9863 const detail = e.detail;
9864 const value = detail?.value;
9865 if (value === "categories" && catsHost && !catsTeardown) {
9866 void Promise.resolve().then(() => categoriesMindmap).then(
9867 async ({ mountCategoriesMindmap: mountCategoriesMindmap2 }) => {
9868 catsTeardown = await mountCategoriesMindmap2(catsHost, client);
9869 }
9870 );
9871 }
9872 if (value === "tags" && tagsHost && !tagsTeardown) {
9873 void Promise.resolve().then(() => tagsCloud).then(
9874 async ({ mountTagsCloud: mountTagsCloud2 }) => {
9875 tagsTeardown = await mountTagsCloud2(tagsHost, client);
9876 }
9877 );
9878 }
9879 });
9880 }
9881 const cfg = client.getConfig();
9882 const view = {
9883 page: 1,
9884 perPage: Math.max(1, cfg.defaultPerPage || 20),
9885 search: "",
9886 status: "",
9887 orderby: "date",
9888 order: "desc",
9889 author: [],
9890 tag: [],
9891 searchDebounce: null
9892 };
9893 const cellCache = /* @__PURE__ */ new Map();
9894 const filterData = { authors: [], tags: [] };
9895 table.columns = buildColumns$1(cellCache, client, filterData);
9896 table.getRowId = (row) => row.id;
9897 table.subTable = (row) => buildSubRow(row);
9898 table.sort = { key: "date", direction: "desc" };
9899 let totalPages = 0;
9900 let totalRows = 0;
9901 let refreshSeq = 0;
9902 const perPageEl = root.querySelector(PER_PAGE$1);
9903 if (perPageEl) {
9904 perPageEl.value = String(view.perPage);
9905 }
9906 const indicator = root.querySelector(PAGE_INDICATOR$1);
9907 const prevBtn = root.querySelector(PREV$1);
9908 const nextBtn = root.querySelector(NEXT$1);
9909 const bulkBar = root.querySelector(BULK$1);
9910 const countEl = root.querySelector(COUNT$1);
9911 const bulkActionsHost = root.querySelector(BULK_ACTIONS_HOST$1);
9912 const trailingExtras = root.querySelector(
9913 TOOLBAR_TRAILING_EXTRAS
9914 );
9915 const statusHost = root.querySelector(STATUS$1);
9916 const statusSegments = resolveStatusSegments();
9917 if (statusHost) {
9918 statusHost.replaceChildren();
9919 for (const seg of statusSegments) {
9920 const el = document.createElement("wpd-segment");
9921 el.setAttribute("value", seg.value);
9922 el.textContent = seg.label;
9923 statusHost.appendChild(el);
9924 }
9925 statusHost.setAttribute("value", view.status);
9926 }
9927 const updatePager = () => {
9928 if (indicator) {
9929 if (totalRows === 0) {
9930 indicator.textContent = __("No posts");
9931 } else {
9932 indicator.textContent = sprintf(
9933 /* translators: 1: current page, 2: total pages, 3: total posts. */
9934 __("Page %1$d of %2$d · %3$d posts"),
9935 view.page,
9936 Math.max(totalPages, 1),
9937 totalRows
9938 );
9939 }
9940 }
9941 if (prevBtn) {
9942 prevBtn.toggleAttribute("disabled", view.page <= 1);
9943 }
9944 if (nextBtn) {
9945 nextBtn.toggleAttribute("disabled", view.page >= totalPages);
9946 }
9947 };
9948 const updateBulkBar = () => {
9949 if (!bulkBar || !countEl) {
9950 return;
9951 }
9952 const sel = Array.from(table.selection ?? []);
9953 if (sel.length === 0) {
9954 bulkBar.hidden = true;
9955 return;
9956 }
9957 bulkBar.hidden = false;
9958 countEl.textContent = sprintf(
9959 /* translators: %d: selected row count. */
9960 __("%d selected"),
9961 sel.length
9962 );
9963 };
9964 const clearSelectionOnQueryChange = () => {
9965 table.clearSelection();
9966 };
9967 const buildParams = () => ({
9968 page: view.page,
9969 perPage: view.perPage,
9970 search: view.search || void 0,
9971 status: view.status || void 0,
9972 orderby: view.orderby,
9973 order: view.order,
9974 author: view.author.length > 0 ? view.author : void 0,
9975 tag: view.tag.length > 0 ? view.tag : void 0
9976 });
9977 const ctx = {
9978 body,
9979 table,
9980 refresh: () => refresh(),
9981 getSelectedIds: () => Array.from(table.selection ?? []).map((id) => Number(id)),
9982 getSelectedRows: () => {
9983 const ids = new Set(ctx.getSelectedIds());
9984 return (table.data ?? []).filter((r) => ids.has(r.id));
9985 },
9986 getCurrentParams: () => buildParams()
9987 };
9988 const refresh = async () => {
9989 const mySeq = ++refreshSeq;
9990 table.toggleAttribute("loading", true);
9991 try {
9992 const result = await client.fetchPosts(buildParams());
9993 if (mySeq !== refreshSeq) {
9994 return;
9995 }
9996 if (result.items.length === 0 && view.page > 1 && result.totalPages > 0 && view.page > result.totalPages) {
9997 view.page = 1;
9998 await refresh();
9999 return;
10000 }
10001 cellCache.clear();
10002 refreshParentTitleRoster(result.items);
10003 table.data = result.items;
10004 totalRows = result.total;
10005 totalPages = result.totalPages;
10006 updatePager();
10007 const hooks2 = window.wp?.hooks;
10008 if (hooks2 && typeof hooks2.doAction === "function") {
10009 hooks2.doAction(HOOK_ACTION_DATA_LOADED, {
10010 items: result.items,
10011 total: result.total,
10012 totalPages: result.totalPages,
10013 page: view.page
10014 });
10015 }
10016 document.dispatchEvent(
10017 new CustomEvent("desktop-mode-posts-window-data-loaded", {
10018 detail: {
10019 items: result.items,
10020 total: result.total,
10021 totalPages: result.totalPages,
10022 page: view.page
10023 }
10024 })
10025 );
10026 } catch (err) {
10027 if (mySeq !== refreshSeq) {
10028 return;
10029 }
10030 console.error("[posts-window] list failed", err);
10031 table.data = [];
10032 totalRows = 0;
10033 totalPages = 0;
10034 updatePager();
10035 } finally {
10036 if (mySeq === refreshSeq) {
10037 table.toggleAttribute("loading", false);
10038 updateBulkBar();
10039 }
10040 }
10041 };
10042 const goToFirstPage = () => {
10043 if (view.page !== 1) {
10044 view.page = 1;
10045 }
10046 };
10047 root.querySelector(STATUS$1)?.addEventListener("wpd-pick", (e) => {
10048 const value = e.detail?.value ?? "";
10049 view.status = value;
10050 goToFirstPage();
10051 clearSelectionOnQueryChange();
10052 void refresh();
10053 });
10054 root.querySelector(SEARCH$1)?.addEventListener(
10055 "wpd-input-change",
10056 (e) => {
10057 const value = e.detail?.value ?? "";
10058 view.search = value;
10059 if (view.searchDebounce !== null) {
10060 window.clearTimeout(view.searchDebounce);
10061 }
10062 view.searchDebounce = window.setTimeout(() => {
10063 goToFirstPage();
10064 clearSelectionOnQueryChange();
10065 void refresh();
10066 }, SEARCH_DEBOUNCE_MS$1);
10067 }
10068 );
10069 body.addEventListener("click", (e) => {
10070 const target = e.target;
10071 if (!target) {
10072 return;
10073 }
10074 if (target.closest(REFRESH$1)) {
10075 void refresh();
10076 return;
10077 }
10078 if (target.closest(NEW_BTN$1)) {
10079 const isPages = cfg.mode === "pages";
10080 openAdminUrl(cfg.newPostUrl, {
10081 title: isPages ? __("Add New Page") : __("Add New Post"),
10082 icon: isPages ? "dashicons-admin-page" : "dashicons-admin-post"
10083 });
10084 return;
10085 }
10086 if (target.closest(PREV$1)) {
10087 if (view.page > 1) {
10088 view.page -= 1;
10089 clearSelectionOnQueryChange();
10090 void refresh();
10091 }
10092 return;
10093 }
10094 if (target.closest(NEXT$1)) {
10095 if (view.page < totalPages) {
10096 view.page += 1;
10097 clearSelectionOnQueryChange();
10098 void refresh();
10099 }
10100 }
10101 });
10102 const bulkActions = resolveBulkActions(client);
10103 if (bulkActionsHost) {
10104 bulkActionsHost.replaceChildren();
10105 for (const action of bulkActions) {
10106 bulkActionsHost.appendChild(buildBulkActionButton(action, ctx));
10107 }
10108 }
10109 if (trailingExtras) {
10110 const extras = resolveToolbarTrailing(ctx);
10111 trailingExtras.replaceChildren(...extras);
10112 }
10113 perPageEl?.addEventListener("change", () => {
10114 const next = parseInt(perPageEl.value, 10);
10115 if (!Number.isFinite(next) || next < 1) {
10116 return;
10117 }
10118 view.perPage = next;
10119 goToFirstPage();
10120 clearSelectionOnQueryChange();
10121 void refresh();
10122 });
10123 table.addEventListener("wpd-table-selection-change", () => {
10124 updateBulkBar();
10125 });
10126 table.addEventListener("wpd-table-sort-change", (e) => {
10127 const detail = e.detail;
10128 if (!detail || !detail.sort) {
10129 view.orderby = "date";
10130 view.order = "desc";
10131 } else {
10132 view.orderby = mapColumnToOrderby(detail.sort.key);
10133 view.order = detail.sort.direction;
10134 }
10135 clearSelectionOnQueryChange();
10136 void refresh();
10137 });
10138 const parseIds = (raw) => raw.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => Number.isFinite(n) && n > 0);
10139 const sameIds = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
10140 table.addEventListener("wpd-table-filter-change", (e) => {
10141 const detail = e.detail;
10142 const filters = detail?.filters ?? {};
10143 const nextAuthor = parseIds(filters.author ?? "");
10144 const nextTag = parseIds(filters.tags ?? "");
10145 const changed = !sameIds(nextAuthor, view.author) || !sameIds(nextTag, view.tag);
10146 if (!changed) {
10147 return;
10148 }
10149 view.author = nextAuthor;
10150 view.tag = nextTag;
10151 view.page = 1;
10152 clearSelectionOnQueryChange();
10153 void refresh();
10154 });
10155 activeRunBulkAction = async (action, actionCtx) => {
10156 const ids = actionCtx.getSelectedIds();
10157 if (ids.length === 0) {
10158 return;
10159 }
10160 if (action.confirm) {
10161 const ok = await wpdConfirmGlobal$1({
10162 message: sprintf(
10163 /* translators: %d: row count. */
10164 action.confirm,
10165 ids.length
10166 ),
10167 danger: true
10168 });
10169 if (!ok) {
10170 return;
10171 }
10172 }
10173 try {
10174 const result = await action.run(ids, actionCtx);
10175 if (result === false) {
10176 return;
10177 }
10178 } catch (err) {
10179 console.error(
10180 `[posts-window] bulk action "${action.id}" failed`,
10181 err
10182 );
10183 }
10184 table.clearSelection();
10185 await refresh();
10186 };
10187 const broadcastUnsubs = [];
10188 if (window.wp?.desktop && typeof window.wp.desktop.subscribe === "function") {
10189 const onChange = (payload) => {
10190 const detail = payload;
10191 if (detail?.source === "posts-window") {
10192 return;
10193 }
10194 void refresh();
10195 };
10196 broadcastUnsubs.push(
10197 window.wp.desktop.subscribe("desktop-mode.post.changed", onChange)
10198 );
10199 const onTermChange = (payload) => {
10200 const detail = payload;
10201 if (detail?.taxonomy === "category") {
10202 clearCategoryTreeCache();
10203 broadcastFreshCategoryTreeToPickers(client);
10204 }
10205 };
10206 broadcastUnsubs.push(
10207 window.wp.desktop.subscribe(
10208 "desktop-mode.term.changed",
10209 onTermChange
10210 )
10211 );
10212 }
10213 const repaintColumns = () => {
10214 cellCache.clear();
10215 table.columns = buildColumns$1(cellCache, client, filterData);
10216 };
10217 void client.fetchAuthorOptions().then((authors) => {
10218 filterData.authors = authors;
10219 repaintColumns();
10220 });
10221 let tagPage = 0;
10222 let tagTotalPages = 1;
10223 let tagFetching = false;
10224 const TAG_PAGE_SIZE = 50;
10225 const fetchNextTagPage = async () => {
10226 if (tagFetching || tagPage >= tagTotalPages) {
10227 return;
10228 }
10229 tagFetching = true;
10230 try {
10231 const next = tagPage + 1;
10232 const res = await client.fetchTagOptions(next, TAG_PAGE_SIZE);
10233 tagPage = next;
10234 tagTotalPages = Math.max(tagTotalPages, res.totalPages || next);
10235 const seen = new Set(filterData.tags.map((t) => t.id));
10236 for (const item of res.items) {
10237 if (!seen.has(item.id)) {
10238 filterData.tags.push(item);
10239 seen.add(item.id);
10240 }
10241 }
10242 filterData.tagsHasMore = tagPage < tagTotalPages;
10243 repaintColumns();
10244 } finally {
10245 tagFetching = false;
10246 }
10247 };
10248 filterData.loadMoreTags = () => {
10249 void fetchNextTagPage();
10250 };
10251 void fetchNextTagPage();
10252 const teardownKebabColumns = mountKebabColumnToggles(
10253 body,
10254 cellCache,
10255 repaintColumns,
10256 client
10257 );
10258 let unsubOsSettings = null;
10259 if (window.wp?.desktop && typeof window.wp.desktop.subscribeOsSettings === "function") {
10260 let lastHidden = JSON.stringify(
10261 Array.from(getHiddenColumns()).sort()
10262 );
10263 unsubOsSettings = window.wp.desktop.subscribeOsSettings(() => {
10264 const next = JSON.stringify(
10265 Array.from(getHiddenColumns()).sort()
10266 );
10267 if (next === lastHidden) {
10268 return;
10269 }
10270 lastHidden = next;
10271 repaintColumns();
10272 teardownKebabColumns?.refresh();
10273 });
10274 }
10275 const onWindowClosed = (e) => {
10276 const detail = e.detail;
10277 if (detail?.windowId !== "desktop-mode-posts") {
10278 return;
10279 }
10280 document.removeEventListener("desktop-mode-window-closed", onWindowClosed);
10281 for (const unsub of broadcastUnsubs) {
10282 try {
10283 unsub();
10284 } catch {
10285 }
10286 }
10287 broadcastUnsubs.length = 0;
10288 teardownKebabColumns?.dispose();
10289 unsubOsSettings?.();
10290 catsTeardown?.();
10291 catsTeardown = null;
10292 tagsTeardown?.();
10293 tagsTeardown = null;
10294 if (view.searchDebounce !== null) {
10295 window.clearTimeout(view.searchDebounce);
10296 view.searchDebounce = null;
10297 }
10298 clearCategoryTreeCache();
10299 };
10300 document.addEventListener("desktop-mode-window-closed", onWindowClosed);
10301 await refresh();
10302 const hooks = window.wp?.hooks;
10303 if (hooks && typeof hooks.doAction === "function") {
10304 hooks.doAction(HOOK_ACTION_OPENED, ctx);
10305 }
10306 document.dispatchEvent(
10307 new CustomEvent("desktop-mode-posts-window-opened", {
10308 detail: ctx
10309 })
10310 );
10311 }
10312 function buildBulkActionButton(action, ctx) {
10313 const btn = document.createElement("wpd-button");
10314 btn.setAttribute("variant", action.variant ?? "secondary");
10315 btn.setAttribute("data-desktop-mode-posts-bulk-action", action.id);
10316 if (action.icon) {
10317 const icon = document.createElement("span");
10318 icon.className = `dashicons ${action.icon}`;
10319 icon.setAttribute("aria-hidden", "true");
10320 btn.appendChild(icon);
10321 }
10322 btn.appendChild(document.createTextNode(" " + action.label));
10323 btn.addEventListener("click", () => {
10324 void runBulkActionFor(action, ctx);
10325 });
10326 return btn;
10327 }
10328 let activeRunBulkAction = async () => {
10329 };
10330 async function runBulkActionFor(action, ctx) {
10331 await activeRunBulkAction(action, ctx);
10332 }
10333 function openAdminUrl(url, opts = {}) {
10334 const api = window.wp?.desktop;
10335 if (!api || !api.windowManager || !api.deriveWindowId) {
10336 window.location.href = url;
10337 return;
10338 }
10339 const id = api.deriveWindowId(url);
10340 api.windowManager.open({
10341 id,
10342 baseId: id,
10343 url,
10344 title: opts.title ?? url,
10345 icon: opts.icon ?? "dashicons-admin-generic"
10346 });
10347 }
10348 function mapColumnToOrderby(key) {
10349 switch (key) {
10350 case "title":
10351 return "title";
10352 case "author":
10353 return "author";
10354 case "date":
10355 return "date";
10356 case "modified":
10357 return "modified";
10358 case "comments":
10359 return "comment_count";
10360 default:
10361 return "date";
10362 }
10363 }
10364 const registry = window.desktopModeNativeWindows ?? (window.desktopModeNativeWindows = {});
10365 registry["desktop-mode-posts"] = (body) => {
10366 const client = createPostsWindowClient("desktop-mode-posts");
10367 return renderPostsWindow(body, client).catch((err) => {
10368 console.error("[posts-window] render failed:", err);
10369 });
10370 };
10371 registry["desktop-mode-pages"] = (body) => {
10372 const client = createPostsWindowClient("desktop-mode-pages");
10373 return renderPostsWindow(body, client).catch((err) => {
10374 console.error("[pages-window] render failed:", err);
10375 });
10376 };
10377 registry["desktop-mode-users"] = (body) => {
10378 const client = createUsersWindowClient("desktop-mode-users");
10379 return Promise.resolve().then(() => usersRender).then((m) => m.renderUsersWindow(body, client)).catch((err) => {
10380 console.error("[users-window] render failed:", err);
10381 });
10382 };
10383 registry["desktop-mode-user-edit"] = (body) => {
10384 const profile = body.querySelector(
10385 "wpd-user-profile[data-wpd-user-profile-host]"
10386 );
10387 if (!profile) {
10388 return;
10389 }
10390 void Promise.resolve().then(() => userEditTarget).then((target) => {
10391 const pending = target.readUserEditTarget();
10392 let userId = pending.userId && pending.userId > 0 ? pending.userId : 0;
10393 if (userId <= 0) {
10394 try {
10395 userId = window.desktopModeWindowConfig?.["desktop-mode-user-edit"]?.currentUserId ?? 0;
10396 } catch {
10397 userId = 0;
10398 }
10399 }
10400 if (userId > 0) {
10401 profile.setAttribute("user-id", String(userId));
10402 }
10403 target.clearUserEditTarget();
10404 target.subscribeUserEditTarget((next) => {
10405 if (!profile.isConnected) {
10406 return;
10407 }
10408 if (next.userId && next.userId > 0 && next.userId !== userId) {
10409 userId = next.userId;
10410 profile.setAttribute("user-id", String(userId));
10411 target.clearUserEditTarget();
10412 }
10413 });
10414 });
10415 };
10416 function createUserEditClient(windowId = "desktop-mode-user-edit") {
10417 const getConfig = () => {
10418 const store = window.desktopModeWindowConfig;
10419 const cfg = store?.[windowId];
10420 if (!cfg) {
10421 throw new Error(
10422 `[${windowId}] config blob is missing — was the window opened without registration? See \`includes/user-edit-window/window.php\`.`
10423 );
10424 }
10425 return cfg;
10426 };
10427 const shellFetch = (input, init, source) => {
10428 return trackedFetch(input, init, {
10429 windowId,
10430 source: source ?? "user-edit-window/rest"
10431 });
10432 };
10433 const fetchUser = async (id) => {
10434 const cfg = getConfig();
10435 const base = cfg.usersUrl ?? joinRestUrl(cfg.restRoot, "wp/v2/users");
10436 const url = joinRestUrl(base, `${id}?context=edit`);
10437 const res = await shellFetch(
10438 url,
10439 {
10440 method: "GET",
10441 credentials: "same-origin",
10442 headers: {
10443 Accept: "application/json",
10444 "X-WP-Nonce": cfg.restNonce
10445 }
10446 },
10447 "user-edit-window/load"
10448 );
10449 if (!res.ok) {
10450 throw new Error(`[user-edit] load failed: ${res.status}`);
10451 }
10452 return await res.json();
10453 };
10454 const saveUser = async (id, patch) => {
10455 const cfg = getConfig();
10456 const base = cfg.usersUrl ?? joinRestUrl(cfg.restRoot, "wp/v2/users");
10457 const res = await shellFetch(
10458 joinRestUrl(base, `${id}?context=edit`),
10459 {
10460 method: "POST",
10461 // PUT == POST for WP REST when X-HTTP-Method-Override is unsupported.
10462 credentials: "same-origin",
10463 headers: {
10464 "Content-Type": "application/json",
10465 "X-WP-Nonce": cfg.restNonce,
10466 "X-HTTP-Method-Override": "PUT"
10467 },
10468 body: JSON.stringify(patch)
10469 },
10470 "user-edit-window/save"
10471 );
10472 if (!res.ok) {
10473 const data = await res.json().catch(() => ({}));
10474 const fieldErrors = {};
10475 const params = data.data?.params;
10476 if (params && typeof params === "object") {
10477 for (const [k, v] of Object.entries(params)) {
10478 fieldErrors[k] = String(v);
10479 }
10480 }
10481 return {
10482 ok: false,
10483 error: data.code ?? `http_${res.status}`,
10484 message: data.message,
10485 fieldErrors
10486 };
10487 }
10488 const user = await res.json();
10489 return { ok: true, user };
10490 };
10491 const fetchInsights = async (id, opts = {}) => {
10492 const cfg = getConfig();
10493 const base = cfg.insightsUrlBase ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/");
10494 const url = new URL(joinRestUrl(base, `${id}/insights`));
10495 if (opts.fresh) {
10496 url.searchParams.set("fresh", "1");
10497 }
10498 const res = await shellFetch(
10499 url.toString(),
10500 {
10501 method: "GET",
10502 credentials: "same-origin",
10503 headers: {
10504 Accept: "application/json",
10505 "X-WP-Nonce": cfg.restNonce
10506 }
10507 },
10508 "user-edit-window/insights"
10509 );
10510 if (!res.ok) {
10511 throw new Error(`[user-edit] insights failed: ${res.status}`);
10512 }
10513 return await res.json();
10514 };
10515 return {
10516 windowId,
10517 getConfig,
10518 fetchUser,
10519 saveUser,
10520 fetchInsights
10521 };
10522 }
10523 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}`;
10524 const _WpdCheckboxLabel = class _WpdCheckboxLabel extends Component {
10525 render() {
10526 const label = this.label || "";
10527 const checked = this.checked !== null;
10528 const disabled = this.disabled !== null;
10529 return html`
10530 <label>
10531 <input
10532 type="checkbox"
10533 ?checked=${checked}
10534 ?disabled=${disabled}
10535 @change=${(e) => this._onChange(e)}
10536 />
10537 <span class="wpd-checkbox-label__text">${label}</span>
10538 </label>
10539 `;
10540 }
10541 _onChange(e) {
10542 if (this.disabled !== null) {
10543 return;
10544 }
10545 const next = e.target.checked;
10546 if (next) {
10547 this.setAttribute("checked", "");
10548 } else {
10549 this.removeAttribute("checked");
10550 }
10551 this.emit("wpd-checkbox-change", { checked: next });
10552 }
10553 };
10554 _WpdCheckboxLabel.props = ["label", "checked", "disabled"];
10555 _WpdCheckboxLabel.styles = [styles$1];
10556 _WpdCheckboxLabel.help = {
10557 title: "Checkbox label",
10558 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.",
10559 status: "stable",
10560 since: "0.9.0",
10561 props: [
10562 {
10563 name: "label",
10564 type: "string",
10565 description: "Visible label text, paired with the checkbox via a native <label>."
10566 },
10567 {
10568 name: "checked",
10569 type: "boolean attribute",
10570 description: "Reflects and controls the checked state."
10571 },
10572 {
10573 name: "disabled",
10574 type: "boolean attribute",
10575 description: "When present, the checkbox is not interactive and dimmed."
10576 }
10577 ],
10578 events: [
10579 {
10580 name: "wpd-checkbox-change",
10581 description: "Fires when the user toggles the checkbox.",
10582 detail: "{ checked: boolean }"
10583 }
10584 ],
10585 cssProps: [
10586 { name: "--desktop-mode-text", description: "Label colour." }
10587 ],
10588 example: html`
10589 <wpd-checkbox-label label="Reduce motion" checked></wpd-checkbox-label>
10590 `
10591 };
10592 let WpdCheckboxLabel = _WpdCheckboxLabel;
10593 defineComponent("wpd-checkbox-label", WpdCheckboxLabel);
10594 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}`;
10595 let _cache = null;
10596 function parseCssContentToChar(raw) {
10597 let value = raw.trim();
10598 if (value === "") {
10599 return null;
10600 }
10601 if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
10602 value = value.slice(1, -1);
10603 }
10604 const escaped = value.match(/^\\([0-9a-f]{1,6})\s?$/i);
10605 if (escaped) {
10606 return String.fromCodePoint(parseInt(escaped[1], 16));
10607 }
10608 return value || null;
10609 }
10610 function buildMap() {
10611 const map = /* @__PURE__ */ new Map();
10612 if (typeof document === "undefined") {
10613 return map;
10614 }
10615 const sheets = Array.from(document.styleSheets ?? []);
10616 for (const sheet of sheets) {
10617 let rules = null;
10618 try {
10619 rules = sheet.cssRules;
10620 } catch {
10621 continue;
10622 }
10623 if (!rules) {
10624 continue;
10625 }
10626 for (const rule of Array.from(rules)) {
10627 const styleRule = rule;
10628 if (!styleRule || !styleRule.selectorText) {
10629 continue;
10630 }
10631 const match = styleRule.selectorText.match(
10632 /\.dashicons-([a-z0-9-]+)::?before/i
10633 );
10634 if (!match) {
10635 continue;
10636 }
10637 const content = styleRule.style?.content;
10638 if (!content) {
10639 continue;
10640 }
10641 const char = parseCssContentToChar(content);
10642 if (char) {
10643 map.set(match[1], char);
10644 }
10645 }
10646 }
10647 return map;
10648 }
10649 function resolveDashicon(name) {
10650 if (!_cache) {
10651 _cache = buildMap();
10652 }
10653 const slug = name.startsWith("dashicons-") ? name.slice("dashicons-".length) : name;
10654 return _cache.get(slug) ?? null;
10655 }
10656 function refreshDashiconCache() {
10657 _cache = buildMap();
10658 }
10659 let _scheduled = false;
10660 function primeOnLoad() {
10661 if (_scheduled || typeof window === "undefined") {
10662 return;
10663 }
10664 _scheduled = true;
10665 const refresh = () => {
10666 refreshDashiconCache();
10667 };
10668 if (document.readyState === "loading") {
10669 document.addEventListener("DOMContentLoaded", refresh, { once: true });
10670 }
10671 window.addEventListener("load", refresh, { once: true });
10672 }
10673 primeOnLoad();
10674 const _WpdIcon = class _WpdIcon extends Component {
10675 render() {
10676 const rawName = this.name || "";
10677 const slug = rawName.startsWith("dashicons-") ? rawName.slice("dashicons-".length) : rawName;
10678 const size = this.size;
10679 if (size && /^\d+$/.test(size)) {
10680 this.style.setProperty("--wpd-icon-size", `${size}px`);
10681 }
10682 const char = resolveDashicon(slug);
10683 if (char) {
10684 return html`<span
10685 class="wpd-icon__glyph wpd-icon__glyph--char dashicons dashicons-${slug}"
10686 aria-hidden="true"
10687 >${char}</span>`;
10688 }
10689 return html`<span
10690 class="wpd-icon__glyph dashicons dashicons-${slug}"
10691 aria-hidden="true"
10692 ></span>`;
10693 }
10694 };
10695 _WpdIcon.props = ["name", "size"];
10696 _WpdIcon.styles = [styles];
10697 _WpdIcon.help = {
10698 title: "Icon",
10699 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.',
10700 status: "stable",
10701 since: "0.5.0",
10702 props: [
10703 {
10704 name: "name",
10705 type: "string",
10706 description: "Dashicon identifier, with or without the `dashicons-` prefix."
10707 },
10708 {
10709 name: "size",
10710 type: "integer (px)",
10711 default: "16",
10712 description: "Glyph size in pixels."
10713 }
10714 ],
10715 cssProps: [
10716 { name: "--wpd-icon-size", default: "16px" }
10717 ],
10718 example: html`
10719 <wpd-cluster gap="8" align="center">
10720 <wpd-icon name="admin-post"></wpd-icon>
10721 <wpd-icon name="calculator" size="20"></wpd-icon>
10722 <wpd-icon name="dashicons-star-filled" size="32"></wpd-icon>
10723 </wpd-cluster>
10724 `
10725 };
10726 let WpdIcon = _WpdIcon;
10727 defineComponent("wpd-icon", WpdIcon);
10728 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}`;
10729 const _WpdTextField = class _WpdTextField extends Component {
10730 constructor() {
10731 super(...arguments);
10732 this._revealed = false;
10733 }
10734 connectedCallback() {
10735 super.connectedCallback();
10736 ensureAutoId(this);
10737 }
10738 render() {
10739 const label = this.label || "";
10740 const value = this.value ?? "";
10741 const placeholder = this.placeholder || "";
10742 const disabled = this.disabled !== null;
10743 const readonly = this.readonly !== null;
10744 const declaredAutocomplete = this.autocomplete;
10745 const declaredType = this.type || "text";
10746 const isPassword = declaredType === "password";
10747 let autocomplete = declaredAutocomplete || "off";
10748 if (isPassword && (!declaredAutocomplete || autocomplete === "off")) {
10749 autocomplete = "new-password";
10750 }
10751 const maxLength = this.maxlength;
10752 const minLength = this.minlength;
10753 const pattern = this.pattern || "";
10754 const name = this.name || "";
10755 const suffix = this.suffix || "";
10756 const invalid = this.invalid !== null;
10757 const reveal = this.reveal !== null;
10758 const isPasswordIntent = declaredType === "password";
10759 const isMasked = isPasswordIntent && !(reveal && this._revealed);
10760 let effectiveType;
10761 if (isPasswordIntent) {
10762 effectiveType = "text";
10763 } else if (reveal && this._revealed) {
10764 effectiveType = "text";
10765 } else {
10766 effectiveType = declaredType;
10767 }
10768 const rowClass = reveal ? "wpd-text-field__row wpd-text-field__row--has-reveal" : "wpd-text-field__row";
10769 const inputClass = isMasked ? "wpd-text-field__input wpd-text-field__input--masked" : "wpd-text-field__input";
10770 const hostId = this.id || "wpd-unnamed";
10771 const inputId = `${hostId}__input`;
10772 return html`
10773 ${label ? html`<label
10774 class="wpd-text-field__label"
10775 for=${inputId}
10776 >${label}</label>` : html``}
10777 <span class=${rowClass}>
10778 <input
10779 id=${inputId}
10780 class=${inputClass}
10781 type=${effectiveType}
10782 .value=${value}
10783 placeholder=${placeholder}
10784 ?disabled=${disabled}
10785 ?readonly=${readonly}
10786 autocomplete=${autocomplete}
10787 maxlength=${maxLength ?? ""}
10788 minlength=${minLength ?? ""}
10789 pattern=${pattern}
10790 name=${name}
10791 aria-invalid=${invalid ? "true" : "false"}
10792 aria-label=${label || ""}
10793 @input=${(e) => this._onInput(e)}
10794 @change=${(e) => this._onChange(e)}
10795 @keydown=${(e) => this._onKeyDown(e)}
10796 />
10797 ${suffix ? html`<span class="wpd-text-field__suffix">${suffix}</span>` : html``}
10798 ${reveal ? this._renderRevealButton(disabled) : html``}
10799 </span>
10800 `;
10801 }
10802 _renderRevealButton(disabled) {
10803 const label = this._revealed ? "Hide" : "Show";
10804 return html`
10805 <button
10806 type="button"
10807 class="wpd-text-field__reveal"
10808 aria-label=${label}
10809 aria-pressed=${this._revealed ? "true" : "false"}
10810 ?disabled=${disabled}
10811 tabindex="0"
10812 @click=${() => this._onToggleReveal()}
10813 >
10814 ${this._revealed ? _iconEyeOff() : _iconEye()}
10815 </button>
10816 `;
10817 }
10818 _onToggleReveal() {
10819 this._revealed = !this._revealed;
10820 this.requestUpdate();
10821 }
10822 _onInput(e) {
10823 const input = e.target;
10824 this.value = input.value;
10825 this.emit("wpd-input-change", { value: input.value });
10826 }
10827 _onChange(e) {
10828 const input = e.target;
10829 this.emit("wpd-input-commit", { value: input.value });
10830 }
10831 _onKeyDown(e) {
10832 if (e.key === "Enter" && !e.shiftKey && !e.altKey && !e.metaKey) {
10833 const input = e.target;
10834 this.emit("wpd-submit", { value: input.value });
10835 }
10836 }
10837 };
10838 _WpdTextField.props = [
10839 "label",
10840 "value",
10841 "placeholder",
10842 "disabled",
10843 "readonly",
10844 "autocomplete",
10845 "type",
10846 "maxlength",
10847 "minlength",
10848 "pattern",
10849 "name",
10850 "suffix",
10851 "invalid",
10852 "reveal"
10853 ];
10854 _WpdTextField.styles = [textFieldStyles];
10855 _WpdTextField.help = {
10856 title: "Text field",
10857 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.",
10858 status: "stable",
10859 since: "0.5.0",
10860 props: [
10861 { name: "label", type: "string", description: "Visible label above the input." },
10862 { name: "value", type: "string", description: "Current input value; reflected two-way." },
10863 { name: "placeholder", type: "string", description: "Native placeholder string." },
10864 { name: "disabled", type: "boolean attribute", description: "Disables the native input." },
10865 { name: "readonly", type: "boolean attribute", description: "Marks the input readonly." },
10866 {
10867 name: "autocomplete",
10868 type: "string",
10869 default: "off",
10870 description: "Forwarded to the native input autocomplete attribute."
10871 },
10872 {
10873 name: "type",
10874 type: "string",
10875 default: "text",
10876 description: "Native input type (text, password, email, search, tel, url)."
10877 },
10878 { name: "maxlength", type: "integer (string)", description: "Native maxlength." },
10879 { name: "minlength", type: "integer (string)", description: "Native minlength." },
10880 { name: "pattern", type: "regex string", description: "Native validation pattern." },
10881 { name: "name", type: "string", description: "Forwarded to the native input for form submission." },
10882 { name: "suffix", type: "string", description: "Text rendered inside the right edge of the input row." },
10883 {
10884 name: "invalid",
10885 type: "boolean attribute",
10886 description: "Marks the field aria-invalid and applies the error style."
10887 },
10888 {
10889 name: "reveal",
10890 type: "boolean attribute",
10891 description: 'On type="password" fields, adds an eye-icon toggle that flips the input between hidden and visible text.'
10892 }
10893 ],
10894 events: [
10895 {
10896 name: "wpd-input-change",
10897 description: "Fires on every input keystroke.",
10898 detail: "{ value: string }"
10899 },
10900 {
10901 name: "wpd-input-commit",
10902 description: "Fires on the native change event (blur / Enter).",
10903 detail: "{ value: string }"
10904 },
10905 {
10906 name: "wpd-submit",
10907 description: "Fires when the user presses Enter (without Shift/Alt/Meta).",
10908 detail: "{ value: string }"
10909 }
10910 ],
10911 cssProps: [
10912 { name: "--desktop-mode-text", description: "Text colour." },
10913 { name: "--desktop-mode-muted", description: "Label + suffix colour." },
10914 { name: "--desktop-mode-border", description: "Input outline." },
10915 { name: "--desktop-mode-window-bg", description: "Input background." }
10916 ],
10917 example: html`
10918 <wpd-stack gap="8">
10919 <wpd-text-field label="Note title" value="Untitled" placeholder="Name this note"></wpd-text-field>
10920 <wpd-text-field type="password" reveal label="API key"></wpd-text-field>
10921 </wpd-stack>
10922 `
10923 };
10924 let WpdTextField = _WpdTextField;
10925 defineComponent("wpd-text-field", WpdTextField);
10926 function _iconEye() {
10927 return html`
10928 <svg
10929 viewBox="0 0 16 16"
10930 width="14"
10931 height="14"
10932 fill="none"
10933 stroke="currentColor"
10934 stroke-width="1.5"
10935 stroke-linecap="round"
10936 stroke-linejoin="round"
10937 aria-hidden="true"
10938 focusable="false"
10939 >
10940 <path d="M1 8C1 8 3.5 3 8 3s7 5 7 5-2.5 5-7 5S1 8 1 8z" />
10941 <circle cx="8" cy="8" r="2" />
10942 </svg>
10943 `;
10944 }
10945 function _iconEyeOff() {
10946 return html`
10947 <svg
10948 viewBox="0 0 16 16"
10949 width="14"
10950 height="14"
10951 fill="none"
10952 stroke="currentColor"
10953 stroke-width="1.5"
10954 stroke-linecap="round"
10955 stroke-linejoin="round"
10956 aria-hidden="true"
10957 focusable="false"
10958 >
10959 <path d="M1 8C1 8 3.5 3 8 3s7 5 7 5-2.5 5-7 5S1 8 1 8z" />
10960 <circle cx="8" cy="8" r="2" />
10961 <line x1="2" y1="2" x2="14" y2="14" />
10962 </svg>
10963 `;
10964 }
10965 function resolveUserEditClient() {
10966 const store = window.desktopModeWindowConfig;
10967 if (store?.["desktop-mode-user-edit"]) {
10968 return createUserEditClient("desktop-mode-user-edit");
10969 }
10970 if (store?.["desktop-mode-users"]) {
10971 return createUserEditClient("desktop-mode-users");
10972 }
10973 return createUserEditClient("desktop-mode-user-edit");
10974 }
10975 function notifyToast$1(body, kind = "info") {
10976 const api = window.wp?.desktop;
10977 if (api?.showToast) {
10978 let duration;
10979 if (kind === "error") {
10980 duration = 8e3;
10981 } else if (kind === "success") {
10982 duration = 5e3;
10983 }
10984 api.showToast({ message: body, duration });
10985 return;
10986 }
10987 console.info("[user-edit-window]", body);
10988 }
10989 async function mountProfileFormAt(host, userId) {
10990 return loadAndMountProfile(host, userId);
10991 }
10992 async function mountProfileAsideAt(host, userId, fresh) {
10993 return renderInsightsAside(host, userId, fresh);
10994 }
10995 async function mountProfileActivityAt(host, userId, fresh) {
10996 return renderInsightsActivity(host, userId, fresh);
10997 }
10998 async function loadAndMountProfile(host, userId) {
10999 host.replaceChildren();
11000 const skeleton = document.createElement("div");
11001 skeleton.className = "desktop-mode-user-edit__skeleton";
11002 skeleton.style.cssText = "display:flex;align-items:center;justify-content:center;padding:48px;color:var(--desktop-mode-muted, #50575e);font-size:13px;";
11003 skeleton.textContent = __("Loading profile…");
11004 host.appendChild(skeleton);
11005 let user;
11006 try {
11007 user = await resolveUserEditClient().fetchUser(userId);
11008 } catch (err) {
11009 host.replaceChildren();
11010 const msg = document.createElement("p");
11011 msg.style.cssText = "padding:32px;color:#b32d2e;font-size:13px;text-align:center;";
11012 msg.textContent = sprintf(
11013 // translators: %s is an error message.
11014 __("Could not load profile (%s)."),
11015 String(err.message ?? err)
11016 );
11017 host.appendChild(msg);
11018 throw err;
11019 }
11020 host.replaceChildren();
11021 mountProfileForm(host, user, userId);
11022 return user;
11023 }
11024 function resolveProfileConfig() {
11025 const store = window.desktopModeWindowConfig;
11026 const userEdit = store?.["desktop-mode-user-edit"];
11027 const users = store?.["desktop-mode-users"];
11028 return {
11029 ...users ?? {},
11030 ...userEdit ?? {}
11031 };
11032 }
11033 function mountProfileForm(host, user, userId) {
11034 const cfg = resolveProfileConfig();
11035 const wrap = document.createElement("div");
11036 wrap.className = "desktop-mode-user-edit__profile";
11037 const form = document.createElement("wpd-form");
11038 form.setAttribute("submit-label", __("Save changes"));
11039 form.setAttribute("reset-label", __("Revert"));
11040 form.setAttribute("columns", "auto");
11041 const header = document.createElement("div");
11042 header.setAttribute("slot", "header");
11043 let profileHeader = buildProfileHeader(user);
11044 header.appendChild(profileHeader);
11045 form.appendChild(header);
11046 form.appendChild(textField("username", __("Username"), user.username, {
11047 readonly: true
11048 }));
11049 form.appendChild(textField("first_name", __("First name"), user.first_name));
11050 form.appendChild(textField("last_name", __("Last name"), user.last_name));
11051 form.appendChild(
11052 textField("nickname", __("Nickname"), user.nickname ?? "", {
11053 required: true,
11054 fullWidth: false
11055 })
11056 );
11057 const displaySelect = document.createElement("wpd-select");
11058 displaySelect.setAttribute("name", "name");
11059 displaySelect.setAttribute("label", __("Display name publicly as"));
11060 displaySelect.items = displayNameCandidates(user);
11061 displaySelect.value = user.name;
11062 form.appendChild(displaySelect);
11063 form.appendChild(
11064 textField("email", __("Email (required)"), user.email, {
11065 required: true,
11066 type: "email"
11067 })
11068 );
11069 form.appendChild(textField("url", __("Website"), user.url, { type: "url" }));
11070 const contactMethods = cfg.contactMethods ?? {};
11071 for (const [slug, label] of Object.entries(contactMethods)) {
11072 const value = typeof user.meta === "object" && user.meta !== null ? String(
11073 user.meta[slug] ?? ""
11074 ) : "";
11075 form.appendChild(
11076 textField(`meta.${slug}`, label, value, {
11077 dataset: { meta: slug }
11078 })
11079 );
11080 }
11081 const bio = document.createElement("wpd-textarea");
11082 bio.setAttribute("name", "description");
11083 bio.setAttribute("label", __("Biographical info"));
11084 bio.setAttribute(
11085 "placeholder",
11086 __("Share a little about yourself — visible on author archives.")
11087 );
11088 bio.setAttribute("rows", "4");
11089 bio.setAttribute("full-width", "");
11090 bio.value = user.description;
11091 bio.setAttribute("value", user.description);
11092 form.appendChild(bio);
11093 const localeSelect = document.createElement("wpd-select");
11094 localeSelect.setAttribute("name", "locale");
11095 localeSelect.setAttribute("label", __("Language"));
11096 const locales = cfg.locales ?? { "": __("Site default") };
11097 localeSelect.items = Object.entries(locales).map(([value, label]) => ({
11098 value,
11099 label
11100 }));
11101 localeSelect.value = String(user.locale ?? "");
11102 form.appendChild(localeSelect);
11103 const isSelfEdit = userId === (cfg.currentUserId ?? 0);
11104 const roleMap = (() => {
11105 const assignable = cfg.assignableRoles;
11106 if (assignable && Object.keys(assignable).length > 0) {
11107 return assignable;
11108 }
11109 return cfg.allRoles ?? {};
11110 })();
11111 if (!isSelfEdit) {
11112 const roleSelect = document.createElement("wpd-select");
11113 roleSelect.setAttribute("name", "roles[0]");
11114 roleSelect.setAttribute("label", __("Role"));
11115 roleSelect.items = Object.entries(roleMap).map(([value, label]) => ({
11116 value,
11117 label
11118 }));
11119 const currentRole = Array.isArray(user.roles) ? user.roles[0] ?? "" : "";
11120 roleSelect.value = currentRole;
11121 form.appendChild(roleSelect);
11122 }
11123 {
11124 const optsHeading = document.createElement("h3");
11125 optsHeading.setAttribute("full-width", "");
11126 optsHeading.textContent = __("Personal options");
11127 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);";
11128 form.appendChild(optsHeading);
11129 const meta = user.meta ?? {};
11130 const richEditing = String(meta.rich_editing ?? "") !== "false";
11131 const syntaxHighlighting = String(meta.syntax_highlighting ?? "") !== "false";
11132 const commentShortcuts = String(meta.comment_shortcuts ?? "false") === "true";
11133 const adminBarFront = String(meta.show_admin_bar_front ?? "true") !== "false";
11134 form.appendChild(
11135 checkboxField(
11136 "meta.rich_editing",
11137 __("Disable the visual editor when writing"),
11138 !richEditing,
11139 { trueValue: "false", falseValue: "true", fullWidth: true }
11140 )
11141 );
11142 form.appendChild(
11143 checkboxField(
11144 "meta.syntax_highlighting",
11145 __("Disable syntax highlighting when editing code"),
11146 !syntaxHighlighting,
11147 { trueValue: "false", falseValue: "true", fullWidth: true }
11148 )
11149 );
11150 form.appendChild(
11151 checkboxField(
11152 "meta.comment_shortcuts",
11153 __("Enable keyboard shortcuts for comment moderation"),
11154 commentShortcuts,
11155 { trueValue: "true", falseValue: "false", fullWidth: true }
11156 )
11157 );
11158 form.appendChild(
11159 checkboxField(
11160 "meta.show_admin_bar_front",
11161 __("Show toolbar when viewing site"),
11162 adminBarFront,
11163 { trueValue: "true", falseValue: "false", fullWidth: true }
11164 )
11165 );
11166 const colorSchemes = cfg.colorSchemes ?? {};
11167 const currentScheme = String(meta.admin_color ?? "fresh");
11168 form.appendChild(
11169 buildAdminColorPicker(colorSchemes, currentScheme, {
11170 livePreview: isSelfEdit
11171 })
11172 );
11173 }
11174 const pwdHeading = document.createElement("h3");
11175 pwdHeading.setAttribute("full-width", "");
11176 pwdHeading.textContent = __("Account management");
11177 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);";
11178 form.appendChild(pwdHeading);
11179 const pwdRow = document.createElement("div");
11180 pwdRow.setAttribute("full-width", "");
11181 pwdRow.style.cssText = "display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;";
11182 const pwd = document.createElement("wpd-text-field");
11183 pwd.setAttribute("name", "password");
11184 pwd.setAttribute("type", "password");
11185 pwd.setAttribute("reveal", "");
11186 pwd.setAttribute("label", __("New password"));
11187 pwd.setAttribute(
11188 "placeholder",
11189 __("Leave blank to keep the current password.")
11190 );
11191 pwd.setAttribute("autocomplete", "new-password");
11192 pwd.style.flex = "1 1 280px";
11193 pwdRow.appendChild(pwd);
11194 const genBtn = document.createElement("wpd-button");
11195 genBtn.setAttribute("variant", "ghost");
11196 genBtn.setAttribute("type", "button");
11197 const genIcon = document.createElement("wpd-icon");
11198 genIcon.setAttribute("name", "randomize");
11199 genIcon.setAttribute("size", "14");
11200 genBtn.appendChild(genIcon);
11201 genBtn.appendChild(document.createTextNode(__("Generate strong")));
11202 genBtn.addEventListener("click", (e) => {
11203 e.preventDefault();
11204 const next = generateStrongPassword$1(18);
11205 pwd.value = next;
11206 pwd.setAttribute("value", next);
11207 const pwdConfirmEl = form.querySelector(
11208 'wpd-text-field[name="password_confirm"]'
11209 );
11210 if (pwdConfirmEl) {
11211 pwdConfirmEl.value = next;
11212 pwdConfirmEl.setAttribute("value", next);
11213 }
11214 void navigator.clipboard?.writeText(next).catch(() => {
11215 });
11216 notifyToast$1(__("Password generated and copied to clipboard."), "success");
11217 });
11218 pwdRow.appendChild(genBtn);
11219 form.appendChild(pwdRow);
11220 const pwdConfirm = document.createElement("wpd-text-field");
11221 pwdConfirm.setAttribute("name", "password_confirm");
11222 pwdConfirm.setAttribute("type", "password");
11223 pwdConfirm.setAttribute("reveal", "");
11224 pwdConfirm.setAttribute("label", __("Confirm new password"));
11225 pwdConfirm.setAttribute(
11226 "placeholder",
11227 __("Type the new password again.")
11228 );
11229 pwdConfirm.setAttribute("autocomplete", "new-password");
11230 pwdConfirm.setAttribute("full-width", "");
11231 form.appendChild(pwdConfirm);
11232 form.appendChild(
11233 buildSessionsRow(userId, isSelfEdit)
11234 );
11235 form.appendChild(buildAppPasswordsRow(userId));
11236 if (!isSelfEdit && cfg.isMultisite && user.meta?.is_super_admin !== void 0) {
11237 form.appendChild(
11238 checkboxField(
11239 "meta.is_super_admin",
11240 __("Grant super admin privileges for the network"),
11241 Boolean(
11242 user.meta?.is_super_admin
11243 ),
11244 { trueValue: "true", falseValue: "false", fullWidth: true }
11245 )
11246 );
11247 }
11248 let pending = false;
11249 form.addEventListener("wpd-form-submit", (e) => {
11250 const detail = e.detail;
11251 void onSubmit(detail.values);
11252 });
11253 const onSubmit = async (values) => {
11254 if (pending) {
11255 return;
11256 }
11257 pending = true;
11258 form.setBusy(true);
11259 form.clearErrors();
11260 const patch = {
11261 first_name: values.first_name,
11262 last_name: values.last_name,
11263 nickname: values.nickname,
11264 name: values.name,
11265 email: values.email,
11266 url: values.url,
11267 description: values.description,
11268 locale: values.locale ?? ""
11269 };
11270 if (typeof values.password === "string" && values.password !== "") {
11271 const confirm = String(values.password_confirm ?? "");
11272 if (confirm !== values.password) {
11273 form.setError(__("The two password fields do not match."));
11274 form.setFieldInvalid("password_confirm");
11275 pending = false;
11276 form.setBusy(false);
11277 return;
11278 }
11279 patch.password = values.password;
11280 }
11281 if (typeof values["roles[0]"] === "string" && values["roles[0]"]) {
11282 patch.roles = [values["roles[0]"]];
11283 }
11284 const meta = {};
11285 for (const [k, v] of Object.entries(values)) {
11286 if (!k.startsWith("meta.")) {
11287 continue;
11288 }
11289 let resolved = v;
11290 if (typeof v === "boolean") {
11291 const field = form.querySelector(`[name="${k}"]`);
11292 const valueAttr = field?.getAttribute("value");
11293 resolved = valueAttr ?? String(v);
11294 }
11295 meta[k.slice(5)] = resolved;
11296 }
11297 if (Object.keys(meta).length > 0) {
11298 patch.meta = meta;
11299 }
11300 const result = await resolveUserEditClient().saveUser(userId, patch);
11301 pending = false;
11302 form.setBusy(false);
11303 if (!result.ok) {
11304 const summary = result.message ?? mapErrorCode(result.error) ?? __("Save failed.");
11305 form.setError(summary);
11306 notifyToast$1(summary, "error");
11307 if (result.fieldErrors) {
11308 for (const field of Object.keys(result.fieldErrors)) {
11309 form.setFieldInvalid(field);
11310 }
11311 }
11312 console.warn("[user-edit] save failed", {
11313 code: result.error,
11314 message: result.message
11315 });
11316 return;
11317 }
11318 notifyToast$1(__("Profile saved."), "success");
11319 const broadcastApi = window.wp?.desktop;
11320 broadcastApi?.broadcast?.("desktop-mode.user.changed", {
11321 source: "user-edit-window",
11322 action: "updated",
11323 ids: [userId]
11324 });
11325 pwd.value = "";
11326 pwd.setAttribute("value", "");
11327 pwdConfirm.value = "";
11328 pwdConfirm.setAttribute("value", "");
11329 if (result.user) {
11330 Object.assign(user, result.user);
11331 const next = buildProfileHeader(user);
11332 profileHeader.replaceWith(next);
11333 profileHeader = next;
11334 const aside = host.ownerDocument?.querySelector(
11335 "[data-wpd-user-profile-aside]"
11336 );
11337 if (aside) {
11338 void mountProfileAsideAt(aside, userId, true);
11339 }
11340 }
11341 };
11342 wrap.appendChild(form);
11343 host.appendChild(wrap);
11344 }
11345 function buildProfileHeader(user) {
11346 const wrap = document.createElement("div");
11347 wrap.className = "desktop-mode-user-edit__header";
11348 wrap.style.cssText = "display:flex;align-items:center;gap:16px;margin:0 0 12px;";
11349 const avatar = document.createElement("wpd-avatar");
11350 avatar.setAttribute("size", "64");
11351 if (user.name || user.username) {
11352 avatar.setAttribute("name", user.name || user.username || "");
11353 }
11354 if (user.id > 0) {
11355 avatar.setAttribute("user-id", String(user.id));
11356 }
11357 const avatars = user.avatar_urls ?? {};
11358 const rawAvatar = avatars["96"] ?? avatars["48"] ?? "";
11359 if (rawAvatar) {
11360 applyAvatarSrc(avatar, rawAvatar);
11361 }
11362 wrap.appendChild(avatar);
11363 const text = document.createElement("div");
11364 text.style.cssText = "min-width:0;display:flex;flex-direction:column;gap:4px;";
11365 const name = document.createElement("div");
11366 name.style.cssText = "font-size:18px;font-weight:600;letter-spacing:-0.01em;";
11367 name.textContent = user.name || user.username || `#${user.id}`;
11368 text.appendChild(name);
11369 const sub = document.createElement("div");
11370 sub.style.cssText = "display:flex;align-items:center;gap:6px;font-size:12px;color:var(--desktop-mode-muted, #50575e);flex-wrap:wrap;";
11371 const handle = document.createElement("span");
11372 handle.textContent = `@${user.username}`;
11373 sub.appendChild(handle);
11374 const dot = document.createElement("span");
11375 dot.textContent = "·";
11376 dot.setAttribute("aria-hidden", "true");
11377 sub.appendChild(dot);
11378 const roleStr = Array.isArray(user.roles) ? user.roles.join(", ") : "";
11379 const roleSpan = document.createElement("span");
11380 roleSpan.textContent = roleStr || __("No role");
11381 sub.appendChild(roleSpan);
11382 text.appendChild(sub);
11383 wrap.appendChild(text);
11384 return wrap;
11385 }
11386 async function loadInsightsInto(host, userId, fresh) {
11387 host.replaceChildren();
11388 const skeleton = document.createElement("div");
11389 skeleton.style.cssText = "display:flex;align-items:center;justify-content:center;padding:32px;color:var(--desktop-mode-muted, #50575e);font-size:13px;";
11390 skeleton.textContent = __("Loading insights…");
11391 host.appendChild(skeleton);
11392 try {
11393 return await resolveUserEditClient().fetchInsights(userId, { fresh });
11394 } catch (err) {
11395 host.replaceChildren();
11396 const msg = document.createElement("p");
11397 msg.style.cssText = "padding:24px;color:#b32d2e;font-size:13px;text-align:center;";
11398 msg.textContent = sprintf(
11399 // translators: %s is an error message.
11400 __("Could not load insights (%s)."),
11401 String(err.message ?? err)
11402 );
11403 host.appendChild(msg);
11404 return null;
11405 }
11406 }
11407 async function renderInsightsAside(host, userId, fresh) {
11408 const data = await loadInsightsInto(host, userId, fresh);
11409 if (!data) {
11410 return;
11411 }
11412 host.replaceChildren();
11413 host.appendChild(buildAsideSummary(data));
11414 host.appendChild(buildAsideStatGrid(data));
11415 host.appendChild(buildContentSparkline(data));
11416 }
11417 async function renderInsightsActivity(host, userId, fresh) {
11418 const data = await loadInsightsInto(host, userId, fresh);
11419 if (!data) {
11420 return;
11421 }
11422 host.replaceChildren();
11423 const wrap = document.createElement("div");
11424 wrap.className = "desktop-mode-user-edit__activity";
11425 const heading = document.createElement("h3");
11426 heading.textContent = __("Recent activity");
11427 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);";
11428 wrap.appendChild(heading);
11429 wrap.appendChild(buildRecentLists(data));
11430 wrap.appendChild(buildSecurityPanel(data));
11431 host.appendChild(wrap);
11432 }
11433 function buildAsideSummary(data) {
11434 const card = document.createElement("div");
11435 card.style.cssText = [
11436 "display:flex",
11437 "flex-direction:column",
11438 "align-items:center",
11439 "text-align:center",
11440 "gap:6px",
11441 "padding:16px",
11442 "border:1px solid var(--desktop-mode-border, #dcdcde)",
11443 "border-radius:12px",
11444 "background:var(--wp-admin-theme-bg-elevated, #f6f7f7)"
11445 ].join(";");
11446 const avatar = document.createElement("img");
11447 avatar.src = data.avatarUrl;
11448 avatar.alt = "";
11449 avatar.style.cssText = "width:72px;height:72px;border-radius:50%;flex-shrink:0;";
11450 card.appendChild(avatar);
11451 const name = document.createElement("div");
11452 name.style.cssText = "font-size:15px;font-weight:600;letter-spacing:-0.01em;";
11453 name.textContent = data.displayName || `#${data.userId}`;
11454 card.appendChild(name);
11455 const roles = document.createElement("div");
11456 roles.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;justify-content:center;";
11457 for (const role of data.roles) {
11458 const chip = document.createElement("span");
11459 chip.textContent = role;
11460 chip.style.cssText = [
11461 "display:inline-flex",
11462 "padding:2px 8px",
11463 "border-radius:10px",
11464 "background:rgba(34,113,177,0.10)",
11465 "color:#0a4b78",
11466 "font-size:11px",
11467 "font-weight:600"
11468 ].join(";");
11469 roles.appendChild(chip);
11470 }
11471 if (data.roles.length === 0) {
11472 const noRole = document.createElement("span");
11473 noRole.textContent = __("No role");
11474 noRole.style.cssText = "font-size:11px;color:var(--desktop-mode-muted, #8c8f94);";
11475 roles.appendChild(noRole);
11476 }
11477 card.appendChild(roles);
11478 const completeness = data.profileCompleteness;
11479 if (completeness && completeness.total > 0) {
11480 const cwrap = document.createElement("div");
11481 cwrap.style.cssText = "display:flex;flex-direction:column;gap:4px;width:100%;margin-top:6px;";
11482 const top = document.createElement("div");
11483 top.style.cssText = "display:flex;justify-content:space-between;align-items:baseline;font-size:11px;color:var(--desktop-mode-muted, #50575e);";
11484 const lbl = document.createElement("span");
11485 lbl.textContent = __("Profile completeness");
11486 const pct = document.createElement("span");
11487 pct.style.cssText = "font-variant-numeric:tabular-nums;font-weight:600;";
11488 pct.textContent = `${completeness.percent}%`;
11489 top.appendChild(lbl);
11490 top.appendChild(pct);
11491 cwrap.appendChild(top);
11492 const track = document.createElement("div");
11493 track.style.cssText = [
11494 "height:4px",
11495 "border-radius:999px",
11496 "background:rgba(0,0,0,0.06)",
11497 "position:relative",
11498 "overflow:hidden"
11499 ].join(";");
11500 const bar = document.createElement("div");
11501 bar.style.cssText = [
11502 "position:absolute",
11503 "inset:0",
11504 `width:${completeness.percent}%`,
11505 "background:var(--wp-admin-theme-color, #2271b1)",
11506 "transition:width 360ms ease"
11507 ].join(";");
11508 track.appendChild(bar);
11509 cwrap.appendChild(track);
11510 card.appendChild(cwrap);
11511 }
11512 return card;
11513 }
11514 function buildAsideStatGrid(data) {
11515 const grid = document.createElement("div");
11516 grid.style.cssText = [
11517 "display:grid",
11518 "grid-template-columns:1fr 1fr",
11519 "gap:8px",
11520 "margin-top:12px"
11521 ].join(";");
11522 const tile = (label, value, sub) => {
11523 const card = document.createElement("div");
11524 card.style.cssText = [
11525 "border:1px solid var(--desktop-mode-border, #dcdcde)",
11526 "border-radius:8px",
11527 "padding:8px 10px",
11528 "display:flex",
11529 "flex-direction:column",
11530 "gap:1px",
11531 "min-width:0"
11532 ].join(";");
11533 const lbl = document.createElement("div");
11534 lbl.style.cssText = "font-size:10px;text-transform:uppercase;letter-spacing:0.04em;color:var(--desktop-mode-muted, #50575e);font-weight:600;";
11535 lbl.textContent = label;
11536 const val = document.createElement("div");
11537 val.style.cssText = "font-size:18px;font-weight:600;font-variant-numeric:tabular-nums;";
11538 val.textContent = value;
11539 card.appendChild(lbl);
11540 card.appendChild(val);
11541 if (sub) {
11542 const subEl = document.createElement("div");
11543 subEl.style.cssText = "font-size:10px;color:var(--desktop-mode-muted, #8c8f94);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
11544 subEl.title = sub;
11545 subEl.textContent = sub;
11546 card.appendChild(subEl);
11547 }
11548 return card;
11549 };
11550 const stats = data.stats;
11551 let postsSub;
11552 if (stats.pages > 0) {
11553 postsSub = sprintf(
11554 // translators: %d is a count of pages.
11555 _n("+ %d page", "+ %d pages", stats.pages),
11556 stats.pages
11557 );
11558 }
11559 grid.appendChild(
11560 tile(__("Posts"), String(stats.posts), postsSub)
11561 );
11562 let commentsSub;
11563 if (stats.commentsReceived > 0) {
11564 commentsSub = sprintf(
11565 // translators: %d is a count of received comments.
11566 __("%d received"),
11567 stats.commentsReceived
11568 );
11569 }
11570 grid.appendChild(
11571 tile(__("Comments"), String(stats.commentsAuthored), commentsSub)
11572 );
11573 grid.appendChild(
11574 tile(
11575 __("Last login"),
11576 stats.lastLoginAt ? relativeTime$1(stats.lastLoginAt) : __("Never"),
11577 stats.lastLoginAt ? new Date(stats.lastLoginAt * 1e3).toLocaleDateString() : void 0
11578 )
11579 );
11580 let memberValue = "—";
11581 if (stats.daysSinceRegistration !== null) {
11582 memberValue = sprintf(
11583 // translators: %d is a number of days.
11584 _n("%d day", "%d days", stats.daysSinceRegistration),
11585 stats.daysSinceRegistration
11586 );
11587 }
11588 grid.appendChild(
11589 tile(
11590 __("Member"),
11591 memberValue,
11592 stats.registeredAt ? new Date(stats.registeredAt * 1e3).toLocaleDateString() : void 0
11593 )
11594 );
11595 return grid;
11596 }
11597 function buildContentSparkline(data) {
11598 const wrap = document.createElement("div");
11599 wrap.style.cssText = [
11600 "border:1px solid var(--desktop-mode-border, #dcdcde)",
11601 "border-radius:10px",
11602 "padding:14px 16px",
11603 "margin:0 0 22px"
11604 ].join(";");
11605 const head = document.createElement("div");
11606 head.style.cssText = "display:flex;justify-content:space-between;align-items:baseline;margin:0 0 8px;";
11607 const title = document.createElement("div");
11608 title.style.cssText = "font-size:13px;font-weight:600;";
11609 title.textContent = __("Posts published — last 12 months");
11610 head.appendChild(title);
11611 const total = data.contentByMonth.reduce((s, m) => s + m.count, 0);
11612 const sub = document.createElement("div");
11613 sub.style.cssText = "font-size:11px;color:var(--desktop-mode-muted, #50575e);";
11614 sub.textContent = sprintf(
11615 // translators: %d is a count of posts.
11616 __("%d total"),
11617 total
11618 );
11619 head.appendChild(sub);
11620 wrap.appendChild(head);
11621 if (data.contentByMonth.length === 0) {
11622 const empty = document.createElement("p");
11623 empty.style.cssText = "margin:0;color:var(--desktop-mode-muted, #50575e);font-size:12px;";
11624 empty.textContent = __("No activity in the last 12 months.");
11625 wrap.appendChild(empty);
11626 return wrap;
11627 }
11628 const max = Math.max(1, ...data.contentByMonth.map((m) => m.count));
11629 const bars = document.createElement("div");
11630 bars.style.cssText = [
11631 "display:grid",
11632 `grid-template-columns:repeat(${data.contentByMonth.length}, 1fr)`,
11633 "gap:4px",
11634 "align-items:end",
11635 "height:60px"
11636 ].join(";");
11637 for (const month of data.contentByMonth) {
11638 const col = document.createElement("div");
11639 col.style.cssText = "display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end;";
11640 const bar = document.createElement("div");
11641 const heightPct = Math.round(month.count / max * 100);
11642 bar.style.cssText = [
11643 "width:100%",
11644 `height:${Math.max(3, heightPct)}%`,
11645 "background:var(--wp-admin-theme-color, #2271b1)",
11646 month.count === 0 ? "opacity:0.18" : "opacity:1",
11647 "border-radius:3px 3px 0 0",
11648 "transition:height 360ms ease"
11649 ].join(";");
11650 bar.title = sprintf(
11651 // translators: %1$s is a YYYY-MM month, %2$d is post count.
11652 __("%1$s — %2$d posts"),
11653 month.month,
11654 month.count
11655 );
11656 col.appendChild(bar);
11657 wrap.appendChild(col);
11658 bars.appendChild(col);
11659 }
11660 wrap.appendChild(bars);
11661 const labels = document.createElement("div");
11662 labels.style.cssText = [
11663 "display:grid",
11664 `grid-template-columns:repeat(${data.contentByMonth.length}, 1fr)`,
11665 "gap:4px",
11666 "margin-top:4px",
11667 "font-size:10px",
11668 "color:var(--desktop-mode-muted, #8c8f94)",
11669 "text-align:center"
11670 ].join(";");
11671 for (const month of data.contentByMonth) {
11672 const span = document.createElement("span");
11673 const parts = month.month.split("-");
11674 span.textContent = parts.length === 2 ? parts[1] : month.month;
11675 labels.appendChild(span);
11676 }
11677 wrap.appendChild(labels);
11678 return wrap;
11679 }
11680 function buildRecentLists(data) {
11681 const wrap = document.createElement("div");
11682 wrap.style.cssText = "display:grid;grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));gap:14px;margin:0 0 22px;";
11683 wrap.appendChild(
11684 buildRecentList(
11685 __("Recent posts"),
11686 __("No recent posts."),
11687 data.recentPosts.map((p) => ({
11688 primary: p.title,
11689 secondary: relativeFromIso(p.dateGmt),
11690 tag: p.status !== "publish" ? p.status : null,
11691 badge: p.commentCount > 0 ? sprintf(
11692 // translators: %d is a count of comments.
11693 __("%d 💬"),
11694 p.commentCount
11695 ) : null
11696 }))
11697 )
11698 );
11699 wrap.appendChild(
11700 buildRecentList(
11701 __("Recent comments"),
11702 __("No recent comments."),
11703 data.recentComments.map((c) => {
11704 const when = relativeFromIso(c.dateGmt);
11705 return {
11706 primary: c.excerpt || __("(empty comment)"),
11707 secondary: c.postTitle ? `${__("on")} "${c.postTitle}" · ${when}` : when,
11708 tag: c.approved ? null : __("pending"),
11709 badge: null
11710 };
11711 })
11712 )
11713 );
11714 return wrap;
11715 }
11716 function buildRecentList(title, emptyText, items) {
11717 const card = document.createElement("div");
11718 card.style.cssText = [
11719 "border:1px solid var(--desktop-mode-border, #dcdcde)",
11720 "border-radius:10px",
11721 "padding:14px 16px",
11722 "min-width:0"
11723 ].join(";");
11724 const head = document.createElement("div");
11725 head.style.cssText = "font-size:13px;font-weight:600;margin:0 0 10px;";
11726 head.textContent = title;
11727 card.appendChild(head);
11728 if (items.length === 0) {
11729 const empty = document.createElement("p");
11730 empty.style.cssText = "margin:0;color:var(--desktop-mode-muted, #50575e);font-size:12px;";
11731 empty.textContent = emptyText;
11732 card.appendChild(empty);
11733 return card;
11734 }
11735 const list = document.createElement("ul");
11736 list.style.cssText = "list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px;";
11737 for (const item of items) {
11738 const li = document.createElement("li");
11739 li.style.cssText = "min-width:0;";
11740 const top = document.createElement("div");
11741 top.style.cssText = "display:flex;align-items:baseline;gap:6px;min-width:0;";
11742 const primary = document.createElement("span");
11743 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;";
11744 primary.textContent = item.primary;
11745 primary.title = item.primary;
11746 top.appendChild(primary);
11747 if (item.tag) {
11748 const tag = document.createElement("span");
11749 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;";
11750 tag.textContent = item.tag;
11751 top.appendChild(tag);
11752 }
11753 if (item.badge) {
11754 const badge = document.createElement("span");
11755 badge.style.cssText = "font-size:11px;color:var(--desktop-mode-muted, #50575e);flex-shrink:0;";
11756 badge.textContent = item.badge;
11757 top.appendChild(badge);
11758 }
11759 li.appendChild(top);
11760 const sub = document.createElement("div");
11761 sub.style.cssText = "font-size:11px;color:var(--desktop-mode-muted, #8c8f94);";
11762 sub.textContent = item.secondary;
11763 li.appendChild(sub);
11764 list.appendChild(li);
11765 }
11766 card.appendChild(list);
11767 return card;
11768 }
11769 function buildSecurityPanel(data) {
11770 const card = document.createElement("div");
11771 card.style.cssText = [
11772 "border:1px solid var(--desktop-mode-border, #dcdcde)",
11773 "border-radius:10px",
11774 "padding:14px 16px"
11775 ].join(";");
11776 const head = document.createElement("div");
11777 head.style.cssText = "font-size:13px;font-weight:600;margin:0 0 10px;";
11778 head.textContent = __("Active sessions & app access");
11779 card.appendChild(head);
11780 const grid = document.createElement("div");
11781 grid.style.cssText = "display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:12px;";
11782 const sessionTile = document.createElement("div");
11783 sessionTile.style.cssText = "display:flex;flex-direction:column;gap:2px;font-size:12px;";
11784 const sessionLabel = document.createElement("div");
11785 sessionLabel.style.cssText = "color:var(--desktop-mode-muted, #50575e);font-size:11px;text-transform:uppercase;letter-spacing:0.04em;font-weight:600;";
11786 sessionLabel.textContent = __("Active sessions");
11787 const sessionValue = document.createElement("div");
11788 sessionValue.style.cssText = "font-size:18px;font-weight:600;";
11789 sessionValue.textContent = String(data.sessions.length);
11790 const sessionSub = document.createElement("div");
11791 sessionSub.style.cssText = "color:var(--desktop-mode-muted, #8c8f94);";
11792 const currentCount = data.sessions.filter((s) => s.current).length;
11793 sessionSub.textContent = currentCount > 0 ? __("Includes the current device.") : __("Logged in across multiple devices.");
11794 sessionTile.appendChild(sessionLabel);
11795 sessionTile.appendChild(sessionValue);
11796 sessionTile.appendChild(sessionSub);
11797 grid.appendChild(sessionTile);
11798 const appTile = document.createElement("div");
11799 appTile.style.cssText = "display:flex;flex-direction:column;gap:2px;font-size:12px;";
11800 const appLabel = document.createElement("div");
11801 appLabel.style.cssText = "color:var(--desktop-mode-muted, #50575e);font-size:11px;text-transform:uppercase;letter-spacing:0.04em;font-weight:600;";
11802 appLabel.textContent = __("Application passwords");
11803 const appValue = document.createElement("div");
11804 appValue.style.cssText = "font-size:18px;font-weight:600;";
11805 appValue.textContent = String(data.applicationPasswords.total);
11806 const appSub = document.createElement("div");
11807 appSub.style.cssText = "color:var(--desktop-mode-muted, #8c8f94);";
11808 if (data.applicationPasswords.lastUsedAt && data.applicationPasswords.lastUsedName) {
11809 appSub.textContent = sprintf(
11810 // translators: %1$s is the app password name, %2$s is a relative time.
11811 __('"%1$s" last used %2$s'),
11812 data.applicationPasswords.lastUsedName,
11813 relativeTime$1(data.applicationPasswords.lastUsedAt)
11814 );
11815 } else {
11816 appSub.textContent = data.applicationPasswords.total ? __("No recent use.") : __("No app passwords issued yet.");
11817 }
11818 appTile.appendChild(appLabel);
11819 appTile.appendChild(appValue);
11820 appTile.appendChild(appSub);
11821 grid.appendChild(appTile);
11822 card.appendChild(grid);
11823 return card;
11824 }
11825 function textField(formName, label, value, opts = {}) {
11826 const el = document.createElement("wpd-text-field");
11827 el.setAttribute("name", formName);
11828 el.setAttribute("label", label);
11829 el.setAttribute("value", value);
11830 el.value = value;
11831 if (opts.required) {
11832 el.setAttribute("required", "");
11833 }
11834 if (opts.readonly) {
11835 el.setAttribute("readonly", "");
11836 }
11837 if (opts.type) {
11838 el.setAttribute("type", opts.type);
11839 }
11840 if (opts.fullWidth !== false && opts.fullWidth) {
11841 el.setAttribute("full-width", "");
11842 }
11843 if (opts.dataset) {
11844 for (const [k, v] of Object.entries(opts.dataset)) {
11845 el.dataset[k] = v;
11846 }
11847 }
11848 return el;
11849 }
11850 function displayNameCandidates(user) {
11851 const candidates = /* @__PURE__ */ new Set();
11852 const add = (s) => {
11853 const t = s.trim();
11854 if (t !== "") {
11855 candidates.add(t);
11856 }
11857 };
11858 add(user.username);
11859 add(user.nickname ?? "");
11860 add(user.first_name);
11861 add(user.last_name);
11862 if (user.first_name || user.last_name) {
11863 add(`${user.first_name} ${user.last_name}`.trim());
11864 add(`${user.last_name} ${user.first_name}`.trim());
11865 }
11866 if (user.name) {
11867 add(user.name);
11868 }
11869 return Array.from(candidates).map((name) => ({
11870 value: name,
11871 label: name
11872 }));
11873 }
11874 function relativeFromIso(iso) {
11875 const ms = msFromIso(iso);
11876 if (!Number.isFinite(ms)) {
11877 return "—";
11878 }
11879 return relativeTime$1(Math.floor(ms / 1e3));
11880 }
11881 function relativeTime$1(ts) {
11882 if (!Number.isFinite(ts)) {
11883 return "—";
11884 }
11885 const now = Math.floor(Date.now() / 1e3);
11886 const delta = now - ts;
11887 if (delta < 60) {
11888 return __("just now");
11889 }
11890 if (delta < 3600) {
11891 return sprintf(__("%d min ago"), Math.floor(delta / 60));
11892 }
11893 if (delta < 86400) {
11894 return sprintf(__("%d h ago"), Math.floor(delta / 3600));
11895 }
11896 if (delta < 86400 * 30) {
11897 return sprintf(__("%d d ago"), Math.floor(delta / 86400));
11898 }
11899 if (delta < 86400 * 365) {
11900 return sprintf(__("%d mo ago"), Math.floor(delta / (86400 * 30)));
11901 }
11902 return sprintf(__("%d y ago"), Math.floor(delta / (86400 * 365)));
11903 }
11904 function msFromIso(iso) {
11905 if (!iso) {
11906 return NaN;
11907 }
11908 if (iso.startsWith("0000-00-00")) {
11909 return NaN;
11910 }
11911 let normalized = iso;
11912 if (normalized.includes(" ")) {
11913 normalized = normalized.replace(" ", "T");
11914 }
11915 if (!/Z$/.test(normalized) && !/[+-]\d{2}:?\d{2}$/.test(normalized)) {
11916 normalized += "Z";
11917 }
11918 const parsed = Date.parse(normalized);
11919 return Number.isFinite(parsed) ? parsed : NaN;
11920 }
11921 function generateStrongPassword$1(length) {
11922 const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
11923 const lower = "abcdefghjkmnpqrstuvwxyz";
11924 const digits = "23456789";
11925 const symbols = "!@#$%^&*-_=+";
11926 const all = upper + lower + digits + symbols;
11927 const buf = new Uint32Array(length);
11928 crypto.getRandomValues(buf);
11929 let out = "";
11930 for (let i = 0; i < length; i += 1) {
11931 out += all[buf[i] % all.length];
11932 }
11933 return out;
11934 }
11935 function mapErrorCode(code) {
11936 switch (code) {
11937 case "rest_user_invalid_email":
11938 case "invalid_email":
11939 return __("Email address is not valid.");
11940 case "rest_user_email_exists":
11941 case "existing_user_email":
11942 return __("That email is already in use.");
11943 case "rest_user_invalid_role":
11944 return __("You are not allowed to assign that role.");
11945 default:
11946 return null;
11947 }
11948 }
11949 function applyColorSchemePreview(slug, info) {
11950 if (!info.url) {
11951 flipBodyClass(slug);
11952 flipShellScheme(slug);
11953 return;
11954 }
11955 let link = document.getElementById(
11956 "colors-css"
11957 );
11958 if (!link) {
11959 link = document.createElement("link");
11960 link.rel = "stylesheet";
11961 link.id = "colors-css";
11962 document.head.appendChild(link);
11963 }
11964 link.href = info.url;
11965 flipBodyClass(slug);
11966 flipShellScheme(slug);
11967 }
11968 function flipShellScheme(slug) {
11969 const shell = document.querySelector(".desktop-mode-shell");
11970 if (shell) {
11971 shell.setAttribute("data-desktop-mode-scheme", slug);
11972 }
11973 }
11974 function flipBodyClass(slug) {
11975 const body = document.body;
11976 const next = `admin-color-${slug}`;
11977 for (const cls of Array.from(body.classList)) {
11978 if (cls.startsWith("admin-color-") && cls !== next) {
11979 body.classList.remove(cls);
11980 }
11981 }
11982 body.classList.add(next);
11983 }
11984 function buildAdminColorPicker(schemes, current, opts = {}) {
11985 const wrap = document.createElement("div");
11986 wrap.setAttribute("full-width", "");
11987 wrap.style.cssText = "display:flex;flex-direction:column;gap:6px;";
11988 const label = document.createElement("span");
11989 label.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:var(--desktop-mode-muted, #50575e);font-weight:600;";
11990 label.textContent = __("Admin colour scheme");
11991 wrap.appendChild(label);
11992 const hidden = document.createElement("wpd-text-field");
11993 hidden.setAttribute("name", "meta.admin_color");
11994 hidden.setAttribute("value", current);
11995 hidden.value = current;
11996 hidden.style.display = "none";
11997 wrap.appendChild(hidden);
11998 const grid = document.createElement("div");
11999 grid.style.cssText = [
12000 "display:grid",
12001 "grid-template-columns:repeat(auto-fill, minmax(140px, 1fr))",
12002 "gap:8px"
12003 ].join(";");
12004 wrap.appendChild(grid);
12005 let selected = current;
12006 const updateSelected = (slug) => {
12007 selected = slug;
12008 hidden.value = slug;
12009 hidden.setAttribute("value", slug);
12010 for (const t of Array.from(grid.children)) {
12011 const tile = t;
12012 const v = tile.dataset.scheme;
12013 tile.style.borderColor = v === slug ? "var(--wp-admin-theme-color, #2271b1)" : "var(--desktop-mode-border, #dcdcde)";
12014 tile.style.boxShadow = v === slug ? "0 0 0 1px var(--wp-admin-theme-color, #2271b1) inset" : "none";
12015 tile.setAttribute("aria-checked", v === slug ? "true" : "false");
12016 }
12017 };
12018 for (const [slug, info] of Object.entries(schemes)) {
12019 const tile = document.createElement("button");
12020 tile.type = "button";
12021 tile.setAttribute("role", "radio");
12022 tile.setAttribute("aria-checked", slug === selected ? "true" : "false");
12023 tile.dataset.scheme = slug;
12024 tile.style.cssText = [
12025 "appearance:none",
12026 "border:1px solid var(--desktop-mode-border, #dcdcde)",
12027 "background:var(--wp-admin-theme-bg, #fff)",
12028 "color:inherit",
12029 "border-radius:8px",
12030 "padding:10px 10px 8px",
12031 "cursor:pointer",
12032 "display:flex",
12033 "flex-direction:column",
12034 "gap:6px",
12035 "text-align:left",
12036 "min-width:0",
12037 "transition:border-color 120ms ease, box-shadow 120ms ease"
12038 ].join(";");
12039 const swatchRow = document.createElement("span");
12040 swatchRow.style.cssText = "display:flex;height:18px;border-radius:4px;overflow:hidden;border:1px solid rgba(0,0,0,0.06);";
12041 const colors = (info.colors ?? []).slice(0, 4);
12042 if (colors.length === 0) {
12043 colors.push("#dcdcde", "#dcdcde", "#dcdcde");
12044 }
12045 for (const color of colors) {
12046 const swatch = document.createElement("span");
12047 swatch.style.cssText = `flex:1 1 auto;background:${color};`;
12048 swatchRow.appendChild(swatch);
12049 }
12050 tile.appendChild(swatchRow);
12051 const name = document.createElement("span");
12052 name.style.cssText = "font-size:12px;font-weight:500;";
12053 name.textContent = info.name;
12054 tile.appendChild(name);
12055 tile.addEventListener("click", () => {
12056 updateSelected(slug);
12057 if (opts.livePreview) {
12058 applyColorSchemePreview(slug, info);
12059 }
12060 });
12061 grid.appendChild(tile);
12062 }
12063 updateSelected(selected);
12064 return wrap;
12065 }
12066 function checkboxField(name, label, checked, opts = {}) {
12067 const trueValue = opts.trueValue ?? "true";
12068 const falseValue = opts.falseValue ?? "false";
12069 const wrap = document.createElement("span");
12070 if (opts.fullWidth) {
12071 wrap.setAttribute("full-width", "");
12072 }
12073 const cb = document.createElement("wpd-checkbox-label");
12074 cb.setAttribute("label", label);
12075 cb.setAttribute("name", name);
12076 cb.setAttribute("value", checked ? trueValue : falseValue);
12077 cb.value = checked ? trueValue : falseValue;
12078 if (checked) {
12079 cb.setAttribute("checked", "");
12080 }
12081 cb.addEventListener("wpd-checkbox-change", (e) => {
12082 const detail = e.detail;
12083 const v = detail?.checked ? trueValue : falseValue;
12084 cb.value = v;
12085 cb.setAttribute("value", v);
12086 });
12087 wrap.appendChild(cb);
12088 return wrap;
12089 }
12090 function buildSessionsRow(userId, isSelfEdit) {
12091 const wrap = document.createElement("div");
12092 wrap.setAttribute("full-width", "");
12093 wrap.style.cssText = "display:flex;align-items:center;gap:12px;flex-wrap:wrap;";
12094 const label = document.createElement("span");
12095 label.style.cssText = "font-size:13px;color:var(--desktop-mode-fg, inherit);";
12096 label.textContent = __("Active sessions");
12097 wrap.appendChild(label);
12098 const btn = document.createElement("wpd-button");
12099 btn.setAttribute("variant", "ghost");
12100 btn.setAttribute("type", "button");
12101 btn.textContent = isSelfEdit ? __("Log out everywhere else") : __("Log this user out everywhere");
12102 btn.addEventListener("click", async (e) => {
12103 e.preventDefault();
12104 try {
12105 const cfg = resolveUserEditClient().getConfig();
12106 const base = cfg.insightsUrlBase ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/");
12107 const res = await trackedFetch(
12108 joinRestUrl(base, `${userId}/destroy-sessions`),
12109 {
12110 method: "POST",
12111 credentials: "same-origin",
12112 headers: {
12113 "Content-Type": "application/json",
12114 "X-WP-Nonce": cfg.restNonce
12115 },
12116 body: JSON.stringify({
12117 scope: isSelfEdit ? "others" : "all"
12118 })
12119 },
12120 { source: "user-edit-window/destroy-sessions" }
12121 );
12122 if (!res.ok) {
12123 throw new Error(`http_${res.status}`);
12124 }
12125 notifyToast$1(__("Sessions destroyed."), "success");
12126 } catch (err) {
12127 notifyToast$1(
12128 sprintf(
12129 // translators: %s is an error message.
12130 __("Could not destroy sessions (%s)."),
12131 String(err.message ?? err)
12132 ),
12133 "error"
12134 );
12135 }
12136 });
12137 wrap.appendChild(btn);
12138 return wrap;
12139 }
12140 function buildAppPasswordsRow(userId) {
12141 const wrap = document.createElement("div");
12142 wrap.setAttribute("full-width", "");
12143 wrap.style.cssText = "display:flex;flex-direction:column;gap:8px;border:1px solid var(--desktop-mode-border, #dcdcde);border-radius:8px;padding:12px 14px;";
12144 const heading = document.createElement("div");
12145 heading.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:8px;";
12146 const headLabel = document.createElement("span");
12147 headLabel.textContent = __("Application passwords");
12148 headLabel.style.cssText = "font-size:13px;font-weight:600;";
12149 heading.appendChild(headLabel);
12150 wrap.appendChild(heading);
12151 const cfg = resolveUserEditClient().getConfig();
12152 const base = cfg.insightsUrlBase ?? joinRestUrl(cfg.restRoot, "desktop-mode/v1/users/");
12153 const list = document.createElement("ul");
12154 list.style.cssText = "list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px;";
12155 wrap.appendChild(list);
12156 const createRow = document.createElement("div");
12157 createRow.style.cssText = "display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-top:6px;";
12158 const nameInput = document.createElement("wpd-text-field");
12159 nameInput.setAttribute("label", __("New application password name"));
12160 nameInput.setAttribute(
12161 "placeholder",
12162 __("e.g. iPhone, WP-CLI, Backup tool")
12163 );
12164 nameInput.style.flex = "1 1 220px";
12165 createRow.appendChild(nameInput);
12166 const createBtn = document.createElement("wpd-button");
12167 createBtn.setAttribute("variant", "primary");
12168 createBtn.setAttribute("type", "button");
12169 createBtn.textContent = __("Create");
12170 createRow.appendChild(createBtn);
12171 wrap.appendChild(createRow);
12172 const renderItems = (items) => {
12173 list.replaceChildren();
12174 if (items.length === 0) {
12175 const empty = document.createElement("li");
12176 empty.style.cssText = "font-size:12px;color:var(--desktop-mode-muted, #50575e);";
12177 empty.textContent = __("No application passwords issued yet.");
12178 list.appendChild(empty);
12179 return;
12180 }
12181 for (const item of items) {
12182 const row = document.createElement("li");
12183 row.style.cssText = "display:flex;align-items:center;gap:8px;font-size:12px;";
12184 const nameSpan = document.createElement("span");
12185 nameSpan.style.cssText = "flex:1 1 auto;font-weight:500;";
12186 nameSpan.textContent = item.name;
12187 row.appendChild(nameSpan);
12188 const meta = document.createElement("span");
12189 meta.style.cssText = "color:var(--desktop-mode-muted, #8c8f94);";
12190 meta.textContent = item.last_used ? sprintf(
12191 // translators: %s is a relative time.
12192 __("last used %s"),
12193 relativeTime$1(item.last_used)
12194 ) : __("never used");
12195 row.appendChild(meta);
12196 const revoke = document.createElement("wpd-button");
12197 revoke.setAttribute("variant", "ghost");
12198 revoke.setAttribute("type", "button");
12199 revoke.textContent = __("Revoke");
12200 revoke.addEventListener("click", async (e) => {
12201 e.preventDefault();
12202 try {
12203 const res = await trackedFetch(
12204 joinRestUrl(base, `${userId}/application-passwords/${item.uuid}`),
12205 {
12206 method: "DELETE",
12207 credentials: "same-origin",
12208 headers: { "X-WP-Nonce": cfg.restNonce }
12209 },
12210 { source: "user-edit-window/app-pw-revoke" }
12211 );
12212 if (!res.ok) {
12213 throw new Error(`http_${res.status}`);
12214 }
12215 row.remove();
12216 notifyToast$1(__("Application password revoked."), "success");
12217 } catch (err) {
12218 notifyToast$1(
12219 String(err.message ?? err),
12220 "error"
12221 );
12222 }
12223 });
12224 row.appendChild(revoke);
12225 list.appendChild(row);
12226 }
12227 };
12228 const refresh = async () => {
12229 try {
12230 const res = await trackedFetch(
12231 joinRestUrl(base, `${userId}/application-passwords`),
12232 {
12233 credentials: "same-origin",
12234 headers: { "X-WP-Nonce": cfg.restNonce }
12235 },
12236 { source: "user-edit-window/app-pw-list", silent: true }
12237 );
12238 if (!res.ok) {
12239 return;
12240 }
12241 const data = await res.json();
12242 renderItems(data.items ?? []);
12243 } catch {
12244 }
12245 };
12246 void refresh();
12247 createBtn.addEventListener("click", async (e) => {
12248 e.preventDefault();
12249 const name = String(nameInput.value ?? "").trim();
12250 if (!name) {
12251 notifyToast$1(__("Application password name is required."), "error");
12252 return;
12253 }
12254 try {
12255 const res = await trackedFetch(
12256 joinRestUrl(base, `${userId}/application-passwords`),
12257 {
12258 method: "POST",
12259 credentials: "same-origin",
12260 headers: {
12261 "Content-Type": "application/json",
12262 "X-WP-Nonce": cfg.restNonce
12263 },
12264 body: JSON.stringify({ name })
12265 },
12266 { source: "user-edit-window/app-pw-create" }
12267 );
12268 if (!res.ok) {
12269 throw new Error(`http_${res.status}`);
12270 }
12271 const data = await res.json();
12272 notifyToast$1(
12273 sprintf(
12274 // translators: %s is an application password.
12275 __("Created. Copy the password now: %s"),
12276 data.password
12277 ),
12278 "success"
12279 );
12280 void navigator.clipboard?.writeText(data.password).catch(() => {
12281 });
12282 nameInput.value = "";
12283 nameInput.setAttribute("value", "");
12284 void refresh();
12285 } catch (err) {
12286 notifyToast$1(
12287 String(err.message ?? err),
12288 "error"
12289 );
12290 }
12291 });
12292 return wrap;
12293 }
12294 const userEditRender = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
12295 __proto__: null,
12296 mountProfileActivityAt,
12297 mountProfileAsideAt,
12298 mountProfileFormAt
12299 }, Symbol.toStringTag, { value: "Module" }));
12300 async function showPagesIntroDialog() {
12301 return new Promise((resolve) => {
12302 const backdrop = document.createElement("div");
12303 backdrop.className = "desktop-mode-pages-intro__backdrop";
12304 backdrop.setAttribute("role", "presentation");
12305 Object.assign(backdrop.style, {
12306 position: "fixed",
12307 inset: "0",
12308 background: "color-mix(in srgb, var(--wp-admin-theme-color, #1d2327) 60%, transparent)",
12309 backdropFilter: "blur(2px)",
12310 zIndex: "100000",
12311 display: "flex",
12312 alignItems: "center",
12313 justifyContent: "center",
12314 padding: "24px"
12315 });
12316 const dialog = document.createElement("div");
12317 dialog.setAttribute("role", "dialog");
12318 dialog.setAttribute("aria-modal", "true");
12319 dialog.setAttribute("aria-labelledby", "desktop-mode-pages-intro-title");
12320 dialog.className = "desktop-mode-pages-intro";
12321 Object.assign(dialog.style, {
12322 background: "var(--wp-admin-theme-bg, #fff)",
12323 color: "var(--wp-admin-theme-fg, #1d2327)",
12324 borderRadius: "14px",
12325 boxShadow: "0 24px 60px rgba(0,0,0,.28)",
12326 maxWidth: "520px",
12327 width: "100%",
12328 maxHeight: "90vh",
12329 overflow: "auto",
12330 padding: "28px 32px 24px",
12331 fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
12332 });
12333 dialog.innerHTML = renderDialogMarkup$1();
12334 backdrop.appendChild(dialog);
12335 document.body.appendChild(backdrop);
12336 const primaryBtn = dialog.querySelector(
12337 '[data-action="confirm"]'
12338 );
12339 const settingsBtn = dialog.querySelector(
12340 '[data-action="settings"]'
12341 );
12342 primaryBtn?.focus();
12343 let resolved = false;
12344 const cleanup = (result) => {
12345 if (resolved) {
12346 return;
12347 }
12348 resolved = true;
12349 document.removeEventListener("keydown", onKey, true);
12350 backdrop.remove();
12351 resolve(result);
12352 };
12353 const onKey = (e) => {
12354 if (e.key === "Escape") {
12355 e.preventDefault();
12356 cleanup("cancel");
12357 }
12358 };
12359 document.addEventListener("keydown", onKey, true);
12360 backdrop.addEventListener("click", (e) => {
12361 if (e.target === backdrop) {
12362 cleanup("cancel");
12363 }
12364 });
12365 primaryBtn?.addEventListener("click", () => cleanup("confirm"));
12366 settingsBtn?.addEventListener("click", () => cleanup("settings"));
12367 });
12368 }
12369 function renderDialogMarkup$1() {
12370 const title = __("Welcome to the new Pages window");
12371 const lede = __(
12372 "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."
12373 );
12374 const highlights = [
12375 __("Sticky header and sticky title column so long lists stay readable as you scroll."),
12376 __('Front page and Posts page badges right on the title — no more "wait, which one is the homepage?".'),
12377 __("Page Template column so you can spot which template each page uses at a glance."),
12378 __("Slug column with one-click copy — perfect when configuring redirects or sharing canonical URLs."),
12379 __("Comments column, Parent column, View link, lock indicator, multi-select bulk actions, inline search, status segments. All in one screen, no reloads.")
12380 ];
12381 const li = (arr) => arr.map(
12382 (s) => `<li><span class="dot" aria-hidden="true"></span>${escapeHtml$1(s)}</li>`
12383 ).join("");
12384 return `
12385 <style>
12386 .desktop-mode-pages-intro h2 {
12387 margin: 0 0 8px;
12388 font-size: 22px;
12389 font-weight: 600;
12390 letter-spacing: -0.01em;
12391 }
12392 .desktop-mode-pages-intro p.lede {
12393 margin: 0 0 20px;
12394 color: var(--wp-admin-theme-fg-muted, #50575e);
12395 font-size: 14px;
12396 line-height: 1.5;
12397 }
12398 .desktop-mode-pages-intro__list {
12399 list-style: none;
12400 margin: 0 0 22px;
12401 padding: 0;
12402 font-size: 14px;
12403 line-height: 1.5;
12404 }
12405 .desktop-mode-pages-intro__list li {
12406 display: flex;
12407 align-items: flex-start;
12408 gap: 10px;
12409 padding: 6px 0;
12410 }
12411 .desktop-mode-pages-intro__list .dot {
12412 flex: 0 0 auto;
12413 width: 6px;
12414 height: 6px;
12415 margin-top: 9px;
12416 border-radius: 50%;
12417 background: var(--wp-admin-theme-color, #2271b1);
12418 }
12419 .desktop-mode-pages-intro__footer {
12420 display: flex;
12421 justify-content: flex-end;
12422 gap: 8px;
12423 margin-top: 8px;
12424 }
12425 .desktop-mode-pages-intro__footer button {
12426 appearance: none;
12427 border: 1px solid var(--wp-admin-theme-border, #dcdcde);
12428 background: var(--wp-admin-theme-bg, #fff);
12429 color: inherit;
12430 padding: 8px 14px;
12431 border-radius: 6px;
12432 font-size: 13px;
12433 cursor: pointer;
12434 }
12435 .desktop-mode-pages-intro__footer button.primary {
12436 border-color: var(--wp-admin-theme-color, #2271b1);
12437 background: var(--wp-admin-theme-color, #2271b1);
12438 color: #fff;
12439 font-weight: 500;
12440 }
12441 .desktop-mode-pages-intro__footer button:hover { filter: brightness(1.05); }
12442 .desktop-mode-pages-intro__footer button:focus-visible {
12443 outline: 2px solid var(--wp-admin-theme-color, #2271b1);
12444 outline-offset: 2px;
12445 }
12446 </style>
12447 <h2 id="desktop-mode-pages-intro-title">${escapeHtml$1(title)}</h2>
12448 <p class="lede">${escapeHtml$1(lede)}</p>
12449 <ul class="desktop-mode-pages-intro__list">${li(highlights)}</ul>
12450 <div class="desktop-mode-pages-intro__footer">
12451 <button type="button" data-action="settings">${escapeHtml$1(
12452 __("Take me to settings")
12453 )}</button>
12454 <button type="button" class="primary" data-action="confirm">${escapeHtml$1(
12455 __("Got it")
12456 )}</button>
12457 </div>
12458 `;
12459 }
12460 function escapeHtml$1(s) {
12461 const t = document.createElement("div");
12462 t.textContent = s;
12463 return t.innerHTML;
12464 }
12465 const pagesIntroDialog = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
12466 __proto__: null,
12467 showPagesIntroDialog
12468 }, Symbol.toStringTag, { value: "Module" }));
12469 const REPULSION_K = 5500;
12470 const SPRING_K = 0.05;
12471 const SPRING_LEN = 130;
12472 const MIN_RADIUS = 22;
12473 const MAX_RADIUS = 48;
12474 const POST_PER_PAGE$1 = 10;
12475 const POST_RING_RADIUS$1 = 170;
12476 async function mountCategoriesMindmap(host, client) {
12477 const api = window.wp?.desktop;
12478 if (!api || typeof api.loadModules !== "function") {
12479 host.textContent = __("Mindmap unavailable: shell modules API missing.");
12480 return () => {
12481 };
12482 }
12483 try {
12484 await api.loadModules(["pixijs"]);
12485 } catch {
12486 host.textContent = __("Mindmap unavailable.");
12487 return () => {
12488 };
12489 }
12490 const pixiMaybe = window.PIXI;
12491 if (!pixiMaybe) {
12492 host.textContent = __("Mindmap unavailable.");
12493 return () => {
12494 };
12495 }
12496 const pixi = pixiMaybe;
12497 host.replaceChildren();
12498 host.classList.add("wpd-mindmap");
12499 const toolbar = document.createElement("div");
12500 toolbar.className = "wpd-mindmap__toolbar";
12501 const addRootBtn = document.createElement("button");
12502 addRootBtn.type = "button";
12503 addRootBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--primary";
12504 addRootBtn.innerHTML = '<span class="dashicons dashicons-plus" aria-hidden="true"></span>' + __("Add root category");
12505 const recenterBtn = document.createElement("button");
12506 recenterBtn.type = "button";
12507 recenterBtn.className = "wpd-mindmap__btn";
12508 recenterBtn.innerHTML = '<span class="dashicons dashicons-image-rotate" aria-hidden="true"></span>' + __("Recenter");
12509 const searchWrap = document.createElement("div");
12510 searchWrap.className = "wpd-mindmap__search";
12511 const searchInput = document.createElement("input");
12512 searchInput.type = "search";
12513 searchInput.className = "wpd-mindmap__search-input";
12514 searchInput.placeholder = __("Search categories…");
12515 searchInput.setAttribute(
12516 "aria-label",
12517 __("Search categories in the mindmap")
12518 );
12519 searchWrap.appendChild(searchInput);
12520 const searchResults = document.createElement("ul");
12521 searchResults.className = "wpd-mindmap__search-results";
12522 searchResults.hidden = true;
12523 searchWrap.appendChild(searchResults);
12524 const hint = document.createElement("span");
12525 hint.className = "wpd-mindmap__hint";
12526 hint.textContent = __(
12527 "Click a node to focus + edit · drag onto another to reparent · wheel to zoom"
12528 );
12529 toolbar.appendChild(addRootBtn);
12530 toolbar.appendChild(recenterBtn);
12531 toolbar.appendChild(searchWrap);
12532 toolbar.appendChild(hint);
12533 host.appendChild(toolbar);
12534 const layout = document.createElement("div");
12535 layout.className = "wpd-mindmap__layout";
12536 host.appendChild(layout);
12537 const stage = document.createElement("div");
12538 stage.className = "wpd-mindmap__stage";
12539 stage.classList.add("is-loading");
12540 layout.appendChild(stage);
12541 const sidebar = document.createElement("aside");
12542 sidebar.className = "wpd-mindmap__sidebar";
12543 layout.appendChild(sidebar);
12544 const app = new pixi.Application();
12545 await app.init({
12546 resizeTo: stage,
12547 backgroundAlpha: 0,
12548 antialias: true,
12549 autoDensity: true,
12550 resolution: Math.min(window.devicePixelRatio || 1, 2)
12551 });
12552 stage.appendChild(app.canvas);
12553 app.canvas.classList.add("wpd-mindmap__canvas");
12554 const world = new pixi.Container();
12555 world.x = stage.clientWidth / 2;
12556 world.y = stage.clientHeight / 2;
12557 app.stage.addChild(world);
12558 const edgeLayer = new pixi.Container();
12559 const nodeLayer = new pixi.Container();
12560 const postEdgeLayer = new pixi.Container();
12561 const postLayer = new pixi.Container();
12562 const chipLayer = new pixi.Container();
12563 const postChipLayer = new pixi.Container();
12564 world.addChild(edgeLayer);
12565 world.addChild(postEdgeLayer);
12566 world.addChild(postLayer);
12567 world.addChild(nodeLayer);
12568 world.addChild(chipLayer);
12569 world.addChild(postChipLayer);
12570 const edgeGfx = new pixi.Graphics();
12571 edgeLayer.addChild(edgeGfx);
12572 const postEdgeGfx = new pixi.Graphics();
12573 postEdgeLayer.addChild(postEdgeGfx);
12574 const CHIP_TEXT_RES2 = 4;
12575 const pager = new pixi.Container();
12576 pager.eventMode = "passive";
12577 pager.visible = false;
12578 postLayer.addChild(pager);
12579 const pagerPrev = new pixi.Graphics();
12580 const pagerNext = new pixi.Graphics();
12581 const pagerLabel = new pixi.Text({
12582 text: "1 / 1",
12583 style: {
12584 fill: 5265246,
12585 fontSize: 14,
12586 fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
12587 fontWeight: "600"
12588 },
12589 resolution: CHIP_TEXT_RES2
12590 });
12591 pagerLabel.anchor.set(0.5);
12592 pagerPrev.eventMode = "static";
12593 pagerPrev.cursor = "pointer";
12594 pagerNext.eventMode = "static";
12595 pagerNext.cursor = "pointer";
12596 pagerPrev.hitArea = new pixi.Circle(0, 0, 16);
12597 pagerNext.hitArea = new pixi.Circle(0, 0, 16);
12598 pager.addChild(pagerPrev);
12599 pager.addChild(pagerLabel);
12600 pager.addChild(pagerNext);
12601 const stopBubble = (e) => {
12602 e.stopPropagation?.();
12603 pixiInteractionAt = performance.now();
12604 };
12605 pagerPrev.on("pointerdown", stopBubble);
12606 pagerNext.on("pointerdown", stopBubble);
12607 pagerPrev.on("pointertap", (e) => {
12608 stopBubble(e);
12609 lastFocusChange = performance.now();
12610 if (focusPage <= 1) {
12611 return;
12612 }
12613 focusPage--;
12614 void loadPostsForFocus();
12615 });
12616 pagerNext.on("pointertap", (e) => {
12617 stopBubble(e);
12618 lastFocusChange = performance.now();
12619 if (focusPage >= focusTotalPages) {
12620 return;
12621 }
12622 focusPage++;
12623 void loadPostsForFocus();
12624 });
12625 const nodes = /* @__PURE__ */ new Map();
12626 const chips = /* @__PURE__ */ new Map();
12627 const postChips = /* @__PURE__ */ new Map();
12628 const postNodes = /* @__PURE__ */ new Map();
12629 let focusId = null;
12630 let focusPage = 1;
12631 let focusTotalPages = 1;
12632 let loadSeq = 0;
12633 let pixiInteractionAt = 0;
12634 let dragNode = null;
12635 let dragHover = null;
12636 let panActive = false;
12637 let panStart = null;
12638 let panMovedDist = 0;
12639 let raf = null;
12640 let lastTick = performance.now();
12641 let targetScale = world.scale.x;
12642 let targetWorldX = world.x;
12643 let targetWorldY = world.y;
12644 let nudgeAwayFrom = null;
12645 const pinnedTargetBackup = /* @__PURE__ */ new Map();
12646 let prevView = null;
12647 let draft = null;
12648 const themeHue = readAdminThemeHue$1();
12649 const clusterColor = (idx) => hslToInt$1((themeHue + idx * 47) % 360, 55, 52);
12650 let terms = [];
12651 try {
12652 const all = [];
12653 let page = 1;
12654 while (page <= 5) {
12655 const res = await client.fetchTerms("categories", { page, perPage: 100 });
12656 all.push(...res.items);
12657 if (page >= res.totalPages) {
12658 break;
12659 }
12660 page++;
12661 }
12662 terms = all;
12663 } catch (err) {
12664 showToast$1(__("Couldn’t load categories:"), err);
12665 }
12666 const showError = (title, err) => showToast$1(title, err);
12667 function isUncategorized(term) {
12668 if (term.isDefault) {
12669 return true;
12670 }
12671 return term.id === 1 || term.slug === "uncategorized" || term.name.toLowerCase() === "uncategorized";
12672 }
12673 function syncEmptyHint() {
12674 const existing = stage.querySelector(".wpd-mindmap__empty");
12675 if (terms.length <= 1) {
12676 if (!existing) {
12677 const empty = document.createElement("div");
12678 empty.className = "wpd-mindmap__empty";
12679 empty.textContent = __(
12680 'No custom categories yet. Click "Add root category" to start branching.'
12681 );
12682 stage.appendChild(empty);
12683 }
12684 } else if (existing) {
12685 existing.remove();
12686 }
12687 }
12688 function buildTree() {
12689 const childMap = /* @__PURE__ */ new Map();
12690 for (const t of terms) {
12691 const list = childMap.get(t.parent) ?? [];
12692 list.push(t);
12693 childMap.set(t.parent, list);
12694 }
12695 const allRoots = childMap.get(0) ?? [];
12696 const roots = allRoots.filter((r) => !isUncategorized(r));
12697 const uncategorized = allRoots.find(isUncategorized);
12698 const place = (term, depth, rootIdx, angle, angleSpan) => {
12699 const rootRingByCount = roots.length > 1 ? 110 + roots.length * 28 : 0;
12700 const rootRing = uncategorized ? Math.max(rootRingByCount, 140) : rootRingByCount;
12701 const baseRadius = depth === 0 ? rootRing : rootRing + 160 + (depth - 1) * 150;
12702 const tx = baseRadius * Math.cos(angle);
12703 const ty = baseRadius * Math.sin(angle);
12704 const radius = nodeRadius(term.count, terms);
12705 const color = depth === 0 ? clusterColor(rootIdx) : nodes.get(term.parent)?.color ?? clusterColor(rootIdx);
12706 let node = nodes.get(term.id);
12707 if (!node) {
12708 const gfx = new pixi.Graphics();
12709 gfx.eventMode = "static";
12710 gfx.cursor = "pointer";
12711 node = {
12712 id: term.id,
12713 parent: term.parent,
12714 name: term.name,
12715 description: term.description,
12716 count: term.count,
12717 x: tx,
12718 y: ty,
12719 tx,
12720 ty,
12721 radius,
12722 depth,
12723 color,
12724 gfx,
12725 pinned: depth === 0
12726 };
12727 nodeLayer.addChild(gfx);
12728 gfx.on("pointerdown", (e) => onNodePointerDown(e, node));
12729 nodes.set(term.id, node);
12730 } else {
12731 node.parent = term.parent;
12732 node.name = term.name;
12733 node.description = term.description;
12734 node.count = term.count;
12735 node.depth = depth;
12736 node.color = color;
12737 node.radius = radius;
12738 node.tx = tx;
12739 node.ty = ty;
12740 node.pinned = depth === 0;
12741 }
12742 drawNodeDisc(node, false);
12743 const kids = childMap.get(term.id) ?? [];
12744 if (kids.length > 0) {
12745 const sub = angleSpan / kids.length;
12746 kids.forEach((child, i) => {
12747 place(
12748 child,
12749 depth + 1,
12750 rootIdx,
12751 angle - angleSpan / 2 + sub * (i + 0.5),
12752 sub * 0.85
12753 );
12754 });
12755 }
12756 };
12757 const liveIds = new Set(terms.map((t) => t.id));
12758 for (const [id, node] of nodes) {
12759 if (!liveIds.has(id)) {
12760 nodeLayer.removeChild(node.gfx);
12761 node.gfx.destroy();
12762 nodes.delete(id);
12763 destroyChip(id);
12764 }
12765 }
12766 const rootCount = Math.max(1, roots.length);
12767 roots.forEach((root, idx) => {
12768 const angle = 2 * Math.PI / rootCount * idx;
12769 place(root, 0, idx, angle, 2 * Math.PI / rootCount);
12770 });
12771 if (uncategorized) {
12772 placeIsolated(uncategorized);
12773 }
12774 syncEmptyHint();
12775 }
12776 function placeIsolated(term) {
12777 const tx = 0;
12778 const ty = 0;
12779 const radius = nodeRadius(term.count, terms);
12780 const color = 9211796;
12781 let node = nodes.get(term.id);
12782 if (!node) {
12783 const gfx = new pixi.Graphics();
12784 gfx.eventMode = "static";
12785 gfx.cursor = "pointer";
12786 node = {
12787 id: term.id,
12788 parent: 0,
12789 name: term.name,
12790 description: term.description,
12791 count: term.count,
12792 x: tx,
12793 y: ty,
12794 tx,
12795 ty,
12796 radius,
12797 depth: 0,
12798 color,
12799 gfx,
12800 pinned: true
12801 };
12802 nodeLayer.addChild(gfx);
12803 gfx.on("pointerdown", (e) => onNodePointerDown(e, node));
12804 nodes.set(term.id, node);
12805 } else {
12806 node.parent = 0;
12807 node.name = term.name;
12808 node.description = term.description;
12809 node.count = term.count;
12810 node.depth = 0;
12811 node.color = color;
12812 node.radius = radius;
12813 node.tx = tx;
12814 node.ty = ty;
12815 node.pinned = true;
12816 }
12817 drawNodeDisc(node, false);
12818 }
12819 function drawCurvedEdge(g, x1, y1, x2, y2, color, opts = {}) {
12820 const dx = x2 - x1;
12821 const cp1x = x1 + dx * 0.5;
12822 const cp1y = y1;
12823 const cp2x = x2 - dx * 0.5;
12824 const cp2y = y2;
12825 const alpha = opts.alpha ?? 0.5;
12826 const width = opts.width ?? 1.5;
12827 if (!opts.dashed) {
12828 g.moveTo(x1, y1);
12829 g.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2);
12830 g.stroke({ color, width, alpha });
12831 return;
12832 }
12833 const sampleAt = (t) => {
12834 const omt = 1 - t;
12835 const px = omt * omt * omt * x1 + 3 * omt * omt * t * cp1x + 3 * omt * t * t * cp2x + t * t * t * x2;
12836 const py = omt * omt * omt * y1 + 3 * omt * omt * t * cp1y + 3 * omt * t * t * cp2y + t * t * t * y2;
12837 return { x: px, y: py };
12838 };
12839 const STEPS = 32;
12840 const phase = opts.dashPhase ?? 0;
12841 const stride = Math.max(1, opts.dashStride ?? 1);
12842 let lastX = x1;
12843 let lastY = y1;
12844 for (let i = 1; i <= STEPS; i++) {
12845 const p = sampleAt(i / STEPS);
12846 const groupIdx = Math.floor((i - 1 + phase) / stride);
12847 const visible = groupIdx % 2 === 0;
12848 if (visible) {
12849 g.moveTo(lastX, lastY);
12850 g.lineTo(p.x, p.y);
12851 g.stroke({ color, width, alpha });
12852 }
12853 lastX = p.x;
12854 lastY = p.y;
12855 }
12856 }
12857 function drawNodeDisc(node, highlighted) {
12858 const g = node.gfx;
12859 g.clear();
12860 const r = node.radius;
12861 if (!highlighted) {
12862 g.circle(0, 5, r);
12863 g.fill({ color: 0, alpha: 0.18 });
12864 }
12865 if (highlighted) {
12866 g.circle(0, 0, r + 10);
12867 g.fill({ color: node.color, alpha: 0.22 });
12868 }
12869 g.circle(0, 0, r);
12870 g.fill(shadeColor(node.color, -0.18));
12871 g.circle(0, -r * 0.06, r * 0.94);
12872 g.fill(node.color);
12873 g.circle(-r * 0.32, -r * 0.42, r * 0.3);
12874 g.fill({ color: 16777215, alpha: 0.32 });
12875 g.circle(0, 0, r);
12876 g.stroke({
12877 color: 16777215,
12878 width: highlighted ? 3 : 2,
12879 alignment: 0
12880 });
12881 g.x = node.x;
12882 g.y = node.y;
12883 g.zIndex = 10;
12884 g.hitArea = new pixi.Circle(0, 0, r + 4);
12885 }
12886 function drawDropTarget(hover, sourceColor) {
12887 drawNodeDisc(hover, false);
12888 const g = hover.gfx;
12889 const t = performance.now();
12890 const pulse = Math.sin(t / 280) * 0.5 + 0.5;
12891 const ringR = hover.radius + 6 + pulse * 5;
12892 g.circle(0, 0, ringR);
12893 g.stroke({
12894 color: sourceColor,
12895 width: 3,
12896 alpha: 0.6 + pulse * 0.35
12897 });
12898 g.circle(0, 0, hover.radius * 0.42);
12899 g.fill({ color: sourceColor, alpha: 0.85 });
12900 g.hitArea = new pixi.Circle(0, 0, hover.radius + 12);
12901 }
12902 function drawEdges() {
12903 edgeGfx.clear();
12904 for (const node of nodes.values()) {
12905 if (!node.parent) {
12906 continue;
12907 }
12908 const parent = nodes.get(node.parent);
12909 if (!parent) {
12910 continue;
12911 }
12912 const isOldLink = dragNode !== null && node === dragNode;
12913 const isFocusEdge = focusId !== null && (node.id === focusId || node.parent === focusId);
12914 const dimMul = focusId !== null && !isFocusEdge ? 0.35 : 1;
12915 drawCurvedEdge(
12916 edgeGfx,
12917 parent.x,
12918 parent.y,
12919 node.x,
12920 node.y,
12921 parent.color,
12922 isOldLink ? { dashed: true, alpha: 0.28 * dimMul } : { alpha: 0.5 * dimMul }
12923 );
12924 }
12925 if (dragNode && dragHover) {
12926 const x1 = dragNode.x;
12927 const y1 = dragNode.y;
12928 const x2 = dragHover.x;
12929 const y2 = dragHover.y;
12930 const targetColor = dragHover.color;
12931 drawCurvedEdge(edgeGfx, x1, y1, x2, y2, targetColor, {
12932 alpha: 0.22,
12933 width: 9
12934 });
12935 const dashPhase = Math.floor(performance.now() / 70);
12936 drawCurvedEdge(edgeGfx, x1, y1, x2, y2, targetColor, {
12937 alpha: 0.95,
12938 width: 2.5,
12939 dashed: true,
12940 dashStride: 2,
12941 dashPhase
12942 });
12943 const pt = performance.now() % 1300 / 1300;
12944 const omt = 1 - pt;
12945 const dx = x2 - x1;
12946 const cp1x = x1 + dx * 0.5;
12947 const cp1y = y1;
12948 const cp2x = x2 - dx * 0.5;
12949 const cp2y = y2;
12950 const px = omt * omt * omt * x1 + 3 * omt * omt * pt * cp1x + 3 * omt * pt * pt * cp2x + pt * pt * pt * x2;
12951 const py = omt * omt * omt * y1 + 3 * omt * omt * pt * cp1y + 3 * omt * pt * pt * cp2y + pt * pt * pt * y2;
12952 edgeGfx.circle(px, py, 5);
12953 edgeGfx.fill({ color: 16777215, alpha: 0.95 });
12954 edgeGfx.stroke({ color: targetColor, width: 2, alpha: 1 });
12955 }
12956 postEdgeGfx.clear();
12957 if (focusId !== null) {
12958 const center = nodes.get(focusId);
12959 if (center) {
12960 for (const post of postNodes.values()) {
12961 postEdgeGfx.moveTo(center.x, center.y);
12962 postEdgeGfx.lineTo(post.x, post.y);
12963 postEdgeGfx.stroke({
12964 color: center.color,
12965 width: 1,
12966 alpha: 0.35
12967 });
12968 }
12969 }
12970 }
12971 }
12972 const FONT_FAMILY2 = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
12973 const CHIP_NAME_MAX_CHARS2 = 18;
12974 const POST_TITLE_MAX_CHARS2 = 22;
12975 function truncateChipName2(name) {
12976 return name.length > CHIP_NAME_MAX_CHARS2 ? name.slice(0, CHIP_NAME_MAX_CHARS2 - 1) + "…" : name;
12977 }
12978 function ensureChip(node) {
12979 const existing = chips.get(node.id);
12980 if (existing) {
12981 return existing;
12982 }
12983 const container = new pixi.Container();
12984 container.eventMode = "static";
12985 container.cursor = "pointer";
12986 const bg = new pixi.Graphics();
12987 container.addChild(bg);
12988 const nameText = new pixi.Text({
12989 text: truncateChipName2(node.name),
12990 style: {
12991 fill: 1909543,
12992 fontSize: 14,
12993 fontFamily: FONT_FAMILY2,
12994 fontWeight: "600"
12995 },
12996 resolution: CHIP_TEXT_RES2
12997 });
12998 container.addChild(nameText);
12999 const countBg = new pixi.Graphics();
13000 container.addChild(countBg);
13001 const countText = new pixi.Text({
13002 text: String(node.count),
13003 style: {
13004 fill: 16777215,
13005 fontSize: 12,
13006 fontFamily: FONT_FAMILY2,
13007 fontWeight: "700"
13008 },
13009 resolution: CHIP_TEXT_RES2
13010 });
13011 container.addChild(countText);
13012 const chip = {
13013 container,
13014 bg,
13015 nameText,
13016 countBg,
13017 countText,
13018 width: 0,
13019 height: 0,
13020 cachedName: "",
13021 cachedCount: -1,
13022 cachedFocused: false,
13023 cachedHover: false,
13024 cachedColor: -1
13025 };
13026 chips.set(node.id, chip);
13027 chipLayer.addChild(container);
13028 container.on("pointerdown", (e) => {
13029 e.stopPropagation?.();
13030 pixiInteractionAt = performance.now();
13031 });
13032 container.on("pointertap", () => {
13033 void focusNode(node.id);
13034 });
13035 container.on("pointerover", () => {
13036 chip.cachedHover = true;
13037 layoutChip(chip, node);
13038 });
13039 container.on("pointerout", () => {
13040 chip.cachedHover = false;
13041 layoutChip(chip, node);
13042 });
13043 return chip;
13044 }
13045 function layoutChip(chip, node) {
13046 const focused = focusId === node.id;
13047 const displayName = truncateChipName2(node.name);
13048 const countStr = String(node.count);
13049 if (chip.nameText.text !== displayName) {
13050 chip.nameText.text = displayName;
13051 }
13052 if (chip.countText.text !== countStr) {
13053 chip.countText.text = countStr;
13054 }
13055 chip.cachedName = displayName;
13056 chip.cachedCount = node.count;
13057 chip.cachedFocused = focused;
13058 chip.cachedColor = node.color;
13059 const padX = 9;
13060 const padY = 3;
13061 const gap = 5;
13062 const countPadX = 5;
13063 const countPadY = 2;
13064 const minBadgeW = 18;
13065 const nameW = chip.nameText.width;
13066 const nameH = chip.nameText.height;
13067 const countW = chip.countText.width;
13068 const countH = chip.countText.height;
13069 const badgeW = Math.max(minBadgeW, countW + countPadX * 2);
13070 const badgeH = countH + countPadY * 2;
13071 const totalW = padX + nameW + gap + badgeW + padX;
13072 const totalH = Math.max(nameH, badgeH) + padY * 2;
13073 chip.width = totalW;
13074 chip.height = totalH;
13075 const left = -totalW / 2;
13076 chip.bg.clear();
13077 chip.bg.roundRect(left, 0, totalW, totalH, totalH / 2);
13078 if (focused) {
13079 chip.bg.fill(node.color);
13080 } else if (chip.cachedHover) {
13081 chip.bg.fill({ color: 16777215, alpha: 0.96 });
13082 chip.bg.stroke({
13083 color: node.color,
13084 width: 1.5,
13085 alpha: 1
13086 });
13087 } else {
13088 chip.bg.fill({ color: 16777215, alpha: 0.88 });
13089 chip.bg.stroke({
13090 color: 0,
13091 width: 1,
13092 alpha: 0.06
13093 });
13094 }
13095 chip.nameText.x = left + padX;
13096 chip.nameText.y = (totalH - nameH) / 2;
13097 chip.nameText.style.fill = focused ? 16777215 : 1909543;
13098 const badgeX = left + padX + nameW + gap;
13099 const badgeY = (totalH - badgeH) / 2;
13100 chip.countBg.clear();
13101 chip.countBg.roundRect(
13102 badgeX,
13103 badgeY,
13104 badgeW,
13105 badgeH,
13106 badgeH / 2
13107 );
13108 chip.countBg.fill(
13109 focused ? { color: 16777215, alpha: 0.25 } : node.color
13110 );
13111 chip.countText.x = badgeX + (badgeW - countW) / 2;
13112 chip.countText.y = badgeY + (badgeH - countH) / 2;
13113 }
13114 function destroyChip(id) {
13115 const chip = chips.get(id);
13116 if (!chip) {
13117 return;
13118 }
13119 chipLayer.removeChild(chip.container);
13120 chip.container.destroy({ children: true });
13121 chips.delete(id);
13122 }
13123 function syncChipPositions() {
13124 const activeIds = new Set(nodes.keys());
13125 for (const id of [...chips.keys()]) {
13126 if (!activeIds.has(id)) {
13127 destroyChip(id);
13128 }
13129 }
13130 const chipCounterScale = 1 / Math.max(0.01, world.scale.x);
13131 const anyFocus = focusId !== null;
13132 for (const node of nodes.values()) {
13133 const chip = ensureChip(node);
13134 chip.container.x = node.x;
13135 chip.container.y = node.y + node.radius + 6;
13136 chip.container.scale.set(chipCounterScale);
13137 const focused = focusId === node.id;
13138 const targetAlpha = !anyFocus || focused ? 1 : 0.4;
13139 if (Math.abs(chip.container.alpha - targetAlpha) > 5e-3) {
13140 chip.container.alpha += (targetAlpha - chip.container.alpha) * 0.18;
13141 } else {
13142 chip.container.alpha = targetAlpha;
13143 }
13144 if (Math.abs(node.gfx.alpha - targetAlpha) > 5e-3) {
13145 node.gfx.alpha += (targetAlpha - node.gfx.alpha) * 0.18;
13146 } else {
13147 node.gfx.alpha = targetAlpha;
13148 }
13149 const displayName = truncateChipName2(node.name);
13150 if (chip.cachedName !== displayName || chip.cachedCount !== node.count || chip.cachedFocused !== focused || chip.cachedColor !== node.color) {
13151 layoutChip(chip, node);
13152 }
13153 }
13154 for (const post of postNodes.values()) {
13155 const chip = postChips.get(post.id);
13156 if (!chip) {
13157 continue;
13158 }
13159 chip.container.x = post.x;
13160 chip.container.y = post.y;
13161 chip.container.scale.set(chipCounterScale);
13162 if (chip.container.alpha < 1) {
13163 chip.container.alpha = Math.min(
13164 1,
13165 chip.container.alpha + 0.18
13166 );
13167 }
13168 }
13169 }
13170 function physicsStep(dt) {
13171 const list = Array.from(nodes.values());
13172 for (const a of list) {
13173 if (a.pinned) {
13174 a.x += (a.tx - a.x) * 0.12;
13175 a.y += (a.ty - a.y) * 0.12;
13176 a.gfx.x = a.x;
13177 a.gfx.y = a.y;
13178 continue;
13179 }
13180 let fx = 0;
13181 let fy = 0;
13182 for (const b of list) {
13183 if (a === b) {
13184 continue;
13185 }
13186 const dx = a.x - b.x;
13187 const dy = a.y - b.y;
13188 const d2 = dx * dx + dy * dy + 1;
13189 const f = REPULSION_K / d2;
13190 const d = Math.sqrt(d2);
13191 fx += dx / d * f;
13192 fy += dy / d * f;
13193 }
13194 const parent = nodes.get(a.parent);
13195 if (parent) {
13196 const dx = parent.x - a.x;
13197 const dy = parent.y - a.y;
13198 const d = Math.sqrt(dx * dx + dy * dy) || 1;
13199 const stretch = d - SPRING_LEN;
13200 fx += dx / d * stretch * SPRING_K;
13201 fy += dy / d * stretch * SPRING_K;
13202 } else {
13203 fx += -a.x * 8e-4;
13204 fy += -a.y * 8e-4;
13205 }
13206 if (nudgeAwayFrom && a.id !== focusId) {
13207 const ndx = a.x - nudgeAwayFrom.x;
13208 const ndy = a.y - nudgeAwayFrom.y;
13209 const nd = Math.sqrt(ndx * ndx + ndy * ndy) || 1;
13210 const limit = nudgeAwayFrom.radius + a.radius;
13211 if (nd < limit) {
13212 const pushK = 18;
13213 fx += ndx / nd * pushK * (limit - nd);
13214 fy += ndy / nd * pushK * (limit - nd);
13215 }
13216 }
13217 if (a !== dragNode) {
13218 a.x += fx * dt * 1e-3 + (a.tx - a.x) * 0.02;
13219 a.y += fy * dt * 1e-3 + (a.ty - a.y) * 0.02;
13220 }
13221 a.gfx.x = a.x;
13222 a.gfx.y = a.y;
13223 }
13224 }
13225 function preSettlePhysics(iterations) {
13226 for (let i = 0; i < iterations; i++) {
13227 physicsStep(16);
13228 }
13229 for (const n of nodes.values()) {
13230 n.tx = n.x;
13231 n.ty = n.y;
13232 }
13233 }
13234 function tick() {
13235 const now = performance.now();
13236 const dt = Math.min(50, now - lastTick);
13237 lastTick = now;
13238 const ZOOM_EASE = 0.22;
13239 const ds = targetScale - world.scale.x;
13240 const dwx = targetWorldX - world.x;
13241 const dwy = targetWorldY - world.y;
13242 if (Math.abs(ds) > 5e-4 || Math.abs(dwx) > 0.5 || Math.abs(dwy) > 0.5) {
13243 world.scale.set(world.scale.x + ds * ZOOM_EASE);
13244 world.x += dwx * ZOOM_EASE;
13245 world.y += dwy * ZOOM_EASE;
13246 }
13247 physicsStep(dt);
13248 for (const p of postNodes.values()) {
13249 p.x += (p.tx - p.x) * 0.18;
13250 p.y += (p.ty - p.y) * 0.18;
13251 p.gfx.x = p.x;
13252 p.gfx.y = p.y;
13253 }
13254 drawEdges();
13255 if (dragNode && dragHover) {
13256 drawDropTarget(dragHover, dragNode.color);
13257 }
13258 syncChipPositions();
13259 raf = requestAnimationFrame(tick);
13260 }
13261 let dragStartPos = null;
13262 let dragOffset = { x: 0, y: 0 };
13263 function onNodePointerDown(e, node) {
13264 const ev = e;
13265 ev.stopPropagation?.();
13266 pixiInteractionAt = performance.now();
13267 dragNode = node;
13268 node.pinned = true;
13269 node.tx = node.x;
13270 node.ty = node.y;
13271 dragStartPos = { x: ev.global.x, y: ev.global.y };
13272 const local = stageToWorld({ x: ev.global.x, y: ev.global.y });
13273 dragOffset = { x: node.x - local.x, y: node.y - local.y };
13274 }
13275 function stageToWorld(global) {
13276 return {
13277 x: (global.x - world.x) / world.scale.x,
13278 y: (global.y - world.y) / world.scale.y
13279 };
13280 }
13281 function onStagePointerDown(e) {
13282 const ev = e;
13283 panActive = true;
13284 panStart = { x: ev.global.x, y: ev.global.y };
13285 panMovedDist = 0;
13286 }
13287 function onStagePointerMove(e) {
13288 const ev = e;
13289 if (dragNode) {
13290 const cursorWorld = stageToWorld(ev.global);
13291 const nx = cursorWorld.x + dragOffset.x;
13292 const ny = cursorWorld.y + dragOffset.y;
13293 dragNode.x = nx;
13294 dragNode.y = ny;
13295 dragNode.tx = nx;
13296 dragNode.ty = ny;
13297 dragNode.gfx.x = nx;
13298 dragNode.gfx.y = ny;
13299 let hover = null;
13300 for (const c of nodes.values()) {
13301 if (c === dragNode) {
13302 continue;
13303 }
13304 const dx = c.x - cursorWorld.x;
13305 const dy = c.y - cursorWorld.y;
13306 if (dx * dx + dy * dy < c.radius * c.radius) {
13307 hover = c;
13308 break;
13309 }
13310 }
13311 if (hover !== dragHover) {
13312 if (dragHover) {
13313 drawNodeDisc(dragHover, focusId === dragHover.id);
13314 }
13315 dragHover = hover;
13316 if (hover && dragNode) {
13317 drawDropTarget(hover, dragNode.color);
13318 }
13319 }
13320 return;
13321 }
13322 if (panActive && panStart) {
13323 const dx = ev.global.x - panStart.x;
13324 const dy = ev.global.y - panStart.y;
13325 world.x += dx;
13326 world.y += dy;
13327 targetWorldX += dx;
13328 targetWorldY += dy;
13329 panMovedDist += Math.sqrt(dx * dx + dy * dy);
13330 panStart = { x: ev.global.x, y: ev.global.y };
13331 }
13332 }
13333 async function onStagePointerUp(e) {
13334 if (dragNode) {
13335 const node = dragNode;
13336 const target = dragHover;
13337 const startPos = dragStartPos;
13338 dragNode = null;
13339 dragHover = null;
13340 dragStartPos = null;
13341 node.pinned = node.depth === 0;
13342 let movement = Infinity;
13343 const ev = e;
13344 if (startPos && ev && ev.global) {
13345 const dx = ev.global.x - startPos.x;
13346 const dy = ev.global.y - startPos.y;
13347 movement = Math.sqrt(dx * dx + dy * dy);
13348 }
13349 if (!target && movement < 2) {
13350 focusNode(node.id);
13351 panActive = false;
13352 panStart = null;
13353 return;
13354 }
13355 if (target && target.id !== node.parent && !isAncestor(node.id, target.id)) {
13356 try {
13357 await client.updateTerm("categories", node.id, {
13358 parent: target.id
13359 });
13360 node.parent = target.id;
13361 terms = terms.map(
13362 (t) => t.id === node.id ? { ...t, parent: target.id } : t
13363 );
13364 buildTree();
13365 } catch (err) {
13366 showError(__("Reparent failed:"), err);
13367 }
13368 } else {
13369 drawNodeDisc(node, focusId === node.id);
13370 if (target) {
13371 drawNodeDisc(target, focusId === target.id);
13372 }
13373 }
13374 }
13375 panActive = false;
13376 panStart = null;
13377 }
13378 app.stage.eventMode = "static";
13379 app.stage.hitArea = new pixi.Rectangle(
13380 0,
13381 0,
13382 stage.clientWidth,
13383 stage.clientHeight
13384 );
13385 app.stage.on("pointerdown", onStagePointerDown);
13386 app.stage.on("pointermove", onStagePointerMove);
13387 app.stage.on("pointerup", (e) => void onStagePointerUp(e));
13388 app.stage.on("pointerupoutside", (e) => void onStagePointerUp(e));
13389 function onWheel(e) {
13390 e.preventDefault();
13391 const SENSITIVITY = 8e-4;
13392 const factor = Math.exp(-e.deltaY * SENSITIVITY);
13393 const prev = targetScale;
13394 const next = Math.max(0.3, Math.min(2.5, prev * factor));
13395 if (Math.abs(next - prev) < 5e-4) {
13396 return;
13397 }
13398 const r = stage.getBoundingClientRect();
13399 const sx = e.clientX - r.left;
13400 const sy = e.clientY - r.top;
13401 const wx = (sx - targetWorldX) / prev;
13402 const wy = (sy - targetWorldY) / prev;
13403 targetScale = next;
13404 targetWorldX = sx - wx * next;
13405 targetWorldY = sy - wy * next;
13406 }
13407 stage.addEventListener("wheel", onWheel, { passive: false });
13408 let firstFitDone = false;
13409 let settledW = 0;
13410 let settledH = 0;
13411 const SETTLE_THRESHOLD_PX = 24;
13412 const SETTLE_DEBOUNCE_MS = 80;
13413 let settleTimer = null;
13414 function onResize() {
13415 const r = stage.getBoundingClientRect();
13416 app.renderer.resize(r.width, r.height);
13417 app.stage.hitArea = new pixi.Rectangle(0, 0, r.width, r.height);
13418 if (!firstFitDone && r.width > 0 && r.height > 0) {
13419 firstFitDone = true;
13420 settledW = r.width;
13421 settledH = r.height;
13422 fitToView();
13423 stage.classList.remove("is-loading");
13424 }
13425 if (settleTimer !== null) {
13426 window.clearTimeout(settleTimer);
13427 }
13428 settleTimer = window.setTimeout(() => {
13429 settleTimer = null;
13430 const cur = stage.getBoundingClientRect();
13431 const dw = Math.abs(cur.width - settledW);
13432 const dh = Math.abs(cur.height - settledH);
13433 if (dw >= SETTLE_THRESHOLD_PX || dh >= SETTLE_THRESHOLD_PX) {
13434 settledW = cur.width;
13435 settledH = cur.height;
13436 recenterCamera();
13437 }
13438 }, SETTLE_DEBOUNCE_MS);
13439 app.render();
13440 }
13441 const ro = new ResizeObserver(onResize);
13442 ro.observe(stage);
13443 function isAncestor(ancestor, descendant) {
13444 let cur = nodes.get(descendant);
13445 let safety = 32;
13446 while (cur && safety-- > 0) {
13447 if (cur.id === ancestor) {
13448 return true;
13449 }
13450 if (!cur.parent) {
13451 return false;
13452 }
13453 cur = nodes.get(cur.parent);
13454 }
13455 return false;
13456 }
13457 let lastFocusChange = 0;
13458 const SPOTLIGHT_RADIUS2 = POST_RING_RADIUS$1 + 130;
13459 async function focusNode(id) {
13460 if (focusId === id) {
13461 closeFocus();
13462 return;
13463 }
13464 const wasFocused = focusId !== null;
13465 focusId = id;
13466 focusPage = 1;
13467 lastFocusChange = performance.now();
13468 const focused = nodes.get(id);
13469 if (focused) {
13470 if (!wasFocused) {
13471 prevView = {
13472 scale: targetScale,
13473 x: targetWorldX,
13474 y: targetWorldY
13475 };
13476 }
13477 const r = stage.getBoundingClientRect();
13478 if (r.width > 0 && r.height > 0) {
13479 const half = POST_RING_RADIUS$1 + 70;
13480 const sx = r.width * 0.85 / (2 * half);
13481 const sy = r.height * 0.85 / (2 * half);
13482 const newScale = Math.max(
13483 0.5,
13484 Math.min(1.6, Math.min(sx, sy))
13485 );
13486 targetScale = newScale;
13487 targetWorldX = r.width / 2 - focused.x * newScale;
13488 targetWorldY = r.height / 2 - focused.y * newScale;
13489 }
13490 nudgeAwayFrom = {
13491 x: focused.x,
13492 y: focused.y,
13493 radius: SPOTLIGHT_RADIUS2
13494 };
13495 pinnedTargetBackup.clear();
13496 for (const n of nodes.values()) {
13497 if (n.id === id || !n.pinned) {
13498 continue;
13499 }
13500 const dx = n.x - focused.x;
13501 const dy = n.y - focused.y;
13502 const d = Math.sqrt(dx * dx + dy * dy) || 1;
13503 if (d >= SPOTLIGHT_RADIUS2 + n.radius) {
13504 continue;
13505 }
13506 pinnedTargetBackup.set(n.id, { tx: n.tx, ty: n.ty });
13507 const push = SPOTLIGHT_RADIUS2 + n.radius + 20;
13508 n.tx = focused.x + dx / d * push;
13509 n.ty = focused.y + dy / d * push;
13510 }
13511 }
13512 for (const n of nodes.values()) {
13513 drawNodeDisc(n, focusId === n.id);
13514 }
13515 paintSidebar();
13516 await loadPostsForFocus();
13517 }
13518 function closeFocus() {
13519 focusId = null;
13520 lastFocusChange = performance.now();
13521 loadSeq++;
13522 nudgeAwayFrom = null;
13523 for (const [id, t] of pinnedTargetBackup) {
13524 const n = nodes.get(id);
13525 if (n) {
13526 n.tx = t.tx;
13527 n.ty = t.ty;
13528 }
13529 }
13530 pinnedTargetBackup.clear();
13531 if (prevView) {
13532 targetScale = prevView.scale;
13533 targetWorldX = prevView.x;
13534 targetWorldY = prevView.y;
13535 prevView = null;
13536 }
13537 paintSidebar();
13538 clearPosts();
13539 for (const n of nodes.values()) {
13540 drawNodeDisc(n, false);
13541 }
13542 }
13543 function clearPosts() {
13544 for (const post of postNodes.values()) {
13545 postLayer.removeChild(post.gfx);
13546 post.gfx.destroy();
13547 }
13548 postNodes.clear();
13549 for (const chip of postChips.values()) {
13550 postChipLayer.removeChild(chip.container);
13551 chip.container.destroy({ children: true });
13552 }
13553 postChips.clear();
13554 postEdgeGfx.clear();
13555 pager.visible = false;
13556 }
13557 function ensurePostChip(post) {
13558 const existing = postChips.get(post.id);
13559 if (existing) {
13560 return existing;
13561 }
13562 const container = new pixi.Container();
13563 container.eventMode = "static";
13564 container.cursor = "pointer";
13565 container.alpha = 0;
13566 const bg = new pixi.Graphics();
13567 container.addChild(bg);
13568 const dot = new pixi.Graphics();
13569 container.addChild(dot);
13570 const titleText = new pixi.Text({
13571 text: post.title,
13572 style: {
13573 fill: 1909543,
13574 // Matches category chip fontSize so the two read at
13575 // the same weight when both are deployed. Base size
13576 // is the on-screen size since the post chip's
13577 // container counter-scales with `1/world.scale.x`
13578 // in `syncChipPositions`.
13579 fontSize: 14,
13580 fontFamily: FONT_FAMILY2,
13581 fontWeight: "500"
13582 },
13583 resolution: CHIP_TEXT_RES2
13584 });
13585 container.addChild(titleText);
13586 const chip = {
13587 container,
13588 bg,
13589 dot,
13590 titleText,
13591 width: 0,
13592 height: 0,
13593 cachedTitle: "",
13594 cachedHover: false
13595 };
13596 postChips.set(post.id, chip);
13597 postChipLayer.addChild(container);
13598 container.on("pointerdown", (e) => {
13599 e.stopPropagation?.();
13600 pixiInteractionAt = performance.now();
13601 });
13602 container.on("pointertap", () => {
13603 openInPostsTab(post.id, post.editUrl, post.title);
13604 closeFocus();
13605 });
13606 container.on("pointerover", () => {
13607 chip.cachedHover = true;
13608 layoutPostChip(chip, post);
13609 });
13610 container.on("pointerout", () => {
13611 chip.cachedHover = false;
13612 layoutPostChip(chip, post);
13613 });
13614 layoutPostChip(chip, post);
13615 return chip;
13616 }
13617 function layoutPostChip(chip, post) {
13618 const displayTitle = post.title.length > POST_TITLE_MAX_CHARS2 ? post.title.slice(0, POST_TITLE_MAX_CHARS2 - 1) + "…" : post.title;
13619 if (chip.titleText.text !== displayTitle) {
13620 chip.titleText.text = displayTitle;
13621 }
13622 chip.cachedTitle = displayTitle;
13623 const padX = 9;
13624 const padY = 3;
13625 const dotR = 4;
13626 const gap = 6;
13627 const titleW = chip.titleText.width;
13628 const titleH = chip.titleText.height;
13629 const totalW = padX + dotR * 2 + gap + titleW + padX;
13630 const totalH = Math.max(titleH, dotR * 2) + padY * 2;
13631 chip.width = totalW;
13632 chip.height = totalH;
13633 const left = -totalW / 2;
13634 const top = -totalH / 2;
13635 chip.bg.clear();
13636 chip.bg.roundRect(left, top, totalW, totalH, totalH / 2);
13637 if (chip.cachedHover) {
13638 chip.bg.fill({ color: 16777215, alpha: 1 });
13639 chip.bg.stroke({
13640 color: post.tone,
13641 width: 1.5,
13642 alpha: 1
13643 });
13644 } else {
13645 chip.bg.fill({ color: 16777215, alpha: 0.95 });
13646 chip.bg.stroke({
13647 color: 0,
13648 width: 1,
13649 alpha: 0.12
13650 });
13651 }
13652 chip.dot.clear();
13653 chip.dot.circle(left + padX + dotR, 0, dotR);
13654 chip.dot.fill({ color: post.tone, alpha: 0.85 });
13655 chip.dot.stroke({ color: 16777215, width: 1 });
13656 chip.titleText.x = left + padX + dotR * 2 + gap;
13657 chip.titleText.y = -titleH / 2;
13658 }
13659 const POSTS_CACHE_TTL_MS = 6e4;
13660 const postsCache = /* @__PURE__ */ new Map();
13661 function applyPostsResult(entry, focusedNodeId) {
13662 focusTotalPages = entry.totalPages;
13663 if (Number.isFinite(entry.realTotal)) {
13664 const node = nodes.get(focusedNodeId);
13665 if (node && node.count !== entry.realTotal) {
13666 node.count = entry.realTotal;
13667 terms = terms.map(
13668 (t) => t.id === node.id ? { ...t, count: entry.realTotal } : t
13669 );
13670 layoutChip(ensureChip(node), node);
13671 }
13672 }
13673 renderPosts(entry.items);
13674 }
13675 async function loadPostsForFocus() {
13676 if (focusId === null) {
13677 return;
13678 }
13679 const mySeq = ++loadSeq;
13680 const myFocusId = focusId;
13681 const cacheKey2 = `${focusId}:${focusPage}`;
13682 const cached = postsCache.get(cacheKey2);
13683 if (cached && performance.now() - cached.fetchedAt < POSTS_CACHE_TTL_MS) {
13684 applyPostsResult(cached, myFocusId);
13685 return;
13686 }
13687 const cfg = client.getConfig();
13688 const url = new URL(cfg.postsUrl);
13689 url.searchParams.set("categories", String(focusId));
13690 url.searchParams.set("per_page", String(POST_PER_PAGE$1));
13691 url.searchParams.set("page", String(focusPage));
13692 url.searchParams.set("status", "any");
13693 url.searchParams.set("_fields", "id,title,status");
13694 try {
13695 const response = await fetchShellJson$1(client, url.toString());
13696 if (mySeq !== loadSeq || focusId !== myFocusId) {
13697 return;
13698 }
13699 const raw = response.json ?? [];
13700 const totalPages = Math.max(
13701 1,
13702 parseInt(response.headers.get("X-WP-TotalPages") ?? "1", 10) || 1
13703 );
13704 const realTotalParsed = parseInt(response.headers.get("X-WP-Total") ?? "", 10);
13705 const realTotal = Number.isFinite(realTotalParsed) ? realTotalParsed : -1;
13706 const items = raw.map((p) => ({
13707 id: p.id,
13708 title: stripTags$1(p.title?.rendered || `#${p.id}`),
13709 editUrl: `${cfg.editPostUrlBase}?post=${p.id}&action=edit`
13710 }));
13711 const entry = {
13712 items,
13713 totalPages,
13714 realTotal,
13715 fetchedAt: performance.now()
13716 };
13717 postsCache.set(cacheKey2, entry);
13718 applyPostsResult(entry, myFocusId);
13719 } catch (err) {
13720 showError(__("Couldn’t load posts:"), err);
13721 }
13722 }
13723 function renderPosts(items) {
13724 clearPosts();
13725 if (focusId === null) {
13726 return;
13727 }
13728 const center = nodes.get(focusId);
13729 if (!center) {
13730 return;
13731 }
13732 const count = items.length;
13733 const ringR = POST_RING_RADIUS$1 + Math.max(0, count - 8) * 6;
13734 items.forEach((item, idx) => {
13735 const angle = 2 * Math.PI / Math.max(1, count) * idx - Math.PI / 2;
13736 const tx = center.x + Math.cos(angle) * ringR;
13737 const ty = center.y + Math.sin(angle) * ringR;
13738 const tone = center.color;
13739 const gfx = new pixi.Graphics();
13740 postLayer.addChild(gfx);
13741 const post = {
13742 id: item.id,
13743 title: item.title,
13744 editUrl: item.editUrl,
13745 angle,
13746 r: ringR,
13747 x: center.x,
13748 y: center.y,
13749 tx,
13750 ty,
13751 gfx,
13752 tone
13753 };
13754 postNodes.set(item.id, post);
13755 ensurePostChip(post);
13756 });
13757 repaintPager();
13758 }
13759 function repaintPager() {
13760 if (focusId === null || focusTotalPages <= 1) {
13761 pager.visible = false;
13762 return;
13763 }
13764 pager.visible = true;
13765 const center = nodes.get(focusId);
13766 if (!center) {
13767 pager.visible = false;
13768 return;
13769 }
13770 const prevDisabled = focusPage <= 1;
13771 const nextDisabled = focusPage >= focusTotalPages;
13772 drawPagerButton(pagerPrev, "◀", prevDisabled);
13773 drawPagerButton(pagerNext, "▶", nextDisabled);
13774 pagerPrev.cursor = prevDisabled ? "default" : "pointer";
13775 pagerNext.cursor = nextDisabled ? "default" : "pointer";
13776 pagerLabel.text = `${focusPage} / ${focusTotalPages}`;
13777 pagerPrev.x = -38;
13778 pagerPrev.y = 0;
13779 pagerNext.x = 38;
13780 pagerNext.y = 0;
13781 pagerLabel.x = 0;
13782 pagerLabel.y = 0;
13783 pager.x = center.x;
13784 pager.y = center.y + POST_RING_RADIUS$1 + 60;
13785 }
13786 function drawPagerButton(gfx, glyph, disabled) {
13787 gfx.clear();
13788 gfx.circle(0, 0, 14);
13789 gfx.fill({
13790 color: disabled ? 15921906 : 16777215,
13791 alpha: disabled ? 0.7 : 1
13792 });
13793 gfx.stroke({
13794 color: 0,
13795 width: 1,
13796 alpha: 0.12
13797 });
13798 const children = gfx.children;
13799 const label = children?.[0] ?? null;
13800 if (!label) {
13801 const t = new pixi.Text({
13802 text: glyph,
13803 style: {
13804 fill: disabled ? 11580344 : 5265246,
13805 fontSize: 16,
13806 fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
13807 fontWeight: "600"
13808 },
13809 resolution: CHIP_TEXT_RES2
13810 });
13811 t.anchor.set(0.5);
13812 gfx.addChild(t);
13813 } else {
13814 label.text = glyph;
13815 label.style.fill = disabled ? 11580344 : 5265246;
13816 }
13817 }
13818 function openInPostsTab(_id, editUrl, title) {
13819 const wm = api?.windowManager;
13820 const derive = api?.deriveWindowId;
13821 const postsWin = wm && typeof wm.getById === "function" ? wm.getById("desktop-mode-posts") : void 0;
13822 if (postsWin && typeof postsWin.isFullscreen === "function" && typeof postsWin.toggleFullscreen === "function" && postsWin.isFullscreen()) {
13823 postsWin.toggleFullscreen();
13824 }
13825 if (wm && typeof derive === "function") {
13826 const id = derive(editUrl);
13827 wm.open({
13828 id,
13829 baseId: id,
13830 url: editUrl,
13831 title: title ?? editUrl,
13832 icon: "dashicons-admin-post"
13833 });
13834 return;
13835 }
13836 try {
13837 window.open(editUrl, "_blank");
13838 } catch {
13839 window.location.assign(editUrl);
13840 }
13841 }
13842 function paintDraftSidebar(d) {
13843 const parentNode = d.parent !== 0 ? nodes.get(d.parent) : null;
13844 const header = document.createElement("div");
13845 header.className = "wpd-mindmap__sidebar-header";
13846 const dot = document.createElement("span");
13847 dot.className = "wpd-mindmap__sidebar-dot";
13848 const color = parentNode ? parentNode.color : clusterColor(terms.length);
13849 dot.style.background = `#${color.toString(16).padStart(6, "0")}`;
13850 const label = document.createElement("code");
13851 label.className = "wpd-mindmap__sidebar-slug";
13852 label.textContent = parentNode ? sprintf(
13853 /* translators: %s: parent category name. */
13854 __("New child of %s"),
13855 parentNode.name
13856 ) : __("New root category");
13857 header.appendChild(dot);
13858 header.appendChild(label);
13859 sidebar.appendChild(header);
13860 const nameLabel = document.createElement("label");
13861 nameLabel.className = "wpd-mindmap__sidebar-label";
13862 nameLabel.textContent = __("Name");
13863 sidebar.appendChild(nameLabel);
13864 const nameInput = document.createElement("input");
13865 nameInput.type = "text";
13866 nameInput.className = "wpd-mindmap__editor-name";
13867 nameInput.placeholder = __("e.g. Recipes");
13868 sidebar.appendChild(nameInput);
13869 requestAnimationFrame(() => nameInput.focus());
13870 const slugLabel = document.createElement("label");
13871 slugLabel.className = "wpd-mindmap__sidebar-label";
13872 slugLabel.textContent = __("Slug");
13873 sidebar.appendChild(slugLabel);
13874 const slugInput = document.createElement("input");
13875 slugInput.type = "text";
13876 slugInput.className = "wpd-mindmap__editor-name";
13877 slugInput.placeholder = __("auto-from-name");
13878 slugInput.spellcheck = false;
13879 slugInput.autocapitalize = "off";
13880 slugInput.addEventListener("input", () => {
13881 const v = slugInput.value;
13882 const norm = v.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
13883 if (v !== norm) {
13884 const sel = slugInput.selectionStart ?? norm.length;
13885 slugInput.value = norm;
13886 slugInput.setSelectionRange(sel, sel);
13887 }
13888 });
13889 sidebar.appendChild(slugInput);
13890 const descLabel = document.createElement("label");
13891 descLabel.className = "wpd-mindmap__sidebar-label";
13892 descLabel.textContent = __("Description");
13893 sidebar.appendChild(descLabel);
13894 const descInput = document.createElement("textarea");
13895 descInput.className = "wpd-mindmap__editor-desc";
13896 descInput.placeholder = __("Description (optional)");
13897 descInput.rows = 4;
13898 sidebar.appendChild(descInput);
13899 const actions = document.createElement("div");
13900 actions.className = "wpd-mindmap__editor-actions";
13901 const createBtn = document.createElement("button");
13902 createBtn.type = "button";
13903 createBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--primary";
13904 createBtn.textContent = __("Create");
13905 const cancelBtn = document.createElement("button");
13906 cancelBtn.type = "button";
13907 cancelBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--danger";
13908 cancelBtn.textContent = __("Cancel");
13909 const handleCreate = async () => {
13910 const name = nameInput.value.trim();
13911 if (!name) {
13912 nameInput.focus();
13913 return;
13914 }
13915 createBtn.disabled = true;
13916 try {
13917 const created = await client.createCategory(name, d.parent, {
13918 slug: slugInput.value.trim() || void 0,
13919 description: descInput.value || void 0
13920 });
13921 const next = {
13922 id: created.id,
13923 name: created.name,
13924 slug: created.slug || "",
13925 parent: created.parent,
13926 count: 0,
13927 description: created.description || "",
13928 isDefault: false
13929 };
13930 if (!terms.some((t) => t.id === next.id)) {
13931 terms = terms.concat(next);
13932 }
13933 draft = null;
13934 buildTree();
13935 focusId = created.id;
13936 paintSidebar();
13937 await loadPostsForFocus();
13938 } catch (err) {
13939 createBtn.disabled = false;
13940 showError(__("Couldn’t create:"), err);
13941 }
13942 };
13943 createBtn.addEventListener("click", () => {
13944 void handleCreate();
13945 });
13946 cancelBtn.addEventListener("click", () => {
13947 draft = null;
13948 paintSidebar();
13949 });
13950 nameInput.addEventListener("keydown", (e) => {
13951 if (e.key === "Enter") {
13952 e.preventDefault();
13953 void handleCreate();
13954 } else if (e.key === "Escape") {
13955 draft = null;
13956 paintSidebar();
13957 }
13958 });
13959 actions.appendChild(createBtn);
13960 actions.appendChild(cancelBtn);
13961 sidebar.appendChild(actions);
13962 }
13963 function paintSidebar() {
13964 sidebar.replaceChildren();
13965 if (draft !== null) {
13966 paintDraftSidebar(draft);
13967 return;
13968 }
13969 if (focusId === null) {
13970 const empty = document.createElement("div");
13971 empty.className = "wpd-mindmap__sidebar-empty";
13972 const icon = document.createElement("span");
13973 icon.className = "dashicons dashicons-admin-tools";
13974 icon.setAttribute("aria-hidden", "true");
13975 empty.appendChild(icon);
13976 const title = document.createElement("h3");
13977 title.textContent = __("No category selected");
13978 empty.appendChild(title);
13979 const help = document.createElement("p");
13980 help.textContent = __(
13981 "Click a node on the mindmap to edit its name, description, and posts."
13982 );
13983 empty.appendChild(help);
13984 sidebar.appendChild(empty);
13985 return;
13986 }
13987 const node = nodes.get(focusId);
13988 if (!node) {
13989 focusId = null;
13990 paintSidebar();
13991 return;
13992 }
13993 const id = node.id;
13994 const header = document.createElement("div");
13995 header.className = "wpd-mindmap__sidebar-header";
13996 const dot = document.createElement("span");
13997 dot.className = "wpd-mindmap__sidebar-dot";
13998 dot.style.background = `#${node.color.toString(16).padStart(6, "0")}`;
13999 const term = terms.find((t) => t.id === id);
14000 const idLabel = document.createElement("code");
14001 idLabel.className = "wpd-mindmap__sidebar-slug";
14002 idLabel.textContent = `#${id}`;
14003 header.appendChild(dot);
14004 header.appendChild(idLabel);
14005 sidebar.appendChild(header);
14006 const nameLabel = document.createElement("label");
14007 nameLabel.className = "wpd-mindmap__sidebar-label";
14008 nameLabel.textContent = __("Name");
14009 sidebar.appendChild(nameLabel);
14010 const nameInput = document.createElement("input");
14011 nameInput.type = "text";
14012 nameInput.className = "wpd-mindmap__editor-name";
14013 nameInput.value = node.name;
14014 nameInput.placeholder = __("Name");
14015 sidebar.appendChild(nameInput);
14016 const slugLabel = document.createElement("label");
14017 slugLabel.className = "wpd-mindmap__sidebar-label";
14018 slugLabel.textContent = __("Slug");
14019 sidebar.appendChild(slugLabel);
14020 const slugInput = document.createElement("input");
14021 slugInput.type = "text";
14022 slugInput.className = "wpd-mindmap__editor-name";
14023 slugInput.value = term?.slug || "";
14024 slugInput.placeholder = __("auto-from-name");
14025 slugInput.spellcheck = false;
14026 slugInput.autocapitalize = "off";
14027 slugInput.addEventListener("input", () => {
14028 const v = slugInput.value;
14029 const norm = v.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
14030 if (v !== norm) {
14031 const sel = slugInput.selectionStart ?? norm.length;
14032 slugInput.value = norm;
14033 slugInput.setSelectionRange(sel, sel);
14034 }
14035 });
14036 sidebar.appendChild(slugInput);
14037 const descLabel = document.createElement("label");
14038 descLabel.className = "wpd-mindmap__sidebar-label";
14039 descLabel.textContent = __("Description");
14040 sidebar.appendChild(descLabel);
14041 const descInput = document.createElement("textarea");
14042 descInput.className = "wpd-mindmap__editor-desc";
14043 descInput.value = node.description || "";
14044 descInput.placeholder = __("Description (optional)");
14045 descInput.rows = 4;
14046 sidebar.appendChild(descInput);
14047 const meta = document.createElement("p");
14048 meta.className = "wpd-mindmap__sidebar-meta";
14049 meta.textContent = sprintf(
14050 /* translators: %d: post count. */
14051 _n(
14052 "%d post in this category.",
14053 "%d posts in this category.",
14054 node.count
14055 ),
14056 node.count
14057 );
14058 sidebar.appendChild(meta);
14059 const actions = document.createElement("div");
14060 actions.className = "wpd-mindmap__editor-actions";
14061 const addChildBtn = document.createElement("button");
14062 addChildBtn.type = "button";
14063 addChildBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--secondary";
14064 addChildBtn.textContent = __("+ Child");
14065 addChildBtn.addEventListener("click", () => {
14066 startDraft(id);
14067 });
14068 const makeRootBtn = node.parent && node.parent !== 0 ? document.createElement("button") : null;
14069 if (makeRootBtn) {
14070 makeRootBtn.type = "button";
14071 makeRootBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--secondary";
14072 makeRootBtn.textContent = __("Make root");
14073 makeRootBtn.title = __(
14074 "Promote this category to a top-level root (no parent)."
14075 );
14076 makeRootBtn.addEventListener("click", async () => {
14077 try {
14078 await client.updateTerm("categories", node.id, { parent: 0 });
14079 node.parent = 0;
14080 terms = terms.map(
14081 (t) => t.id === node.id ? { ...t, parent: 0 } : t
14082 );
14083 buildTree();
14084 paintSidebar();
14085 } catch (err) {
14086 showError(__("Couldn’t reparent:"), err);
14087 }
14088 });
14089 }
14090 const saveBtn = document.createElement("button");
14091 saveBtn.type = "button";
14092 saveBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--primary";
14093 saveBtn.textContent = __("Save");
14094 saveBtn.addEventListener("click", async () => {
14095 const name = nameInput.value.trim();
14096 if (!name) {
14097 return;
14098 }
14099 const description = descInput.value;
14100 const slugRaw = slugInput.value.trim();
14101 const currentSlug = term?.slug ?? "";
14102 if (name === node.name && description === (node.description || "") && slugRaw === currentSlug) {
14103 return;
14104 }
14105 const patch = { name, description };
14106 if (slugRaw !== currentSlug) {
14107 patch.slug = slugRaw;
14108 }
14109 try {
14110 const updated = await client.updateTerm(
14111 "categories",
14112 node.id,
14113 patch
14114 );
14115 node.name = updated.name;
14116 node.description = updated.description;
14117 terms = terms.map(
14118 (t) => t.id === node.id ? {
14119 ...t,
14120 name: updated.name,
14121 description: updated.description,
14122 slug: updated.slug ?? t.slug
14123 } : t
14124 );
14125 layoutChip(ensureChip(node), node);
14126 paintSidebar();
14127 } catch (err) {
14128 showError(__("Couldn’t save:"), err);
14129 }
14130 });
14131 const delBtn = document.createElement("button");
14132 delBtn.type = "button";
14133 delBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--danger";
14134 delBtn.textContent = __("Delete");
14135 let armResetTimer = null;
14136 const armDelete = () => {
14137 delBtn.textContent = __("Click again to delete");
14138 delBtn.classList.add("is-armed");
14139 if (armResetTimer !== null) {
14140 window.clearTimeout(armResetTimer);
14141 }
14142 armResetTimer = window.setTimeout(() => {
14143 delBtn.textContent = __("Delete");
14144 delBtn.classList.remove("is-armed");
14145 armResetTimer = null;
14146 }, 2500);
14147 };
14148 delBtn.addEventListener("click", async () => {
14149 if (!delBtn.classList.contains("is-armed")) {
14150 armDelete();
14151 return;
14152 }
14153 if (armResetTimer !== null) {
14154 window.clearTimeout(armResetTimer);
14155 armResetTimer = null;
14156 }
14157 try {
14158 await client.deleteTerm("categories", node.id);
14159 terms = terms.filter((t) => t.id !== node.id);
14160 focusId = null;
14161 clearPosts();
14162 buildTree();
14163 paintSidebar();
14164 } catch (err) {
14165 showError(__("Couldn’t delete:"), err);
14166 }
14167 });
14168 actions.appendChild(addChildBtn);
14169 if (makeRootBtn) {
14170 actions.appendChild(makeRootBtn);
14171 }
14172 actions.appendChild(saveBtn);
14173 actions.appendChild(delBtn);
14174 sidebar.appendChild(actions);
14175 }
14176 function startDraft(parent) {
14177 if (parent !== 0 && !nodes.get(parent)) {
14178 return;
14179 }
14180 draft = { parent };
14181 paintSidebar();
14182 }
14183 addRootBtn.addEventListener("click", () => {
14184 startDraft(0);
14185 });
14186 function fitToView(opts = {}) {
14187 const padding = opts.padding ?? 90;
14188 const animate = opts.animate ?? false;
14189 const r = stage.getBoundingClientRect();
14190 if (nodes.size === 0 || r.width === 0 || r.height === 0) {
14191 const cx2 = r.width / 2;
14192 const cy2 = r.height / 2;
14193 targetScale = 1;
14194 targetWorldX = cx2;
14195 targetWorldY = cy2;
14196 if (!animate) {
14197 world.x = cx2;
14198 world.y = cy2;
14199 world.scale.set(1);
14200 }
14201 return;
14202 }
14203 let minX = Infinity;
14204 let minY = Infinity;
14205 let maxX = -Infinity;
14206 let maxY = -Infinity;
14207 const LABEL_OVERHANG = 30;
14208 for (const n of nodes.values()) {
14209 const rad = n.radius;
14210 minX = Math.min(minX, n.tx - rad);
14211 minY = Math.min(minY, n.ty - rad);
14212 maxX = Math.max(maxX, n.tx + rad);
14213 maxY = Math.max(maxY, n.ty + rad + LABEL_OVERHANG);
14214 }
14215 const w = Math.max(1, maxX - minX);
14216 const h = Math.max(1, maxY - minY);
14217 const sx = (r.width - padding * 2) / w;
14218 const sy = (r.height - padding * 2) / h;
14219 const scale = Math.max(0.2, Math.min(1.5, Math.min(sx, sy)));
14220 const cx = (minX + maxX) / 2;
14221 const cy = (minY + maxY) / 2;
14222 const newWorldX = r.width / 2 - cx * scale;
14223 const newWorldY = r.height / 2 - cy * scale;
14224 targetScale = scale;
14225 targetWorldX = newWorldX;
14226 targetWorldY = newWorldY;
14227 if (!animate) {
14228 world.scale.set(scale);
14229 world.x = newWorldX;
14230 world.y = newWorldY;
14231 }
14232 }
14233 function recenterCamera() {
14234 if (focusId !== null) {
14235 const focused = nodes.get(focusId);
14236 const r = stage.getBoundingClientRect();
14237 if (focused && r.width > 0 && r.height > 0) {
14238 const half = POST_RING_RADIUS$1 + 70;
14239 const sx = r.width * 0.85 / (2 * half);
14240 const sy = r.height * 0.85 / (2 * half);
14241 const newScale = Math.max(
14242 0.5,
14243 Math.min(1.6, Math.min(sx, sy))
14244 );
14245 targetScale = newScale;
14246 targetWorldX = r.width / 2 - focused.x * newScale;
14247 targetWorldY = r.height / 2 - focused.y * newScale;
14248 return;
14249 }
14250 }
14251 fitToView({ animate: true });
14252 }
14253 recenterBtn.addEventListener("click", () => recenterCamera());
14254 app.canvas.addEventListener("click", (e) => {
14255 const now = performance.now();
14256 if (now - lastFocusChange < 250 || now - pixiInteractionAt < 250) {
14257 return;
14258 }
14259 if (panMovedDist > 4) {
14260 return;
14261 }
14262 const target = e.target;
14263 if (target === app.canvas && !dragNode && focusId !== null) {
14264 closeFocus();
14265 }
14266 });
14267 async function refreshCountsViaBulk() {
14268 if (terms.length === 0) {
14269 return;
14270 }
14271 const cfg = client.getConfig();
14272 const url = new URL(
14273 joinRestUrl(cfg.restRoot, "desktop-mode/v1/term-counts")
14274 );
14275 url.searchParams.set("taxonomy", "category");
14276 url.searchParams.set(
14277 "ids",
14278 terms.map((t) => t.id).join(",")
14279 );
14280 try {
14281 const response = await fetchShellJson$1(client, url.toString());
14282 const map = response.json;
14283 let dirty = false;
14284 terms = terms.map((t) => {
14285 const fresh = map[String(t.id)];
14286 if (typeof fresh === "number" && fresh !== t.count) {
14287 dirty = true;
14288 const node = nodes.get(t.id);
14289 if (node) {
14290 node.count = fresh;
14291 layoutChip(ensureChip(node), node);
14292 }
14293 return { ...t, count: fresh };
14294 }
14295 return t;
14296 });
14297 if (dirty) {
14298 buildTree();
14299 fitToView({ animate: true });
14300 }
14301 } catch {
14302 }
14303 }
14304 buildTree();
14305 paintSidebar();
14306 preSettlePhysics(80);
14307 raf = requestAnimationFrame(tick);
14308 void refreshCountsViaBulk();
14309 let currentMatches = [];
14310 let selectedIndex = 0;
14311 const repaintHighlight = () => {
14312 const items = searchResults.querySelectorAll(
14313 ".wpd-mindmap__search-result"
14314 );
14315 items.forEach((el, i) => {
14316 const active = i === selectedIndex;
14317 el.classList.toggle("is-active", active);
14318 if (active) {
14319 el.scrollIntoView({ block: "nearest" });
14320 }
14321 });
14322 };
14323 const selectMatch = (n) => {
14324 searchInput.value = "";
14325 searchResults.hidden = true;
14326 searchResults.replaceChildren();
14327 currentMatches = [];
14328 selectedIndex = 0;
14329 void focusNode(n.id);
14330 };
14331 const renderSearchResults = () => {
14332 const q = searchInput.value.trim().toLowerCase();
14333 if (q.length === 0) {
14334 searchResults.hidden = true;
14335 searchResults.replaceChildren();
14336 currentMatches = [];
14337 selectedIndex = 0;
14338 return;
14339 }
14340 currentMatches = Array.from(nodes.values()).filter((n) => n.name.toLowerCase().includes(q)).sort((a, b) => b.count - a.count).slice(0, 10);
14341 selectedIndex = 0;
14342 searchResults.replaceChildren();
14343 currentMatches.forEach((n, i) => {
14344 const li = document.createElement("li");
14345 const btn = document.createElement("button");
14346 btn.type = "button";
14347 btn.className = "wpd-mindmap__search-result";
14348 if (i === 0) {
14349 btn.classList.add("is-active");
14350 }
14351 const nameEl = document.createElement("span");
14352 nameEl.className = "wpd-mindmap__search-title";
14353 nameEl.textContent = n.name || `#${n.id}`;
14354 const countEl = document.createElement("span");
14355 countEl.className = "wpd-mindmap__search-meta";
14356 countEl.textContent = sprintf(
14357 /* translators: %d: number of posts assigned to a category. */
14358 _n("%d post", "%d posts", n.count),
14359 n.count
14360 );
14361 btn.appendChild(nameEl);
14362 btn.appendChild(countEl);
14363 btn.addEventListener("mousedown", (ev) => {
14364 ev.preventDefault();
14365 selectMatch(n);
14366 });
14367 btn.addEventListener("mouseenter", () => {
14368 selectedIndex = i;
14369 repaintHighlight();
14370 });
14371 li.appendChild(btn);
14372 searchResults.appendChild(li);
14373 });
14374 searchResults.hidden = currentMatches.length === 0;
14375 };
14376 searchInput.addEventListener("input", renderSearchResults);
14377 searchInput.addEventListener("focus", renderSearchResults);
14378 searchInput.addEventListener("keydown", (ev) => {
14379 if (ev.key === "ArrowDown") {
14380 if (currentMatches.length === 0) {
14381 return;
14382 }
14383 ev.preventDefault();
14384 selectedIndex = Math.min(
14385 selectedIndex + 1,
14386 currentMatches.length - 1
14387 );
14388 repaintHighlight();
14389 } else if (ev.key === "ArrowUp") {
14390 if (currentMatches.length === 0) {
14391 return;
14392 }
14393 ev.preventDefault();
14394 selectedIndex = Math.max(selectedIndex - 1, 0);
14395 repaintHighlight();
14396 } else if (ev.key === "Enter") {
14397 if (currentMatches.length === 0) {
14398 return;
14399 }
14400 ev.preventDefault();
14401 selectMatch(currentMatches[selectedIndex]);
14402 } else if (ev.key === "Escape") {
14403 searchInput.value = "";
14404 searchResults.hidden = true;
14405 searchResults.replaceChildren();
14406 currentMatches = [];
14407 selectedIndex = 0;
14408 }
14409 });
14410 searchInput.addEventListener("blur", () => {
14411 setTimeout(() => {
14412 searchResults.hidden = true;
14413 }, 120);
14414 });
14415 const onDocClickSearch = (ev) => {
14416 if (!searchWrap.contains(ev.target)) {
14417 searchResults.hidden = true;
14418 }
14419 };
14420 document.addEventListener("click", onDocClickSearch);
14421 return () => {
14422 if (raf !== null) {
14423 cancelAnimationFrame(raf);
14424 raf = null;
14425 }
14426 if (settleTimer !== null) {
14427 window.clearTimeout(settleTimer);
14428 settleTimer = null;
14429 }
14430 ro.disconnect();
14431 stage.removeEventListener("wheel", onWheel);
14432 document.removeEventListener("click", onDocClickSearch);
14433 try {
14434 app.ticker?.stop();
14435 } catch {
14436 }
14437 try {
14438 app.destroy({ removeView: true }, { children: true });
14439 } catch {
14440 }
14441 host.replaceChildren();
14442 host.classList.remove("wpd-mindmap");
14443 };
14444 }
14445 function nodeRadius(count, all) {
14446 const max = Math.max(1, ...all.map((t) => t.count));
14447 const ratio = Math.sqrt(count / max);
14448 return MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * ratio;
14449 }
14450 function readAdminThemeHue$1() {
14451 try {
14452 const value = getComputedStyle(document.documentElement).getPropertyValue("--wp-admin-theme-color").trim();
14453 if (!value) {
14454 return 210;
14455 }
14456 const c = document.createElement("span");
14457 c.style.color = value;
14458 document.body.appendChild(c);
14459 const rgb = getComputedStyle(c).color;
14460 c.remove();
14461 const m = rgb.match(/\d+/g);
14462 if (!m || m.length < 3) {
14463 return 210;
14464 }
14465 return rgbToHue$1(
14466 parseInt(m[0], 10),
14467 parseInt(m[1], 10),
14468 parseInt(m[2], 10)
14469 );
14470 } catch {
14471 return 210;
14472 }
14473 }
14474 function rgbToHue$1(r, g, b) {
14475 const rn = r / 255;
14476 const gn = g / 255;
14477 const bn = b / 255;
14478 const max = Math.max(rn, gn, bn);
14479 const min = Math.min(rn, gn, bn);
14480 const d = max - min;
14481 if (d === 0) {
14482 return 210;
14483 }
14484 let h;
14485 switch (max) {
14486 case rn:
14487 h = (gn - bn) / d + (gn < bn ? 6 : 0);
14488 break;
14489 case gn:
14490 h = (bn - rn) / d + 2;
14491 break;
14492 default:
14493 h = (rn - gn) / d + 4;
14494 break;
14495 }
14496 return Math.round(h * 60);
14497 }
14498 function hslToInt$1(h, s, l) {
14499 const sn = s / 100;
14500 const ln = l / 100;
14501 const c = (1 - Math.abs(2 * ln - 1)) * sn;
14502 const hp = h / 60;
14503 const x = c * (1 - Math.abs(hp % 2 - 1));
14504 let r = 0;
14505 let g = 0;
14506 let b = 0;
14507 if (hp < 1) {
14508 r = c;
14509 g = x;
14510 } else if (hp < 2) {
14511 r = x;
14512 g = c;
14513 } else if (hp < 3) {
14514 g = c;
14515 b = x;
14516 } else if (hp < 4) {
14517 g = x;
14518 b = c;
14519 } else if (hp < 5) {
14520 r = x;
14521 b = c;
14522 } else {
14523 r = c;
14524 b = x;
14525 }
14526 const m = ln - c / 2;
14527 const ri = Math.round((r + m) * 255);
14528 const gi = Math.round((g + m) * 255);
14529 const bi = Math.round((b + m) * 255);
14530 return ri * 65536 + gi * 256 + bi;
14531 }
14532 function shadeColor(color, delta) {
14533 const r = Math.floor(color / 65536) % 256;
14534 const g = Math.floor(color / 256) % 256;
14535 const b = color % 256;
14536 const adj = (ch) => {
14537 return Math.round(ch * (1 + delta));
14538 };
14539 return adj(r) * 65536 + adj(g) * 256 + adj(b);
14540 }
14541 function stripTags$1(html2) {
14542 const tmp = document.createElement("div");
14543 tmp.innerHTML = html2;
14544 return tmp.textContent || tmp.innerText || "";
14545 }
14546 function showToast$1(title, err) {
14547 const reason = err instanceof Error ? err.message : String(err);
14548 const api = window.wp?.desktop;
14549 if (api && typeof api.showToast === "function") {
14550 api.showToast({
14551 message: `${title} ${reason}`.trim(),
14552 duration: 6e3
14553 });
14554 return;
14555 }
14556 console.error(title, err);
14557 }
14558 async function fetchShellJson$1(client, url) {
14559 const cfg = client.getConfig();
14560 const init = {
14561 method: "GET",
14562 credentials: "same-origin",
14563 headers: {
14564 "X-WP-Nonce": cfg.restNonce,
14565 Accept: "application/json"
14566 }
14567 };
14568 const response = await trackedFetch(url, init, {
14569 windowId: "desktop-mode-posts"
14570 });
14571 if (!response.ok) {
14572 throw new Error(`${response.status} ${response.statusText}`);
14573 }
14574 const json = await response.json();
14575 return { json, headers: response.headers };
14576 }
14577 const categoriesMindmap = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
14578 __proto__: null,
14579 mountCategoriesMindmap
14580 }, Symbol.toStringTag, { value: "Module" }));
14581 const POST_PER_PAGE = 10;
14582 const POST_RING_RADIUS = 170;
14583 const MIN_FONT_SIZE = 11;
14584 const MAX_FONT_SIZE = 28;
14585 const FONT_FAMILY = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
14586 const CHIP_TEXT_RES = 3;
14587 const CHIP_NAME_MAX_CHARS = 22;
14588 const POST_TITLE_MAX_CHARS = 22;
14589 const CHIP_PAD_X = 11;
14590 const CHIP_PAD_Y = 6;
14591 const CHIP_GAP_HASH = 4;
14592 const CHIP_GAP_COUNT = 8;
14593 const SPIRAL_PADDING = 14;
14594 const SPOTLIGHT_RADIUS = POST_RING_RADIUS + 130;
14595 async function mountTagsCloud(host, client) {
14596 const api = window.wp?.desktop;
14597 if (!api || typeof api.loadModules !== "function") {
14598 host.textContent = __("Tag cloud unavailable: shell modules API missing.");
14599 return () => {
14600 };
14601 }
14602 try {
14603 await api.loadModules(["pixijs"]);
14604 } catch {
14605 host.textContent = __("Tag cloud unavailable.");
14606 return () => {
14607 };
14608 }
14609 const pixiMaybe = window.PIXI;
14610 if (!pixiMaybe) {
14611 host.textContent = __("Tag cloud unavailable.");
14612 return () => {
14613 };
14614 }
14615 const pixi = pixiMaybe;
14616 host.replaceChildren();
14617 host.classList.add("wpd-tagcloud");
14618 const toolbar = document.createElement("div");
14619 toolbar.className = "wpd-tagcloud__toolbar";
14620 const addTagBtn = document.createElement("button");
14621 addTagBtn.type = "button";
14622 addTagBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--primary";
14623 addTagBtn.innerHTML = '<span class="dashicons dashicons-plus" aria-hidden="true"></span>' + __("Add tag");
14624 const recenterBtn = document.createElement("button");
14625 recenterBtn.type = "button";
14626 recenterBtn.className = "wpd-tagcloud__btn";
14627 recenterBtn.innerHTML = '<span class="dashicons dashicons-image-rotate" aria-hidden="true"></span>' + __("Recenter");
14628 const reflowBtn = document.createElement("button");
14629 reflowBtn.type = "button";
14630 reflowBtn.className = "wpd-tagcloud__btn";
14631 reflowBtn.innerHTML = '<span class="dashicons dashicons-grid-view" aria-hidden="true"></span>' + __("Reflow");
14632 reflowBtn.title = __(
14633 "Recompute the chip layout from scratch — discards manual repositioning."
14634 );
14635 const searchWrap = document.createElement("div");
14636 searchWrap.className = "wpd-tagcloud__search";
14637 const searchInput = document.createElement("input");
14638 searchInput.type = "search";
14639 searchInput.className = "wpd-tagcloud__search-input";
14640 searchInput.placeholder = __("Search tags…");
14641 searchInput.setAttribute(
14642 "aria-label",
14643 __("Search tags in the cloud")
14644 );
14645 searchWrap.appendChild(searchInput);
14646 const searchResults = document.createElement("ul");
14647 searchResults.className = "wpd-tagcloud__search-results";
14648 searchResults.hidden = true;
14649 searchWrap.appendChild(searchResults);
14650 const hint = document.createElement("span");
14651 hint.className = "wpd-tagcloud__hint";
14652 hint.textContent = __(
14653 "Click a tag to focus + edit · drag to reposition · wheel to zoom"
14654 );
14655 toolbar.appendChild(addTagBtn);
14656 toolbar.appendChild(recenterBtn);
14657 toolbar.appendChild(reflowBtn);
14658 toolbar.appendChild(searchWrap);
14659 toolbar.appendChild(hint);
14660 host.appendChild(toolbar);
14661 const layout = document.createElement("div");
14662 layout.className = "wpd-tagcloud__layout";
14663 host.appendChild(layout);
14664 const stage = document.createElement("div");
14665 stage.className = "wpd-tagcloud__stage";
14666 stage.classList.add("is-loading");
14667 layout.appendChild(stage);
14668 const sidebar = document.createElement("aside");
14669 sidebar.className = "wpd-tagcloud__sidebar";
14670 layout.appendChild(sidebar);
14671 const app = new pixi.Application();
14672 await app.init({
14673 resizeTo: stage,
14674 backgroundAlpha: 0,
14675 antialias: true,
14676 autoDensity: true,
14677 resolution: Math.min(window.devicePixelRatio || 1, 2)
14678 });
14679 stage.appendChild(app.canvas);
14680 app.canvas.classList.add("wpd-tagcloud__canvas");
14681 const world = new pixi.Container();
14682 world.x = stage.clientWidth / 2;
14683 world.y = stage.clientHeight / 2;
14684 app.stage.addChild(world);
14685 const chipLayer = new pixi.Container();
14686 const postEdgeLayer = new pixi.Container();
14687 const postLayer = new pixi.Container();
14688 const postChipLayer = new pixi.Container();
14689 world.addChild(postEdgeLayer);
14690 world.addChild(chipLayer);
14691 world.addChild(postLayer);
14692 world.addChild(postChipLayer);
14693 const postEdgeGfx = new pixi.Graphics();
14694 postEdgeLayer.addChild(postEdgeGfx);
14695 const pager = new pixi.Container();
14696 pager.eventMode = "passive";
14697 pager.visible = false;
14698 postLayer.addChild(pager);
14699 const pagerPrev = new pixi.Graphics();
14700 const pagerNext = new pixi.Graphics();
14701 const pagerLabel = new pixi.Text({
14702 text: "1 / 1",
14703 style: {
14704 fill: 5265246,
14705 fontSize: 12,
14706 fontFamily: FONT_FAMILY,
14707 fontWeight: "600"
14708 }
14709 });
14710 pagerLabel.anchor.set(0.5);
14711 pagerPrev.eventMode = "static";
14712 pagerPrev.cursor = "pointer";
14713 pagerNext.eventMode = "static";
14714 pagerNext.cursor = "pointer";
14715 pagerPrev.hitArea = new pixi.Circle(0, 0, 16);
14716 pagerNext.hitArea = new pixi.Circle(0, 0, 16);
14717 pager.addChild(pagerPrev);
14718 pager.addChild(pagerLabel);
14719 pager.addChild(pagerNext);
14720 const stopBubble = (e) => {
14721 e.stopPropagation?.();
14722 pixiInteractionAt = performance.now();
14723 };
14724 pagerPrev.on("pointerdown", stopBubble);
14725 pagerNext.on("pointerdown", stopBubble);
14726 pagerPrev.on("pointertap", (e) => {
14727 stopBubble(e);
14728 lastFocusChange = performance.now();
14729 if (focusPage <= 1) {
14730 return;
14731 }
14732 focusPage--;
14733 void loadPostsForFocus();
14734 });
14735 pagerNext.on("pointertap", (e) => {
14736 stopBubble(e);
14737 lastFocusChange = performance.now();
14738 if (focusPage >= focusTotalPages) {
14739 return;
14740 }
14741 focusPage++;
14742 void loadPostsForFocus();
14743 });
14744 const tags = /* @__PURE__ */ new Map();
14745 const postChips = /* @__PURE__ */ new Map();
14746 const postNodes = /* @__PURE__ */ new Map();
14747 let focusId = null;
14748 let focusPage = 1;
14749 let focusTotalPages = 1;
14750 let loadSeq = 0;
14751 let pixiInteractionAt = 0;
14752 let dragChip = null;
14753 let dragOffset = { x: 0, y: 0 };
14754 let dragStart = null;
14755 let panActive = false;
14756 let panStart = null;
14757 let panMovedDist = 0;
14758 let raf = null;
14759 let lastTick = performance.now();
14760 let targetScale = world.scale.x;
14761 let targetWorldX = world.x;
14762 let targetWorldY = world.y;
14763 let nudgeAwayFrom = null;
14764 let prevView = null;
14765 let lastFocusChange = 0;
14766 let draft = null;
14767 let terms = [];
14768 const positionsKey = computePositionsKey();
14769 const persistedPositions = readPersistedPositions(positionsKey);
14770 let cooccurrenceMap = /* @__PURE__ */ new Map();
14771 const themeHue = readAdminThemeHue();
14772 try {
14773 const all = [];
14774 let page = 1;
14775 while (page <= 5) {
14776 const res = await client.fetchTerms("tags", { page, perPage: 100 });
14777 all.push(...res.items);
14778 if (page >= res.totalPages) {
14779 break;
14780 }
14781 page++;
14782 }
14783 terms = all;
14784 } catch (err) {
14785 showToast(__("Couldn’t load tags:"), err);
14786 }
14787 const showError = (title, err) => showToast(title, err);
14788 function buildCloud() {
14789 const liveIds = new Set(terms.map((t) => t.id));
14790 for (const [id, box] of tags) {
14791 if (!liveIds.has(id)) {
14792 chipLayer.removeChild(box.chip.container);
14793 box.chip.container.destroy({ children: true });
14794 tags.delete(id);
14795 }
14796 }
14797 const maxCount = Math.max(1, ...terms.map((t) => t.count));
14798 const fresh = [];
14799 for (const term of terms) {
14800 const fontSize = fontSizeFor(term.count, maxCount);
14801 const hue = tagHue(term.slug || term.name, themeHue);
14802 const rotation = tagRotation(term.slug || term.name);
14803 const existing = tags.get(term.id);
14804 if (existing) {
14805 existing.name = term.name;
14806 existing.slug = term.slug;
14807 existing.description = term.description;
14808 existing.count = term.count;
14809 existing.fontSize = fontSize;
14810 existing.hue = hue;
14811 existing.rotation = rotation;
14812 layoutChip(existing);
14813 } else {
14814 const chip = createTagChip(pixi, chipLayer, term, fontSize, hue);
14815 const persisted = persistedPositions.get(term.id);
14816 const box = {
14817 id: term.id,
14818 name: term.name,
14819 slug: term.slug,
14820 description: term.description,
14821 count: term.count,
14822 fontSize,
14823 hue,
14824 rotation,
14825 x: persisted ? persisted.x : 0,
14826 y: persisted ? persisted.y : 0,
14827 tx: persisted ? persisted.x : 0,
14828 ty: persisted ? persisted.y : 0,
14829 width: 0,
14830 height: 0,
14831 chip
14832 };
14833 tags.set(term.id, box);
14834 layoutChip(box);
14835 wireChipPointer(box);
14836 if (!persisted) {
14837 fresh.push(box);
14838 }
14839 }
14840 }
14841 const placed = [];
14842 const placedById = /* @__PURE__ */ new Map();
14843 for (const box of tags.values()) {
14844 if (!fresh.includes(box)) {
14845 placed.push({
14846 x: box.tx - box.width / 2,
14847 y: box.ty - box.height / 2,
14848 w: box.width,
14849 h: box.height
14850 });
14851 placedById.set(box.id, { x: box.tx, y: box.ty });
14852 }
14853 }
14854 fresh.sort((a, b) => b.count - a.count);
14855 packBoxesWithClusters(fresh, placed, placedById, cooccurrenceMap);
14856 for (const box of fresh) {
14857 box.x = box.tx;
14858 box.y = box.ty;
14859 }
14860 }
14861 function wireChipPointer(box) {
14862 const c = box.chip.container;
14863 c.on("pointerdown", (e) => {
14864 const ev = e;
14865 ev.stopPropagation?.();
14866 pixiInteractionAt = performance.now();
14867 dragChip = box;
14868 dragStart = { x: ev.global.x, y: ev.global.y };
14869 const local = stageToWorld({ x: ev.global.x, y: ev.global.y });
14870 dragOffset = { x: box.x - local.x, y: box.y - local.y };
14871 });
14872 c.on("pointerover", () => {
14873 box.chip.cachedHover = true;
14874 paintChip(box);
14875 });
14876 c.on("pointerout", () => {
14877 box.chip.cachedHover = false;
14878 paintChip(box);
14879 });
14880 }
14881 function layoutChip(box) {
14882 const chip = box.chip;
14883 const displayName = truncateChipName(box.name);
14884 const countStr = String(box.count);
14885 if (chip.nameText.text !== displayName) {
14886 chip.nameText.text = displayName;
14887 }
14888 if (chip.countText.text !== countStr) {
14889 chip.countText.text = countStr;
14890 }
14891 chip.nameText.style.fontSize = box.fontSize;
14892 chip.hashText.style.fontSize = box.fontSize;
14893 chip.countText.style.fontSize = Math.max(
14894 10,
14895 Math.round(box.fontSize * 0.55)
14896 );
14897 chip.cachedName = displayName;
14898 chip.cachedCount = box.count;
14899 chip.cachedHue = box.hue;
14900 const hashW = chip.hashText.width;
14901 const nameW = chip.nameText.width;
14902 const nameH = chip.nameText.height;
14903 const countW = chip.countText.width;
14904 const countH = chip.countText.height;
14905 const countBadgeW = Math.max(18, countW + 10);
14906 const countBadgeH = Math.max(14, countH + 4);
14907 const totalW = CHIP_PAD_X + hashW + CHIP_GAP_HASH + nameW + CHIP_GAP_COUNT + countBadgeW + CHIP_PAD_X;
14908 const totalH = Math.max(nameH, countBadgeH) + CHIP_PAD_Y * 2;
14909 box.width = totalW;
14910 box.height = totalH;
14911 paintChip(box);
14912 }
14913 function paintChip(box) {
14914 const chip = box.chip;
14915 const focused = focusId === box.id;
14916 chip.cachedFocused = focused;
14917 const totalW = box.width;
14918 const totalH = box.height;
14919 const left = -totalW / 2;
14920 const top = -totalH / 2;
14921 const radius = totalH / 2;
14922 let fillBg;
14923 if (focused) {
14924 fillBg = hslToInt(box.hue, 70, 48);
14925 } else if (chip.cachedHover) {
14926 fillBg = hslToInt(box.hue, 70, 92);
14927 } else {
14928 fillBg = hslToInt(box.hue, 60, 95);
14929 }
14930 const borderColor = focused ? hslToInt(box.hue, 70, 38) : hslToInt(box.hue, 50, 70);
14931 const textColor = focused ? 16777215 : 1909543;
14932 const hashColor = focused ? 16777215 : hslToInt(box.hue, 65, 42);
14933 const countBg = focused ? hslToInt(box.hue, 80, 30) : hslToInt(box.hue, 70, 50);
14934 chip.shadow.clear();
14935 chip.shadow.roundRect(
14936 left - 1,
14937 top + 3,
14938 totalW + 2,
14939 totalH + 2,
14940 radius + 1
14941 );
14942 let shadowAlpha = 0.1;
14943 if (focused) {
14944 shadowAlpha = 0.18;
14945 } else if (chip.cachedHover) {
14946 shadowAlpha = 0.16;
14947 }
14948 chip.shadow.fill({
14949 color: 0,
14950 alpha: shadowAlpha
14951 });
14952 chip.bg.clear();
14953 chip.bg.roundRect(left, top, totalW, totalH, radius);
14954 chip.bg.fill(fillBg);
14955 chip.bg.stroke({
14956 color: borderColor,
14957 width: focused ? 2 : 1.25,
14958 alpha: focused ? 1 : 0.85
14959 });
14960 const hashW = chip.hashText.width;
14961 const nameW = chip.nameText.width;
14962 const nameH = chip.nameText.height;
14963 const countW = chip.countText.width;
14964 const countH = chip.countText.height;
14965 const countBadgeW = Math.max(18, countW + 10);
14966 const countBadgeH = Math.max(14, countH + 4);
14967 chip.hashText.x = left + CHIP_PAD_X;
14968 chip.hashText.y = (totalH - nameH) / 2 + top;
14969 chip.hashText.style.fill = hashColor;
14970 chip.nameText.x = left + CHIP_PAD_X + hashW + CHIP_GAP_HASH;
14971 chip.nameText.y = (totalH - nameH) / 2 + top;
14972 chip.nameText.style.fill = textColor;
14973 const badgeX = left + CHIP_PAD_X + hashW + CHIP_GAP_HASH + nameW + CHIP_GAP_COUNT;
14974 const badgeY = (totalH - countBadgeH) / 2 + top;
14975 chip.bg.roundRect(
14976 badgeX,
14977 badgeY,
14978 countBadgeW,
14979 countBadgeH,
14980 countBadgeH / 2
14981 );
14982 chip.bg.fill(countBg);
14983 chip.countText.x = badgeX + (countBadgeW - countW) / 2;
14984 chip.countText.y = badgeY + (countBadgeH - countH) / 2;
14985 chip.countText.style.fill = 16777215;
14986 }
14987 function findSpiralSlot(w, h, placed, anchorX = 0, anchorY = 0) {
14988 if (placed.length === 0) {
14989 return { x: anchorX, y: anchorY };
14990 }
14991 const padding = SPIRAL_PADDING;
14992 {
14993 const aabb = {
14994 x: anchorX - w / 2 - padding,
14995 y: anchorY - h / 2 - padding,
14996 w: w + padding * 2,
14997 h: h + padding * 2
14998 };
14999 let overlap = false;
15000 for (const p of placed) {
15001 if (aabbIntersect(aabb, p)) {
15002 overlap = true;
15003 break;
15004 }
15005 }
15006 if (!overlap) {
15007 return { x: anchorX, y: anchorY };
15008 }
15009 }
15010 let theta = 0;
15011 const maxIter = 1e4;
15012 for (let i = 0; i < maxIter; i++) {
15013 theta += 0.18;
15014 const r = theta * 5;
15015 const cx = anchorX + r * Math.cos(theta);
15016 const cy = anchorY + r * Math.sin(theta) * 0.7;
15017 const aabb = {
15018 x: cx - w / 2 - padding,
15019 y: cy - h / 2 - padding,
15020 w: w + padding * 2,
15021 h: h + padding * 2
15022 };
15023 let overlap = false;
15024 for (const p of placed) {
15025 if (aabbIntersect(aabb, p)) {
15026 overlap = true;
15027 break;
15028 }
15029 }
15030 if (!overlap) {
15031 return { x: cx, y: cy };
15032 }
15033 }
15034 return {
15035 x: anchorX,
15036 y: anchorY + (placed.length + 1) * (h + padding)
15037 };
15038 }
15039 function packBoxesWithClusters(boxesInOrder, placed, placedById, cooccurrence) {
15040 let clusterCounter = 0;
15041 const allocateClusterAnchor = () => {
15042 const idx = clusterCounter++;
15043 if (idx === 0) {
15044 return { x: 0, y: 0 };
15045 }
15046 const theta = idx * 2.4;
15047 const radius = 120 + idx * 70;
15048 return {
15049 x: radius * Math.cos(theta),
15050 y: radius * Math.sin(theta) * 0.8
15051 };
15052 };
15053 for (const box of boxesInOrder) {
15054 let anchorX = 0;
15055 let anchorY = 0;
15056 let usedCentroid = false;
15057 const neighbors = cooccurrence.get(box.id);
15058 if (neighbors && neighbors.length > 0) {
15059 let sumX = 0;
15060 let sumY = 0;
15061 let sumW = 0;
15062 for (const n of neighbors) {
15063 const pos = placedById.get(n.id);
15064 if (!pos) {
15065 continue;
15066 }
15067 sumX += pos.x * n.shared;
15068 sumY += pos.y * n.shared;
15069 sumW += n.shared;
15070 }
15071 if (sumW > 0) {
15072 anchorX = sumX / sumW;
15073 anchorY = sumY / sumW;
15074 usedCentroid = true;
15075 }
15076 }
15077 if (!usedCentroid) {
15078 const anchor = allocateClusterAnchor();
15079 anchorX = anchor.x;
15080 anchorY = anchor.y;
15081 }
15082 const slot = findSpiralSlot(
15083 box.width,
15084 box.height,
15085 placed,
15086 anchorX,
15087 anchorY
15088 );
15089 box.tx = slot.x;
15090 box.ty = slot.y;
15091 placedById.set(box.id, { x: slot.x, y: slot.y });
15092 placed.push({
15093 x: slot.x - box.width / 2,
15094 y: slot.y - box.height / 2,
15095 w: box.width,
15096 h: box.height
15097 });
15098 }
15099 }
15100 function syncChipPositions() {
15101 const chipCounterScale = 1 / Math.max(0.01, world.scale.x);
15102 const anyFocus = focusId !== null;
15103 for (const box of tags.values()) {
15104 const c = box.chip.container;
15105 c.x = box.x;
15106 c.y = box.y;
15107 const counter = Math.max(1, chipCounterScale);
15108 c.scale.set(counter);
15109 c.rotation = box.rotation;
15110 const focused = focusId === box.id;
15111 const targetAlpha = !anyFocus || focused ? 1 : 0.32;
15112 if (Math.abs(c.alpha - targetAlpha) > 5e-3) {
15113 c.alpha += (targetAlpha - c.alpha) * 0.18;
15114 } else {
15115 c.alpha = targetAlpha;
15116 }
15117 }
15118 for (const post of postNodes.values()) {
15119 const chip = postChips.get(post.id);
15120 if (!chip) {
15121 continue;
15122 }
15123 chip.container.x = post.x;
15124 chip.container.y = post.y;
15125 chip.container.scale.set(chipCounterScale);
15126 if (chip.container.alpha < 1) {
15127 chip.container.alpha = Math.min(
15128 1,
15129 chip.container.alpha + 0.18
15130 );
15131 }
15132 }
15133 }
15134 function tick() {
15135 const now = performance.now();
15136 const dt = Math.min(50, now - lastTick);
15137 lastTick = now;
15138 const ZOOM_EASE = 0.22;
15139 const ds = targetScale - world.scale.x;
15140 const dwx = targetWorldX - world.x;
15141 const dwy = targetWorldY - world.y;
15142 if (Math.abs(ds) > 5e-4 || Math.abs(dwx) > 0.5 || Math.abs(dwy) > 0.5) {
15143 world.scale.set(world.scale.x + ds * ZOOM_EASE);
15144 world.x += dwx * ZOOM_EASE;
15145 world.y += dwy * ZOOM_EASE;
15146 }
15147 for (const box of tags.values()) {
15148 if (box === dragChip) {
15149 continue;
15150 }
15151 let tx = box.tx;
15152 let ty = box.ty;
15153 if (nudgeAwayFrom && box.id !== focusId) {
15154 const dx = box.tx - nudgeAwayFrom.x;
15155 const dy = box.ty - nudgeAwayFrom.y;
15156 const d = Math.sqrt(dx * dx + dy * dy) || 1;
15157 const limit = nudgeAwayFrom.radius + Math.max(box.width, box.height) / 2;
15158 if (d < limit) {
15159 const push = limit + 12;
15160 tx = nudgeAwayFrom.x + dx / d * push;
15161 ty = nudgeAwayFrom.y + dy / d * push;
15162 }
15163 }
15164 const ease = 1 - Math.exp(-dt * 0.012);
15165 box.x += (tx - box.x) * ease;
15166 box.y += (ty - box.y) * ease;
15167 }
15168 for (const p of postNodes.values()) {
15169 p.x += (p.tx - p.x) * 0.18;
15170 p.y += (p.ty - p.y) * 0.18;
15171 p.gfx.x = p.x;
15172 p.gfx.y = p.y;
15173 }
15174 drawPostEdges();
15175 syncChipPositions();
15176 raf = requestAnimationFrame(tick);
15177 }
15178 function drawPostEdges() {
15179 postEdgeGfx.clear();
15180 if (focusId === null) {
15181 return;
15182 }
15183 const center = tags.get(focusId);
15184 if (!center) {
15185 return;
15186 }
15187 for (const post of postNodes.values()) {
15188 postEdgeGfx.moveTo(center.x, center.y);
15189 postEdgeGfx.lineTo(post.x, post.y);
15190 postEdgeGfx.stroke({
15191 color: hslToInt(center.hue, 60, 50),
15192 width: 1,
15193 alpha: 0.35
15194 });
15195 }
15196 }
15197 function stageToWorld(global) {
15198 return {
15199 x: (global.x - world.x) / world.scale.x,
15200 y: (global.y - world.y) / world.scale.y
15201 };
15202 }
15203 function onStagePointerDown(e) {
15204 const ev = e;
15205 panActive = true;
15206 panStart = { x: ev.global.x, y: ev.global.y };
15207 panMovedDist = 0;
15208 }
15209 function onStagePointerMove(e) {
15210 const ev = e;
15211 if (dragChip) {
15212 const cursorWorld = stageToWorld(ev.global);
15213 const nx = cursorWorld.x + dragOffset.x;
15214 const ny = cursorWorld.y + dragOffset.y;
15215 dragChip.x = nx;
15216 dragChip.y = ny;
15217 dragChip.tx = nx;
15218 dragChip.ty = ny;
15219 return;
15220 }
15221 if (panActive && panStart) {
15222 const dx = ev.global.x - panStart.x;
15223 const dy = ev.global.y - panStart.y;
15224 world.x += dx;
15225 world.y += dy;
15226 targetWorldX += dx;
15227 targetWorldY += dy;
15228 panMovedDist += Math.sqrt(dx * dx + dy * dy);
15229 panStart = { x: ev.global.x, y: ev.global.y };
15230 }
15231 }
15232 function onStagePointerUp(e) {
15233 if (dragChip) {
15234 const box = dragChip;
15235 const startPos = dragStart;
15236 dragChip = null;
15237 dragStart = null;
15238 let movement = Infinity;
15239 const ev = e;
15240 if (startPos && ev && ev.global) {
15241 const dx = ev.global.x - startPos.x;
15242 const dy = ev.global.y - startPos.y;
15243 movement = Math.sqrt(dx * dx + dy * dy);
15244 }
15245 if (movement < 3) {
15246 void focusTag(box.id);
15247 } else {
15248 persistedPositions.set(box.id, { x: box.tx, y: box.ty });
15249 writePersistedPositions(positionsKey, persistedPositions);
15250 }
15251 }
15252 panActive = false;
15253 panStart = null;
15254 }
15255 app.stage.eventMode = "static";
15256 app.stage.hitArea = new pixi.Rectangle(
15257 0,
15258 0,
15259 stage.clientWidth,
15260 stage.clientHeight
15261 );
15262 app.stage.on("pointerdown", onStagePointerDown);
15263 app.stage.on("pointermove", onStagePointerMove);
15264 app.stage.on("pointerup", (e) => onStagePointerUp(e));
15265 app.stage.on("pointerupoutside", (e) => onStagePointerUp(e));
15266 function onWheel(e) {
15267 e.preventDefault();
15268 const SENSITIVITY = 8e-4;
15269 const factor = Math.exp(-e.deltaY * SENSITIVITY);
15270 const prev = targetScale;
15271 const next = Math.max(0.3, Math.min(2.5, prev * factor));
15272 if (Math.abs(next - prev) < 5e-4) {
15273 return;
15274 }
15275 const r = stage.getBoundingClientRect();
15276 const sx = e.clientX - r.left;
15277 const sy = e.clientY - r.top;
15278 const wx = (sx - targetWorldX) / prev;
15279 const wy = (sy - targetWorldY) / prev;
15280 targetScale = next;
15281 targetWorldX = sx - wx * next;
15282 targetWorldY = sy - wy * next;
15283 }
15284 stage.addEventListener("wheel", onWheel, { passive: false });
15285 let firstFitDone = false;
15286 let settledW = 0;
15287 let settledH = 0;
15288 const SETTLE_THRESHOLD_PX = 24;
15289 const SETTLE_DEBOUNCE_MS = 80;
15290 let settleTimer = null;
15291 function onResize() {
15292 const r = stage.getBoundingClientRect();
15293 app.renderer.resize(r.width, r.height);
15294 app.stage.hitArea = new pixi.Rectangle(0, 0, r.width, r.height);
15295 if (!firstFitDone && r.width > 0 && r.height > 0) {
15296 firstFitDone = true;
15297 settledW = r.width;
15298 settledH = r.height;
15299 fitToView();
15300 stage.classList.remove("is-loading");
15301 }
15302 if (settleTimer !== null) {
15303 window.clearTimeout(settleTimer);
15304 }
15305 settleTimer = window.setTimeout(() => {
15306 settleTimer = null;
15307 const cur = stage.getBoundingClientRect();
15308 const dw = Math.abs(cur.width - settledW);
15309 const dh = Math.abs(cur.height - settledH);
15310 if (dw >= SETTLE_THRESHOLD_PX || dh >= SETTLE_THRESHOLD_PX) {
15311 settledW = cur.width;
15312 settledH = cur.height;
15313 recenterCamera();
15314 }
15315 }, SETTLE_DEBOUNCE_MS);
15316 app.render();
15317 }
15318 const ro = new ResizeObserver(onResize);
15319 ro.observe(stage);
15320 async function focusTag(id) {
15321 if (focusId === id) {
15322 closeFocus();
15323 return;
15324 }
15325 const wasFocused = focusId !== null;
15326 focusId = id;
15327 focusPage = 1;
15328 lastFocusChange = performance.now();
15329 const focused = tags.get(id);
15330 if (focused) {
15331 if (!wasFocused) {
15332 prevView = {
15333 scale: targetScale,
15334 x: targetWorldX,
15335 y: targetWorldY
15336 };
15337 }
15338 const r = stage.getBoundingClientRect();
15339 if (r.width > 0 && r.height > 0) {
15340 const half = POST_RING_RADIUS + 70;
15341 const sx = r.width * 0.85 / (2 * half);
15342 const sy = r.height * 0.85 / (2 * half);
15343 const newScale = Math.max(
15344 0.5,
15345 Math.min(1.6, Math.min(sx, sy))
15346 );
15347 targetScale = newScale;
15348 targetWorldX = r.width / 2 - focused.x * newScale;
15349 targetWorldY = r.height / 2 - focused.y * newScale;
15350 }
15351 nudgeAwayFrom = {
15352 x: focused.x,
15353 y: focused.y,
15354 radius: SPOTLIGHT_RADIUS
15355 };
15356 }
15357 for (const box of tags.values()) {
15358 paintChip(box);
15359 }
15360 paintSidebar();
15361 await loadPostsForFocus();
15362 }
15363 function closeFocus() {
15364 focusId = null;
15365 lastFocusChange = performance.now();
15366 loadSeq++;
15367 nudgeAwayFrom = null;
15368 if (prevView) {
15369 targetScale = prevView.scale;
15370 targetWorldX = prevView.x;
15371 targetWorldY = prevView.y;
15372 prevView = null;
15373 }
15374 paintSidebar();
15375 clearPosts();
15376 for (const box of tags.values()) {
15377 paintChip(box);
15378 }
15379 }
15380 function clearPosts() {
15381 for (const post of postNodes.values()) {
15382 postLayer.removeChild(post.gfx);
15383 post.gfx.destroy();
15384 }
15385 postNodes.clear();
15386 for (const chip of postChips.values()) {
15387 postChipLayer.removeChild(chip.container);
15388 chip.container.destroy({ children: true });
15389 }
15390 postChips.clear();
15391 postEdgeGfx.clear();
15392 pager.visible = false;
15393 }
15394 function ensurePostChip(post) {
15395 const existing = postChips.get(post.id);
15396 if (existing) {
15397 return existing;
15398 }
15399 const container = new pixi.Container();
15400 container.eventMode = "static";
15401 container.cursor = "pointer";
15402 container.alpha = 0;
15403 const bg = new pixi.Graphics();
15404 container.addChild(bg);
15405 const dot = new pixi.Graphics();
15406 container.addChild(dot);
15407 const titleText = new pixi.Text({
15408 text: post.title,
15409 style: {
15410 fill: 1909543,
15411 fontSize: 12,
15412 fontFamily: FONT_FAMILY,
15413 fontWeight: "500"
15414 },
15415 resolution: CHIP_TEXT_RES
15416 });
15417 container.addChild(titleText);
15418 const chip = {
15419 container,
15420 bg,
15421 dot,
15422 titleText,
15423 width: 0,
15424 height: 0,
15425 cachedTitle: "",
15426 cachedHover: false
15427 };
15428 postChips.set(post.id, chip);
15429 postChipLayer.addChild(container);
15430 container.on("pointerdown", (e) => {
15431 e.stopPropagation?.();
15432 pixiInteractionAt = performance.now();
15433 });
15434 container.on("pointertap", () => {
15435 openInPostsTab(post.id, post.editUrl, post.title);
15436 closeFocus();
15437 });
15438 container.on("pointerover", () => {
15439 chip.cachedHover = true;
15440 layoutPostChip(chip, post);
15441 });
15442 container.on("pointerout", () => {
15443 chip.cachedHover = false;
15444 layoutPostChip(chip, post);
15445 });
15446 layoutPostChip(chip, post);
15447 return chip;
15448 }
15449 function layoutPostChip(chip, post) {
15450 const displayTitle = post.title.length > POST_TITLE_MAX_CHARS ? post.title.slice(0, POST_TITLE_MAX_CHARS - 1) + "…" : post.title;
15451 if (chip.titleText.text !== displayTitle) {
15452 chip.titleText.text = displayTitle;
15453 }
15454 chip.cachedTitle = displayTitle;
15455 const padX = 9;
15456 const padY = 3;
15457 const dotR = 4;
15458 const gap = 6;
15459 const titleW = chip.titleText.width;
15460 const titleH = chip.titleText.height;
15461 const totalW = padX + dotR * 2 + gap + titleW + padX;
15462 const totalH = Math.max(titleH, dotR * 2) + padY * 2;
15463 chip.width = totalW;
15464 chip.height = totalH;
15465 const left = -totalW / 2;
15466 const top = -totalH / 2;
15467 chip.bg.clear();
15468 chip.bg.roundRect(left, top, totalW, totalH, totalH / 2);
15469 if (chip.cachedHover) {
15470 chip.bg.fill({ color: 16777215, alpha: 1 });
15471 chip.bg.stroke({
15472 color: post.tone,
15473 width: 1.5,
15474 alpha: 1
15475 });
15476 } else {
15477 chip.bg.fill({ color: 16777215, alpha: 0.95 });
15478 chip.bg.stroke({
15479 color: 0,
15480 width: 1,
15481 alpha: 0.12
15482 });
15483 }
15484 chip.dot.clear();
15485 chip.dot.circle(left + padX + dotR, 0, dotR);
15486 chip.dot.fill({ color: post.tone, alpha: 0.85 });
15487 chip.dot.stroke({ color: 16777215, width: 1 });
15488 chip.titleText.x = left + padX + dotR * 2 + gap;
15489 chip.titleText.y = -titleH / 2;
15490 }
15491 const POSTS_CACHE_TTL_MS = 6e4;
15492 const postsCache = /* @__PURE__ */ new Map();
15493 function applyPostsResult(entry, focusedTagId) {
15494 focusTotalPages = entry.totalPages;
15495 if (Number.isFinite(entry.realTotal)) {
15496 const box = tags.get(focusedTagId);
15497 if (box && box.count !== entry.realTotal) {
15498 box.count = entry.realTotal;
15499 terms = terms.map(
15500 (t) => t.id === box.id ? { ...t, count: entry.realTotal } : t
15501 );
15502 layoutChip(box);
15503 }
15504 }
15505 renderPosts(entry.items);
15506 }
15507 async function loadPostsForFocus() {
15508 if (focusId === null) {
15509 return;
15510 }
15511 const mySeq = ++loadSeq;
15512 const myFocusId = focusId;
15513 const cacheKey2 = `${focusId}:${focusPage}`;
15514 const cached = postsCache.get(cacheKey2);
15515 if (cached && performance.now() - cached.fetchedAt < POSTS_CACHE_TTL_MS) {
15516 applyPostsResult(cached, myFocusId);
15517 return;
15518 }
15519 const cfg = client.getConfig();
15520 const url = new URL(cfg.postsUrl);
15521 url.searchParams.set("tags", String(focusId));
15522 url.searchParams.set("per_page", String(POST_PER_PAGE));
15523 url.searchParams.set("page", String(focusPage));
15524 url.searchParams.set("status", "any");
15525 url.searchParams.set("_fields", "id,title,status");
15526 try {
15527 const response = await fetchShellJson(client, url.toString());
15528 if (mySeq !== loadSeq || focusId !== myFocusId) {
15529 return;
15530 }
15531 const raw = response.json ?? [];
15532 const totalPages = Math.max(
15533 1,
15534 parseInt(response.headers.get("X-WP-TotalPages") ?? "1", 10) || 1
15535 );
15536 const realTotalParsed = parseInt(response.headers.get("X-WP-Total") ?? "", 10);
15537 const realTotal = Number.isFinite(realTotalParsed) ? realTotalParsed : -1;
15538 const items = raw.map((p) => ({
15539 id: p.id,
15540 title: stripTags(p.title?.rendered || `#${p.id}`),
15541 editUrl: `${cfg.editPostUrlBase}?post=${p.id}&action=edit`
15542 }));
15543 const entry = {
15544 items,
15545 totalPages,
15546 realTotal,
15547 fetchedAt: performance.now()
15548 };
15549 postsCache.set(cacheKey2, entry);
15550 applyPostsResult(entry, myFocusId);
15551 } catch (err) {
15552 showError(__("Couldn’t load posts:"), err);
15553 }
15554 }
15555 function renderPosts(items) {
15556 clearPosts();
15557 if (focusId === null) {
15558 return;
15559 }
15560 const center = tags.get(focusId);
15561 if (!center) {
15562 return;
15563 }
15564 const count = items.length;
15565 const ringR = POST_RING_RADIUS + Math.max(0, count - 8) * 6;
15566 const tone = hslToInt(center.hue, 70, 48);
15567 items.forEach((item, idx) => {
15568 const angle = 2 * Math.PI / Math.max(1, count) * idx - Math.PI / 2;
15569 const tx = center.x + Math.cos(angle) * ringR;
15570 const ty = center.y + Math.sin(angle) * ringR;
15571 const gfx = new pixi.Graphics();
15572 postLayer.addChild(gfx);
15573 const post = {
15574 id: item.id,
15575 title: item.title,
15576 editUrl: item.editUrl,
15577 angle,
15578 r: ringR,
15579 x: center.x,
15580 y: center.y,
15581 tx,
15582 ty,
15583 gfx,
15584 tone
15585 };
15586 postNodes.set(item.id, post);
15587 ensurePostChip(post);
15588 });
15589 repaintPager();
15590 }
15591 function repaintPager() {
15592 if (focusId === null || focusTotalPages <= 1) {
15593 pager.visible = false;
15594 return;
15595 }
15596 pager.visible = true;
15597 const center = tags.get(focusId);
15598 if (!center) {
15599 pager.visible = false;
15600 return;
15601 }
15602 const prevDisabled = focusPage <= 1;
15603 const nextDisabled = focusPage >= focusTotalPages;
15604 drawPagerButton(pagerPrev, "◀", prevDisabled);
15605 drawPagerButton(pagerNext, "▶", nextDisabled);
15606 pagerPrev.cursor = prevDisabled ? "default" : "pointer";
15607 pagerNext.cursor = nextDisabled ? "default" : "pointer";
15608 pagerLabel.text = `${focusPage} / ${focusTotalPages}`;
15609 pagerPrev.x = -38;
15610 pagerPrev.y = 0;
15611 pagerNext.x = 38;
15612 pagerNext.y = 0;
15613 pagerLabel.x = 0;
15614 pagerLabel.y = 0;
15615 pager.x = center.x;
15616 pager.y = center.y + POST_RING_RADIUS + 60;
15617 }
15618 function drawPagerButton(gfx, glyph, disabled) {
15619 gfx.clear();
15620 gfx.circle(0, 0, 14);
15621 gfx.fill({
15622 color: disabled ? 15921906 : 16777215,
15623 alpha: disabled ? 0.7 : 1
15624 });
15625 gfx.stroke({
15626 color: 0,
15627 width: 1,
15628 alpha: 0.12
15629 });
15630 const children = gfx.children;
15631 const label = children?.[0] ?? null;
15632 if (!label) {
15633 const t = new pixi.Text({
15634 text: glyph,
15635 style: {
15636 fill: disabled ? 11580344 : 5265246,
15637 fontSize: 14,
15638 fontFamily: FONT_FAMILY,
15639 fontWeight: "600"
15640 }
15641 });
15642 t.anchor.set(0.5);
15643 gfx.addChild(t);
15644 } else {
15645 label.text = glyph;
15646 label.style.fill = disabled ? 11580344 : 5265246;
15647 }
15648 }
15649 function openInPostsTab(_id, editUrl, title) {
15650 const wm = api?.windowManager;
15651 const derive = api?.deriveWindowId;
15652 const postsWin = wm && typeof wm.getById === "function" ? wm.getById("desktop-mode-posts") : void 0;
15653 if (postsWin && typeof postsWin.isFullscreen === "function" && typeof postsWin.toggleFullscreen === "function" && postsWin.isFullscreen()) {
15654 postsWin.toggleFullscreen();
15655 }
15656 if (wm && typeof derive === "function") {
15657 const id = derive(editUrl);
15658 wm.open({
15659 id,
15660 baseId: id,
15661 url: editUrl,
15662 title: title ?? editUrl,
15663 icon: "dashicons-admin-post"
15664 });
15665 return;
15666 }
15667 try {
15668 window.open(editUrl, "_blank");
15669 } catch {
15670 window.location.assign(editUrl);
15671 }
15672 }
15673 function paintDraftSidebar() {
15674 const header = document.createElement("div");
15675 header.className = "wpd-tagcloud__sidebar-header";
15676 const dot = document.createElement("span");
15677 dot.className = "wpd-tagcloud__sidebar-dot";
15678 dot.style.background = `hsl( ${themeHue}deg 60% 55% )`;
15679 const label = document.createElement("code");
15680 label.className = "wpd-tagcloud__sidebar-slug";
15681 label.textContent = __("New tag");
15682 header.appendChild(dot);
15683 header.appendChild(label);
15684 sidebar.appendChild(header);
15685 const nameLabel = document.createElement("label");
15686 nameLabel.className = "wpd-tagcloud__sidebar-label";
15687 nameLabel.textContent = __("Name");
15688 sidebar.appendChild(nameLabel);
15689 const nameInput = document.createElement("input");
15690 nameInput.type = "text";
15691 nameInput.className = "wpd-tagcloud__editor-name";
15692 nameInput.placeholder = __("e.g. featured");
15693 sidebar.appendChild(nameInput);
15694 requestAnimationFrame(() => nameInput.focus());
15695 const descLabel = document.createElement("label");
15696 descLabel.className = "wpd-tagcloud__sidebar-label";
15697 descLabel.textContent = __("Description");
15698 sidebar.appendChild(descLabel);
15699 const descInput = document.createElement("textarea");
15700 descInput.className = "wpd-tagcloud__editor-desc";
15701 descInput.placeholder = __("Description (optional)");
15702 descInput.rows = 4;
15703 sidebar.appendChild(descInput);
15704 const actions = document.createElement("div");
15705 actions.className = "wpd-tagcloud__editor-actions";
15706 const createBtn = document.createElement("button");
15707 createBtn.type = "button";
15708 createBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--primary";
15709 createBtn.textContent = __("Create");
15710 const cancelBtn = document.createElement("button");
15711 cancelBtn.type = "button";
15712 cancelBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--danger";
15713 cancelBtn.textContent = __("Cancel");
15714 const handleCreate = async () => {
15715 const name = nameInput.value.trim();
15716 if (!name) {
15717 nameInput.focus();
15718 return;
15719 }
15720 createBtn.disabled = true;
15721 try {
15722 const created = await client.createTag(name);
15723 const next = {
15724 id: created.id,
15725 name: created.name,
15726 slug: created.slug || "",
15727 parent: 0,
15728 count: 0,
15729 description: created.description || "",
15730 isDefault: false
15731 };
15732 if (!terms.some((t) => t.id === next.id)) {
15733 terms = terms.concat(next);
15734 }
15735 const desc = descInput.value.trim();
15736 if (desc) {
15737 try {
15738 const updated = await client.updateTerm(
15739 "tags",
15740 created.id,
15741 { description: desc }
15742 );
15743 terms = terms.map(
15744 (t) => t.id === updated.id ? {
15745 ...t,
15746 description: updated.description ?? desc
15747 } : t
15748 );
15749 } catch {
15750 showError(
15751 __("Tag created but description failed:"),
15752 null
15753 );
15754 }
15755 }
15756 draft = null;
15757 buildCloud();
15758 focusId = created.id;
15759 paintSidebar();
15760 await loadPostsForFocus();
15761 } catch (err) {
15762 createBtn.disabled = false;
15763 showError(__("Couldn’t create:"), err);
15764 }
15765 };
15766 createBtn.addEventListener("click", () => {
15767 void handleCreate();
15768 });
15769 cancelBtn.addEventListener("click", () => {
15770 draft = null;
15771 paintSidebar();
15772 });
15773 nameInput.addEventListener("keydown", (e) => {
15774 if (e.key === "Enter") {
15775 e.preventDefault();
15776 void handleCreate();
15777 } else if (e.key === "Escape") {
15778 draft = null;
15779 paintSidebar();
15780 }
15781 });
15782 actions.appendChild(createBtn);
15783 actions.appendChild(cancelBtn);
15784 sidebar.appendChild(actions);
15785 }
15786 function paintSidebar() {
15787 sidebar.replaceChildren();
15788 if (draft !== null) {
15789 paintDraftSidebar();
15790 return;
15791 }
15792 if (focusId === null) {
15793 const empty = document.createElement("div");
15794 empty.className = "wpd-tagcloud__sidebar-empty";
15795 const icon = document.createElement("span");
15796 icon.className = "dashicons dashicons-tag";
15797 icon.setAttribute("aria-hidden", "true");
15798 empty.appendChild(icon);
15799 const title = document.createElement("h3");
15800 title.className = "wpd-tagcloud__sidebar-empty-title";
15801 title.textContent = __("No tag selected");
15802 empty.appendChild(title);
15803 const help = document.createElement("p");
15804 help.className = "wpd-tagcloud__sidebar-empty-hint";
15805 help.textContent = __(
15806 "Click a tag on the cloud to edit it, or click + Add tag to create a new one."
15807 );
15808 empty.appendChild(help);
15809 sidebar.appendChild(empty);
15810 return;
15811 }
15812 const box = tags.get(focusId);
15813 if (!box) {
15814 focusId = null;
15815 paintSidebar();
15816 return;
15817 }
15818 const id = box.id;
15819 const header = document.createElement("div");
15820 header.className = "wpd-tagcloud__sidebar-header";
15821 const dot = document.createElement("span");
15822 dot.className = "wpd-tagcloud__sidebar-dot";
15823 dot.style.background = `hsl( ${box.hue}deg 60% 55% )`;
15824 const term = terms.find((t) => t.id === id);
15825 const idLabel = document.createElement("code");
15826 idLabel.className = "wpd-tagcloud__sidebar-slug";
15827 idLabel.textContent = `#${id}`;
15828 header.appendChild(dot);
15829 header.appendChild(idLabel);
15830 sidebar.appendChild(header);
15831 const nameLabel = document.createElement("label");
15832 nameLabel.className = "wpd-tagcloud__sidebar-label";
15833 nameLabel.textContent = __("Name");
15834 sidebar.appendChild(nameLabel);
15835 const nameInput = document.createElement("input");
15836 nameInput.type = "text";
15837 nameInput.className = "wpd-tagcloud__editor-name";
15838 nameInput.value = box.name;
15839 nameInput.placeholder = __("Name");
15840 sidebar.appendChild(nameInput);
15841 const slugLabel = document.createElement("label");
15842 slugLabel.className = "wpd-tagcloud__sidebar-label";
15843 slugLabel.textContent = __("Slug");
15844 sidebar.appendChild(slugLabel);
15845 const slugInput = document.createElement("input");
15846 slugInput.type = "text";
15847 slugInput.className = "wpd-tagcloud__editor-name";
15848 slugInput.value = term?.slug || "";
15849 slugInput.placeholder = __("auto-from-name");
15850 slugInput.spellcheck = false;
15851 slugInput.autocapitalize = "off";
15852 slugInput.addEventListener("input", () => {
15853 const v = slugInput.value;
15854 const norm = v.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
15855 if (v !== norm) {
15856 const sel = slugInput.selectionStart ?? norm.length;
15857 slugInput.value = norm;
15858 slugInput.setSelectionRange(sel, sel);
15859 }
15860 });
15861 sidebar.appendChild(slugInput);
15862 const descLabel = document.createElement("label");
15863 descLabel.className = "wpd-tagcloud__sidebar-label";
15864 descLabel.textContent = __("Description");
15865 sidebar.appendChild(descLabel);
15866 const descInput = document.createElement("textarea");
15867 descInput.className = "wpd-tagcloud__editor-desc";
15868 descInput.value = box.description || "";
15869 descInput.placeholder = __("Description (optional)");
15870 descInput.rows = 4;
15871 sidebar.appendChild(descInput);
15872 const meta = document.createElement("p");
15873 meta.className = "wpd-tagcloud__sidebar-meta";
15874 meta.textContent = sprintf(
15875 /* translators: %d: post count. */
15876 _n(
15877 "%d post tagged with this.",
15878 "%d posts tagged with this.",
15879 box.count
15880 ),
15881 box.count
15882 );
15883 sidebar.appendChild(meta);
15884 const actions = document.createElement("div");
15885 actions.className = "wpd-tagcloud__editor-actions";
15886 const saveBtn = document.createElement("button");
15887 saveBtn.type = "button";
15888 saveBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--primary";
15889 saveBtn.textContent = __("Save");
15890 saveBtn.addEventListener("click", async () => {
15891 const name = nameInput.value.trim();
15892 if (!name) {
15893 return;
15894 }
15895 const description = descInput.value;
15896 const slugRaw = slugInput.value.trim();
15897 const currentSlug = term?.slug ?? "";
15898 if (name === box.name && description === (box.description || "") && slugRaw === currentSlug) {
15899 return;
15900 }
15901 const patch = { name, description };
15902 if (slugRaw !== currentSlug) {
15903 patch.slug = slugRaw;
15904 }
15905 try {
15906 const updated = await client.updateTerm("tags", box.id, patch);
15907 box.name = updated.name;
15908 box.description = updated.description;
15909 box.slug = updated.slug ?? box.slug;
15910 box.hue = tagHue(box.slug || box.name, themeHue);
15911 box.rotation = tagRotation(box.slug || box.name);
15912 terms = terms.map(
15913 (t) => t.id === box.id ? {
15914 ...t,
15915 name: updated.name,
15916 description: updated.description,
15917 slug: updated.slug ?? t.slug
15918 } : t
15919 );
15920 layoutChip(box);
15921 paintSidebar();
15922 } catch (err) {
15923 showError(__("Couldn’t save:"), err);
15924 }
15925 });
15926 const delBtn = document.createElement("button");
15927 delBtn.type = "button";
15928 delBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--danger";
15929 delBtn.textContent = __("Delete");
15930 let armResetTimer = null;
15931 const armDelete = () => {
15932 delBtn.textContent = __("Click again to delete");
15933 delBtn.classList.add("is-armed");
15934 if (armResetTimer !== null) {
15935 window.clearTimeout(armResetTimer);
15936 }
15937 armResetTimer = window.setTimeout(() => {
15938 delBtn.textContent = __("Delete");
15939 delBtn.classList.remove("is-armed");
15940 armResetTimer = null;
15941 }, 2500);
15942 };
15943 delBtn.addEventListener("click", async () => {
15944 if (!delBtn.classList.contains("is-armed")) {
15945 armDelete();
15946 return;
15947 }
15948 if (armResetTimer !== null) {
15949 window.clearTimeout(armResetTimer);
15950 armResetTimer = null;
15951 }
15952 try {
15953 await client.deleteTerm("tags", box.id);
15954 terms = terms.filter((t) => t.id !== box.id);
15955 persistedPositions.delete(box.id);
15956 writePersistedPositions(positionsKey, persistedPositions);
15957 focusId = null;
15958 clearPosts();
15959 buildCloud();
15960 paintSidebar();
15961 } catch (err) {
15962 showError(__("Couldn’t delete:"), err);
15963 }
15964 });
15965 actions.appendChild(saveBtn);
15966 actions.appendChild(delBtn);
15967 sidebar.appendChild(actions);
15968 }
15969 function startDraft() {
15970 draft = true;
15971 paintSidebar();
15972 }
15973 addTagBtn.addEventListener("click", () => {
15974 startDraft();
15975 });
15976 function fitToView(opts = {}) {
15977 const padding = opts.padding ?? 90;
15978 const animate = opts.animate ?? false;
15979 const r = stage.getBoundingClientRect();
15980 if (tags.size === 0 || r.width === 0 || r.height === 0) {
15981 const cx2 = r.width / 2;
15982 const cy2 = r.height / 2;
15983 targetScale = 1;
15984 targetWorldX = cx2;
15985 targetWorldY = cy2;
15986 if (!animate) {
15987 world.x = cx2;
15988 world.y = cy2;
15989 world.scale.set(1);
15990 }
15991 return;
15992 }
15993 let minX = Infinity;
15994 let minY = Infinity;
15995 let maxX = -Infinity;
15996 let maxY = -Infinity;
15997 for (const box of tags.values()) {
15998 minX = Math.min(minX, box.tx - box.width / 2);
15999 minY = Math.min(minY, box.ty - box.height / 2);
16000 maxX = Math.max(maxX, box.tx + box.width / 2);
16001 maxY = Math.max(maxY, box.ty + box.height / 2);
16002 }
16003 const w = Math.max(1, maxX - minX);
16004 const h = Math.max(1, maxY - minY);
16005 const sx = (r.width - padding * 2) / w;
16006 const sy = (r.height - padding * 2) / h;
16007 const scale = Math.max(0.2, Math.min(1.5, Math.min(sx, sy)));
16008 const cx = (minX + maxX) / 2;
16009 const cy = (minY + maxY) / 2;
16010 const newWorldX = r.width / 2 - cx * scale;
16011 const newWorldY = r.height / 2 - cy * scale;
16012 targetScale = scale;
16013 targetWorldX = newWorldX;
16014 targetWorldY = newWorldY;
16015 if (!animate) {
16016 world.scale.set(scale);
16017 world.x = newWorldX;
16018 world.y = newWorldY;
16019 }
16020 }
16021 function recenterCamera() {
16022 if (focusId !== null) {
16023 const focused = tags.get(focusId);
16024 const r = stage.getBoundingClientRect();
16025 if (focused && r.width > 0 && r.height > 0) {
16026 const half = POST_RING_RADIUS + 70;
16027 const sx = r.width * 0.85 / (2 * half);
16028 const sy = r.height * 0.85 / (2 * half);
16029 const newScale = Math.max(
16030 0.5,
16031 Math.min(1.6, Math.min(sx, sy))
16032 );
16033 targetScale = newScale;
16034 targetWorldX = r.width / 2 - focused.x * newScale;
16035 targetWorldY = r.height / 2 - focused.y * newScale;
16036 return;
16037 }
16038 }
16039 fitToView({ animate: true });
16040 }
16041 recenterBtn.addEventListener("click", () => recenterCamera());
16042 reflowBtn.addEventListener("click", () => {
16043 persistedPositions.clear();
16044 writePersistedPositions(positionsKey, persistedPositions);
16045 for (const box of tags.values()) {
16046 box.tx = 0;
16047 box.ty = 0;
16048 }
16049 const allBoxes = Array.from(tags.values());
16050 allBoxes.sort((a, b) => b.count - a.count);
16051 packBoxesWithClusters(
16052 allBoxes,
16053 [],
16054 /* @__PURE__ */ new Map(),
16055 cooccurrenceMap
16056 );
16057 fitToView({ animate: true });
16058 void refreshCooccurrence();
16059 });
16060 app.canvas.addEventListener("click", (e) => {
16061 const now = performance.now();
16062 if (now - lastFocusChange < 250 || now - pixiInteractionAt < 250) {
16063 return;
16064 }
16065 if (panMovedDist > 4) {
16066 return;
16067 }
16068 const target = e.target;
16069 if (target === app.canvas && !dragChip && focusId !== null) {
16070 closeFocus();
16071 }
16072 });
16073 async function refreshCountsViaBulk() {
16074 if (terms.length === 0) {
16075 return;
16076 }
16077 const cfg = client.getConfig();
16078 const url = new URL(
16079 joinRestUrl(cfg.restRoot, "desktop-mode/v1/term-counts")
16080 );
16081 url.searchParams.set("taxonomy", "post_tag");
16082 url.searchParams.set(
16083 "ids",
16084 terms.map((t) => t.id).join(",")
16085 );
16086 try {
16087 const response = await fetchShellJson(client, url.toString());
16088 const map = response.json;
16089 let dirty = false;
16090 terms = terms.map((t) => {
16091 const fresh = map[String(t.id)];
16092 if (typeof fresh === "number" && fresh !== t.count) {
16093 dirty = true;
16094 const box = tags.get(t.id);
16095 if (box) {
16096 box.count = fresh;
16097 }
16098 return { ...t, count: fresh };
16099 }
16100 return t;
16101 });
16102 if (dirty) {
16103 const maxCount = Math.max(
16104 1,
16105 ...terms.map((t) => t.count)
16106 );
16107 for (const t of terms) {
16108 const box = tags.get(t.id);
16109 if (!box) {
16110 continue;
16111 }
16112 box.count = t.count;
16113 box.fontSize = fontSizeFor(t.count, maxCount);
16114 layoutChip(box);
16115 }
16116 if (focusId !== null) {
16117 paintSidebar();
16118 }
16119 }
16120 } catch {
16121 }
16122 }
16123 function relayoutWithCooccurrence() {
16124 const placed = [];
16125 const placedById = /* @__PURE__ */ new Map();
16126 const toRepack = [];
16127 for (const box of tags.values()) {
16128 if (persistedPositions.has(box.id)) {
16129 placed.push({
16130 x: box.tx - box.width / 2,
16131 y: box.ty - box.height / 2,
16132 w: box.width,
16133 h: box.height
16134 });
16135 placedById.set(box.id, { x: box.tx, y: box.ty });
16136 } else {
16137 toRepack.push(box);
16138 }
16139 }
16140 toRepack.sort((a, b) => b.count - a.count);
16141 packBoxesWithClusters(toRepack, placed, placedById, cooccurrenceMap);
16142 }
16143 async function refreshCooccurrence() {
16144 try {
16145 const fetched = await client.fetchTagCooccurrence("tags", 8);
16146 cooccurrenceMap = fetched;
16147 if (cooccurrenceMap.size > 0) {
16148 relayoutWithCooccurrence();
16149 }
16150 } catch {
16151 }
16152 }
16153 buildCloud();
16154 paintSidebar();
16155 raf = requestAnimationFrame(tick);
16156 void refreshCountsViaBulk();
16157 void refreshCooccurrence();
16158 if (terms.length === 0) {
16159 const empty = document.createElement("div");
16160 empty.className = "wpd-tagcloud__empty";
16161 empty.textContent = __(
16162 'No tags yet. Click "Add tag" to start building the cloud.'
16163 );
16164 stage.appendChild(empty);
16165 }
16166 let currentMatches = [];
16167 let selectedIndex = 0;
16168 const repaintHighlight = () => {
16169 const items = searchResults.querySelectorAll(
16170 ".wpd-tagcloud__search-result"
16171 );
16172 items.forEach((el, i) => {
16173 const active = i === selectedIndex;
16174 el.classList.toggle("is-active", active);
16175 if (active) {
16176 el.scrollIntoView({ block: "nearest" });
16177 }
16178 });
16179 };
16180 const selectMatch = (t) => {
16181 searchInput.value = "";
16182 searchResults.hidden = true;
16183 searchResults.replaceChildren();
16184 currentMatches = [];
16185 selectedIndex = 0;
16186 void focusTag(t.id);
16187 };
16188 const renderSearchResults = () => {
16189 const q = searchInput.value.trim().toLowerCase();
16190 if (q.length === 0) {
16191 searchResults.hidden = true;
16192 searchResults.replaceChildren();
16193 currentMatches = [];
16194 selectedIndex = 0;
16195 return;
16196 }
16197 currentMatches = Array.from(tags.values()).filter(
16198 (t) => t.name.toLowerCase().includes(q) || t.slug.toLowerCase().includes(q)
16199 ).sort((a, b) => b.count - a.count).slice(0, 10);
16200 selectedIndex = 0;
16201 searchResults.replaceChildren();
16202 currentMatches.forEach((t, i) => {
16203 const li = document.createElement("li");
16204 const btn = document.createElement("button");
16205 btn.type = "button";
16206 btn.className = "wpd-tagcloud__search-result";
16207 if (i === 0) {
16208 btn.classList.add("is-active");
16209 }
16210 const nameEl = document.createElement("span");
16211 nameEl.className = "wpd-tagcloud__search-title";
16212 nameEl.textContent = t.name || `#${t.id}`;
16213 const countEl = document.createElement("span");
16214 countEl.className = "wpd-tagcloud__search-meta";
16215 countEl.textContent = sprintf(
16216 /* translators: %d: number of posts assigned to a tag. */
16217 _n("%d post", "%d posts", t.count),
16218 t.count
16219 );
16220 btn.appendChild(nameEl);
16221 btn.appendChild(countEl);
16222 btn.addEventListener("mousedown", (ev) => {
16223 ev.preventDefault();
16224 selectMatch(t);
16225 });
16226 btn.addEventListener("mouseenter", () => {
16227 selectedIndex = i;
16228 repaintHighlight();
16229 });
16230 li.appendChild(btn);
16231 searchResults.appendChild(li);
16232 });
16233 searchResults.hidden = currentMatches.length === 0;
16234 };
16235 searchInput.addEventListener("input", renderSearchResults);
16236 searchInput.addEventListener("focus", renderSearchResults);
16237 searchInput.addEventListener("keydown", (ev) => {
16238 if (ev.key === "ArrowDown") {
16239 if (currentMatches.length === 0) {
16240 return;
16241 }
16242 ev.preventDefault();
16243 selectedIndex = Math.min(
16244 selectedIndex + 1,
16245 currentMatches.length - 1
16246 );
16247 repaintHighlight();
16248 } else if (ev.key === "ArrowUp") {
16249 if (currentMatches.length === 0) {
16250 return;
16251 }
16252 ev.preventDefault();
16253 selectedIndex = Math.max(selectedIndex - 1, 0);
16254 repaintHighlight();
16255 } else if (ev.key === "Enter") {
16256 if (currentMatches.length === 0) {
16257 return;
16258 }
16259 ev.preventDefault();
16260 selectMatch(currentMatches[selectedIndex]);
16261 } else if (ev.key === "Escape") {
16262 searchInput.value = "";
16263 searchResults.hidden = true;
16264 searchResults.replaceChildren();
16265 currentMatches = [];
16266 selectedIndex = 0;
16267 }
16268 });
16269 searchInput.addEventListener("blur", () => {
16270 setTimeout(() => {
16271 searchResults.hidden = true;
16272 }, 120);
16273 });
16274 const onDocClickSearch = (ev) => {
16275 if (!searchWrap.contains(ev.target)) {
16276 searchResults.hidden = true;
16277 }
16278 };
16279 document.addEventListener("click", onDocClickSearch);
16280 return () => {
16281 if (raf !== null) {
16282 cancelAnimationFrame(raf);
16283 raf = null;
16284 }
16285 if (settleTimer !== null) {
16286 window.clearTimeout(settleTimer);
16287 settleTimer = null;
16288 }
16289 ro.disconnect();
16290 stage.removeEventListener("wheel", onWheel);
16291 document.removeEventListener("click", onDocClickSearch);
16292 try {
16293 app.ticker?.stop();
16294 } catch {
16295 }
16296 try {
16297 app.destroy({ removeView: true }, { children: true });
16298 } catch {
16299 }
16300 host.replaceChildren();
16301 host.classList.remove("wpd-tagcloud");
16302 };
16303 }
16304 function fontSizeFor(count, max) {
16305 const ratio = Math.sqrt(count / Math.max(1, max));
16306 return Math.round(
16307 MIN_FONT_SIZE + (MAX_FONT_SIZE - MIN_FONT_SIZE) * ratio
16308 );
16309 }
16310 function truncateChipName(name) {
16311 return name.length > CHIP_NAME_MAX_CHARS ? name.slice(0, CHIP_NAME_MAX_CHARS - 1) + "…" : name;
16312 }
16313 function aabbIntersect(a, b) {
16314 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;
16315 }
16316 function slugHash(slug) {
16317 let h = 0;
16318 for (let i = 0; i < slug.length; i++) {
16319 h = (h * 31 + slug.charCodeAt(i)) % 2147483647;
16320 }
16321 return h;
16322 }
16323 function tagHue(slug, baseHue) {
16324 const h = slugHash(slug);
16325 return ((baseHue + h % 256 * 1.4) % 360 + 360) % 360;
16326 }
16327 function tagRotation(slug) {
16328 const h = slugHash(slug);
16329 const sign = h % 2 === 0 ? -1 : 1;
16330 const mag = Math.floor(h / 2) % 4 * 0.011;
16331 return sign * mag;
16332 }
16333 function readAdminThemeHue() {
16334 try {
16335 const value = getComputedStyle(document.documentElement).getPropertyValue("--wp-admin-theme-color").trim();
16336 if (!value) {
16337 return 210;
16338 }
16339 const c = document.createElement("span");
16340 c.style.color = value;
16341 document.body.appendChild(c);
16342 const rgb = getComputedStyle(c).color;
16343 c.remove();
16344 const m = rgb.match(/\d+/g);
16345 if (!m || m.length < 3) {
16346 return 210;
16347 }
16348 return rgbToHue(
16349 parseInt(m[0], 10),
16350 parseInt(m[1], 10),
16351 parseInt(m[2], 10)
16352 );
16353 } catch {
16354 return 210;
16355 }
16356 }
16357 function rgbToHue(r, g, b) {
16358 const rn = r / 255;
16359 const gn = g / 255;
16360 const bn = b / 255;
16361 const max = Math.max(rn, gn, bn);
16362 const min = Math.min(rn, gn, bn);
16363 const d = max - min;
16364 if (d === 0) {
16365 return 210;
16366 }
16367 let h;
16368 switch (max) {
16369 case rn:
16370 h = (gn - bn) / d + (gn < bn ? 6 : 0);
16371 break;
16372 case gn:
16373 h = (bn - rn) / d + 2;
16374 break;
16375 default:
16376 h = (rn - gn) / d + 4;
16377 break;
16378 }
16379 return Math.round(h * 60);
16380 }
16381 function hslToInt(h, s, l) {
16382 const sn = s / 100;
16383 const ln = l / 100;
16384 const c = (1 - Math.abs(2 * ln - 1)) * sn;
16385 const hp = h / 60;
16386 const x = c * (1 - Math.abs(hp % 2 - 1));
16387 let r = 0;
16388 let g = 0;
16389 let b = 0;
16390 if (hp < 1) {
16391 r = c;
16392 g = x;
16393 } else if (hp < 2) {
16394 r = x;
16395 g = c;
16396 } else if (hp < 3) {
16397 g = c;
16398 b = x;
16399 } else if (hp < 4) {
16400 g = x;
16401 b = c;
16402 } else if (hp < 5) {
16403 r = x;
16404 b = c;
16405 } else {
16406 r = c;
16407 b = x;
16408 }
16409 const m = ln - c / 2;
16410 const ri = Math.round((r + m) * 255);
16411 const gi = Math.round((g + m) * 255);
16412 const bi = Math.round((b + m) * 255);
16413 return ri * 65536 + gi * 256 + bi;
16414 }
16415 function stripTags(html2) {
16416 const tmp = document.createElement("div");
16417 tmp.innerHTML = html2;
16418 return tmp.textContent || tmp.innerText || "";
16419 }
16420 function showToast(title, err) {
16421 const reason = err instanceof Error ? err.message : String(err);
16422 const api = window.wp?.desktop;
16423 if (api && typeof api.showToast === "function") {
16424 api.showToast({
16425 message: `${title} ${reason}`.trim(),
16426 duration: 6e3
16427 });
16428 return;
16429 }
16430 console.error(title, err);
16431 }
16432 async function fetchShellJson(client, url) {
16433 const cfg = client.getConfig();
16434 const init = {
16435 method: "GET",
16436 credentials: "same-origin",
16437 headers: {
16438 "X-WP-Nonce": cfg.restNonce,
16439 Accept: "application/json"
16440 }
16441 };
16442 const response = await trackedFetch(url, init, {
16443 windowId: "desktop-mode-posts"
16444 });
16445 if (!response.ok) {
16446 throw new Error(`${response.status} ${response.statusText}`);
16447 }
16448 const json = await response.json();
16449 return { json, headers: response.headers };
16450 }
16451 function computePositionsKey() {
16452 try {
16453 const host = window.location.host || "unknown";
16454 const path = window.location.pathname.replace(/\/?wp-admin\/?.*$/, "");
16455 return `wpd-tagcloud-positions:${host}${path}`;
16456 } catch {
16457 return "wpd-tagcloud-positions:fallback";
16458 }
16459 }
16460 function readPersistedPositions(key) {
16461 try {
16462 const raw = window.localStorage.getItem(key);
16463 if (!raw) {
16464 return /* @__PURE__ */ new Map();
16465 }
16466 const parsed = JSON.parse(raw);
16467 if (!parsed || typeof parsed !== "object") {
16468 return /* @__PURE__ */ new Map();
16469 }
16470 const out = /* @__PURE__ */ new Map();
16471 for (const [k, v] of Object.entries(
16472 parsed
16473 )) {
16474 const id = parseInt(k, 10);
16475 if (!Number.isFinite(id)) {
16476 continue;
16477 }
16478 const pos = v;
16479 if (typeof pos?.x === "number" && typeof pos?.y === "number") {
16480 out.set(id, { x: pos.x, y: pos.y });
16481 }
16482 }
16483 return out;
16484 } catch {
16485 return /* @__PURE__ */ new Map();
16486 }
16487 }
16488 function writePersistedPositions(key, positions) {
16489 try {
16490 const obj = {};
16491 for (const [id, pos] of positions) {
16492 obj[String(id)] = pos;
16493 }
16494 window.localStorage.setItem(key, JSON.stringify(obj));
16495 } catch {
16496 }
16497 }
16498 function createTagChip(pixi, chipLayer, term, fontSize, hue) {
16499 const container = new pixi.Container();
16500 container.eventMode = "static";
16501 container.cursor = "pointer";
16502 const shadow = new pixi.Graphics();
16503 container.addChild(shadow);
16504 const bg = new pixi.Graphics();
16505 container.addChild(bg);
16506 const hashText = new pixi.Text({
16507 text: "#",
16508 style: {
16509 fill: hslToInt(hue, 65, 42),
16510 fontSize,
16511 fontFamily: FONT_FAMILY,
16512 fontWeight: "700"
16513 },
16514 resolution: CHIP_TEXT_RES
16515 });
16516 container.addChild(hashText);
16517 const nameText = new pixi.Text({
16518 text: truncateChipName(term.name),
16519 style: {
16520 fill: 1909543,
16521 fontSize,
16522 fontFamily: FONT_FAMILY,
16523 fontWeight: "600"
16524 },
16525 resolution: CHIP_TEXT_RES
16526 });
16527 container.addChild(nameText);
16528 const countText = new pixi.Text({
16529 text: String(term.count),
16530 style: {
16531 fill: 16777215,
16532 fontSize: Math.max(10, Math.round(fontSize * 0.55)),
16533 fontFamily: FONT_FAMILY,
16534 fontWeight: "700"
16535 },
16536 resolution: CHIP_TEXT_RES
16537 });
16538 container.addChild(countText);
16539 chipLayer.addChild(container);
16540 return {
16541 container,
16542 shadow,
16543 bg,
16544 hashText,
16545 nameText,
16546 countText,
16547 width: 0,
16548 height: 0,
16549 cachedName: "",
16550 cachedCount: -1,
16551 cachedFocused: false,
16552 cachedHover: false,
16553 cachedHue: -1
16554 };
16555 }
16556 const tagsCloud = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
16557 __proto__: null,
16558 mountTagsCloud
16559 }, Symbol.toStringTag, { value: "Module" }));
16560 async function showUsersIntroDialog() {
16561 return new Promise((resolve) => {
16562 const backdrop = document.createElement("div");
16563 backdrop.className = "desktop-mode-users-intro__backdrop";
16564 backdrop.setAttribute("role", "presentation");
16565 Object.assign(backdrop.style, {
16566 position: "fixed",
16567 inset: "0",
16568 background: "color-mix(in srgb, var(--wp-admin-theme-color, #1d2327) 60%, transparent)",
16569 backdropFilter: "blur(2px)",
16570 zIndex: "100000",
16571 display: "flex",
16572 alignItems: "center",
16573 justifyContent: "center",
16574 padding: "24px"
16575 });
16576 const dialog = document.createElement("div");
16577 dialog.setAttribute("role", "dialog");
16578 dialog.setAttribute("aria-modal", "true");
16579 dialog.setAttribute(
16580 "aria-labelledby",
16581 "desktop-mode-users-intro-title"
16582 );
16583 dialog.className = "desktop-mode-users-intro";
16584 Object.assign(dialog.style, {
16585 background: "var(--wp-admin-theme-bg, #fff)",
16586 color: "var(--wp-admin-theme-fg, #1d2327)",
16587 borderRadius: "14px",
16588 boxShadow: "0 24px 60px rgba(0,0,0,.28)",
16589 maxWidth: "520px",
16590 width: "100%",
16591 maxHeight: "90vh",
16592 overflow: "auto",
16593 padding: "28px 32px 24px",
16594 fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
16595 });
16596 dialog.innerHTML = renderDialogMarkup();
16597 backdrop.appendChild(dialog);
16598 document.body.appendChild(backdrop);
16599 const primaryBtn = dialog.querySelector(
16600 '[data-action="confirm"]'
16601 );
16602 const settingsBtn = dialog.querySelector(
16603 '[data-action="settings"]'
16604 );
16605 primaryBtn?.focus();
16606 let resolved = false;
16607 const cleanup = (result) => {
16608 if (resolved) {
16609 return;
16610 }
16611 resolved = true;
16612 document.removeEventListener("keydown", onKey, true);
16613 backdrop.remove();
16614 resolve(result);
16615 };
16616 const onKey = (e) => {
16617 if (e.key === "Escape") {
16618 e.preventDefault();
16619 cleanup("cancel");
16620 }
16621 };
16622 document.addEventListener("keydown", onKey, true);
16623 backdrop.addEventListener("click", (e) => {
16624 if (e.target === backdrop) {
16625 cleanup("cancel");
16626 }
16627 });
16628 primaryBtn?.addEventListener("click", () => cleanup("confirm"));
16629 settingsBtn?.addEventListener("click", () => cleanup("settings"));
16630 });
16631 }
16632 function renderDialogMarkup() {
16633 const title = __("Welcome to the new Users window");
16634 const lede = __(
16635 "Same data you already manage, with the polish the Users list has been waiting for."
16636 );
16637 const highlights = [
16638 __("Live online indicator on every row — see who is around right now."),
16639 __("Last-login column so you finally know who is actually using the site."),
16640 __("Bulk role change with strict role-permission enforcement — never accidentally promote anyone above your own level."),
16641 __("One-click password reset and resend-welcome buttons, with sensible rate-limiting."),
16642 __("Click-to-copy email and a long-overdue search that matches name, username, AND email."),
16643 __("Per-user content stats: posts, pages, comments at a glance.")
16644 ];
16645 const li = (arr) => arr.map(
16646 (s) => `<li><span class="dot" aria-hidden="true"></span>${escapeHtml(s)}</li>`
16647 ).join("");
16648 return `
16649 <style>
16650 .desktop-mode-users-intro h2 {
16651 margin: 0 0 8px;
16652 font-size: 22px;
16653 font-weight: 600;
16654 letter-spacing: -0.01em;
16655 }
16656 .desktop-mode-users-intro p.lede {
16657 margin: 0 0 20px;
16658 color: var(--wp-admin-theme-fg-muted, #50575e);
16659 font-size: 14px;
16660 line-height: 1.5;
16661 }
16662 .desktop-mode-users-intro__list {
16663 list-style: none;
16664 margin: 0 0 22px;
16665 padding: 0;
16666 font-size: 14px;
16667 line-height: 1.5;
16668 }
16669 .desktop-mode-users-intro__list li {
16670 display: flex;
16671 align-items: flex-start;
16672 gap: 10px;
16673 padding: 6px 0;
16674 }
16675 .desktop-mode-users-intro__list .dot {
16676 flex: 0 0 auto;
16677 width: 6px;
16678 height: 6px;
16679 margin-top: 9px;
16680 border-radius: 50%;
16681 background: var(--wp-admin-theme-color, #2271b1);
16682 }
16683 .desktop-mode-users-intro__footer {
16684 display: flex;
16685 justify-content: flex-end;
16686 gap: 8px;
16687 margin-top: 8px;
16688 }
16689 .desktop-mode-users-intro__footer button {
16690 appearance: none;
16691 border: 1px solid var(--wp-admin-theme-border, #dcdcde);
16692 background: var(--wp-admin-theme-bg, #fff);
16693 color: inherit;
16694 padding: 8px 14px;
16695 border-radius: 6px;
16696 font-size: 13px;
16697 cursor: pointer;
16698 }
16699 .desktop-mode-users-intro__footer button.primary {
16700 border-color: var(--wp-admin-theme-color, #2271b1);
16701 background: var(--wp-admin-theme-color, #2271b1);
16702 color: #fff;
16703 font-weight: 500;
16704 }
16705 .desktop-mode-users-intro__footer button:hover { filter: brightness(1.05); }
16706 .desktop-mode-users-intro__footer button:focus-visible {
16707 outline: 2px solid var(--wp-admin-theme-color, #2271b1);
16708 outline-offset: 2px;
16709 }
16710 </style>
16711 <h2 id="desktop-mode-users-intro-title">${escapeHtml(title)}</h2>
16712 <p class="lede">${escapeHtml(lede)}</p>
16713 <ul class="desktop-mode-users-intro__list">${li(highlights)}</ul>
16714 <div class="desktop-mode-users-intro__footer">
16715 <button type="button" data-action="settings">${escapeHtml(
16716 __("Take me to settings")
16717 )}</button>
16718 <button type="button" class="primary" data-action="confirm">${escapeHtml(
16719 __("Got it")
16720 )}</button>
16721 </div>
16722 `;
16723 }
16724 function escapeHtml(s) {
16725 const t = document.createElement("div");
16726 t.textContent = s;
16727 return t.innerHTML;
16728 }
16729 const _initial = {
16730 userId: null,
16731 requestedAt: 0,
16732 tabRequested: false
16733 };
16734 let _store = null;
16735 function getStore() {
16736 if (_store) {
16737 return _store;
16738 }
16739 const w = window;
16740 const factory = w.wp?.desktop?.createSharedStore;
16741 if (typeof factory !== "function") {
16742 return null;
16743 }
16744 _store = factory(
16745 "desktop-mode/user-edit/target",
16746 () => ({ ..._initial })
16747 );
16748 return _store;
16749 }
16750 function setUserEditTarget(userId) {
16751 const store = getStore();
16752 if (store) {
16753 store.state.userId = userId;
16754 store.state.requestedAt = Date.now();
16755 store.state.tabRequested = true;
16756 store.notify();
16757 return;
16758 }
16759 const w = window;
16760 w._wpdUserEditTarget = {
16761 userId,
16762 requestedAt: Date.now(),
16763 tabRequested: true
16764 };
16765 }
16766 function readUserEditTarget() {
16767 const store = getStore();
16768 if (store) {
16769 return { ...store.state };
16770 }
16771 const w = window;
16772 return w._wpdUserEditTarget ?? { ..._initial };
16773 }
16774 function clearUserEditTarget() {
16775 const store = getStore();
16776 if (store) {
16777 store.state.userId = null;
16778 store.state.requestedAt = 0;
16779 store.state.tabRequested = false;
16780 store.notify();
16781 }
16782 const w = window;
16783 if (w._wpdUserEditTarget) {
16784 w._wpdUserEditTarget = {
16785 userId: null,
16786 requestedAt: 0,
16787 tabRequested: false
16788 };
16789 }
16790 }
16791 function setUserEditTabRequested(requested) {
16792 const store = getStore();
16793 if (store) {
16794 store.state.tabRequested = requested;
16795 store.notify();
16796 return;
16797 }
16798 const w = window;
16799 const prev = w._wpdUserEditTarget ?? { ..._initial };
16800 w._wpdUserEditTarget = { ...prev, tabRequested: requested };
16801 }
16802 function subscribeUserEditTarget(cb) {
16803 const store = getStore();
16804 if (!store) {
16805 return () => {
16806 };
16807 }
16808 return store.subscribe((state) => cb({ ...state }));
16809 }
16810 const userEditTarget = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
16811 __proto__: null,
16812 clearUserEditTarget,
16813 readUserEditTarget,
16814 setUserEditTabRequested,
16815 setUserEditTarget,
16816 subscribeUserEditTarget
16817 }, Symbol.toStringTag, { value: "Module" }));
16818 function wpdConfirmGlobal(options) {
16819 const w = window;
16820 const fn = w.wp?.desktop?.confirm;
16821 if (typeof fn !== "function") {
16822 return Promise.resolve(window.confirm(options.message));
16823 }
16824 return fn(options);
16825 }
16826 function notifyToast(body, opts = {}) {
16827 const w = window;
16828 const api = w.wp?.desktop;
16829 if (api?.notify) {
16830 api.notify({ body, kind: opts.kind });
16831 return;
16832 }
16833 console.info("[users-window]", body);
16834 }
16835 function openUserEditWindow(userId) {
16836 if (!Number.isFinite(userId) || userId <= 0) {
16837 return;
16838 }
16839 setUserEditTarget(userId);
16840 console.info(
16841 "[users-window] opening user-edit window for user",
16842 userId
16843 );
16844 const w = window;
16845 const fn = w.wp?.desktop?.openWindow;
16846 if (typeof fn !== "function") {
16847 console.error(
16848 "[users-window] wp.desktop.openWindow is missing — desktop shell may not be ready."
16849 );
16850 notifyToast(
16851 __("Could not open profile window — desktop shell unavailable."),
16852 { kind: "error" }
16853 );
16854 return;
16855 }
16856 const opened = fn("desktop-mode-user-edit", {
16857 source: "users-window/row-click"
16858 });
16859 if (!opened) {
16860 console.error(
16861 '[users-window] openWindow("desktop-mode-user-edit") returned false — window not registered server-side. Check includes/user-edit-window/window.php.'
16862 );
16863 notifyToast(
16864 __("Profile window not registered — see console."),
16865 { kind: "error" }
16866 );
16867 }
16868 }
16869 const ROOT = "[data-desktop-mode-posts-root]";
16870 const STATUS = "[data-desktop-mode-posts-status]";
16871 const SEARCH = "[data-desktop-mode-posts-search]";
16872 const REFRESH = "[data-desktop-mode-posts-refresh]";
16873 const NEW_BTN = "[data-desktop-mode-posts-new]";
16874 const TABLE = "[data-desktop-mode-posts-table]";
16875 const BULK = "[data-desktop-mode-posts-bulk]";
16876 const COUNT = "[data-desktop-mode-posts-count]";
16877 const PAGE_INDICATOR = "[data-desktop-mode-posts-page-indicator]";
16878 const PREV = "[data-desktop-mode-posts-prev]";
16879 const NEXT = "[data-desktop-mode-posts-next]";
16880 const PER_PAGE = "[data-desktop-mode-posts-per-page]";
16881 const BULK_ACTIONS_HOST = "[data-desktop-mode-posts-bulk-actions]";
16882 const SEARCH_DEBOUNCE_MS = 250;
16883 function userCellKey(id, key) {
16884 return `${id}::${key}`;
16885 }
16886 function memoUserCell(cache, id, key, build) {
16887 const k = userCellKey(id, key);
16888 const cached = cache.get(k);
16889 if (cached) {
16890 return cached;
16891 }
16892 const node = build();
16893 cache.set(k, node);
16894 return node;
16895 }
16896 const _usersIntroShown = { v: false };
16897 function maybeShowUsersIntro(client) {
16898 if (_usersIntroShown.v) {
16899 return;
16900 }
16901 let cfg;
16902 try {
16903 cfg = client.getConfig();
16904 } catch {
16905 return;
16906 }
16907 if (cfg.introSeen) {
16908 return;
16909 }
16910 _usersIntroShown.v = true;
16911 void showUsersIntroDialog().then((result) => {
16912 if (result === "cancel") {
16913 _usersIntroShown.v = false;
16914 return;
16915 }
16916 void markUsersIntroSeen(client, cfg);
16917 if (result === "settings") {
16918 const w = window;
16919 w.wp?.desktop?.openOsSettings?.();
16920 }
16921 }).catch(() => {
16922 _usersIntroShown.v = false;
16923 });
16924 }
16925 async function markUsersIntroSeen(client, cfg) {
16926 if (!cfg.introUrl) {
16927 return;
16928 }
16929 try {
16930 await trackedFetch(
16931 cfg.introUrl,
16932 {
16933 method: "POST",
16934 credentials: "same-origin",
16935 headers: {
16936 "Content-Type": "application/json",
16937 "X-WP-Nonce": cfg.restNonce
16938 },
16939 body: JSON.stringify({ slug: "users" })
16940 },
16941 {
16942 windowId: client.windowId,
16943 source: "users-window/intro"
16944 }
16945 );
16946 cfg.introSeen = true;
16947 } catch {
16948 }
16949 }
16950 function buildIdentityCell(row, cfg) {
16951 const cell = document.createElement("span");
16952 cell.style.cssText = "display:flex;align-items:center;gap:10px;min-width:0;";
16953 const avatar = document.createElement("wpd-avatar");
16954 avatar.setAttribute("size", "32");
16955 if (row.name) {
16956 avatar.setAttribute("name", row.name);
16957 }
16958 const presence = row.desktop_mode_presence ?? "offline";
16959 avatar.setAttribute("presence", presence);
16960 const avatars = row.avatar_urls ?? {};
16961 const rawAvatar = avatars["48"] ?? avatars["96"] ?? avatars["24"] ?? "";
16962 if (rawAvatar) {
16963 applyAvatarSrc(avatar, rawAvatar);
16964 }
16965 cell.appendChild(avatar);
16966 const text = document.createElement("span");
16967 text.style.cssText = "display:flex;flex-direction:column;min-width:0;line-height:1.25;";
16968 const nameRow = document.createElement("span");
16969 const name = document.createElement("a");
16970 name.href = `${cfg.editPostUrlBase}?user_id=${row.id}`;
16971 name.textContent = row.name || `#${row.id}`;
16972 name.title = name.textContent;
16973 name.setAttribute("data-noclick", "");
16974 name.style.cssText = "font-weight:600;color:inherit;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;";
16975 name.addEventListener("mouseenter", () => {
16976 name.style.textDecoration = "underline";
16977 });
16978 name.addEventListener("mouseleave", () => {
16979 name.style.textDecoration = "none";
16980 });
16981 name.addEventListener("click", (e) => {
16982 e.preventDefault();
16983 e.stopPropagation();
16984 void openUserEditWindow(row.id);
16985 });
16986 nameRow.appendChild(name);
16987 text.appendChild(nameRow);
16988 if (row.slug) {
16989 const sub = document.createElement("span");
16990 sub.textContent = `@${row.slug}`;
16991 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;";
16992 text.appendChild(sub);
16993 }
16994 cell.appendChild(text);
16995 return cell;
16996 }
16997 function buildEmailCell(row) {
16998 const cell = document.createElement("button");
16999 cell.type = "button";
17000 const email = typeof row.email === "string" ? row.email : "";
17001 cell.textContent = email || "—";
17002 cell.disabled = email === "";
17003 cell.title = email ? __("Click to copy email") : "";
17004 Object.assign(cell.style, {
17005 appearance: "none",
17006 background: "transparent",
17007 border: "none",
17008 padding: "2px 6px",
17009 font: "inherit",
17010 color: "inherit",
17011 cursor: email ? "copy" : "default",
17012 textAlign: "left",
17013 fontSize: "13px",
17014 borderRadius: "4px",
17015 maxWidth: "100%",
17016 overflow: "hidden",
17017 textOverflow: "ellipsis",
17018 whiteSpace: "nowrap"
17019 });
17020 cell.addEventListener("click", (e) => {
17021 e.stopPropagation();
17022 if (!email) {
17023 return;
17024 }
17025 void navigator.clipboard?.writeText(email).then(() => {
17026 const orig = cell.textContent;
17027 cell.textContent = __("Copied!");
17028 cell.style.color = "var(--wp-admin-theme-color, #2271b1)";
17029 setTimeout(() => {
17030 cell.textContent = orig;
17031 cell.style.color = "";
17032 }, 1200);
17033 }).catch(() => {
17034 });
17035 });
17036 return cell;
17037 }
17038 function buildRoleCell(row, cfg) {
17039 const cell = document.createElement("span");
17040 cell.style.cssText = "display:inline-flex;flex-wrap:wrap;gap:4px;min-width:0;";
17041 const roles = Array.isArray(row.roles) ? row.roles : [];
17042 const labels = cfg.allRoles ?? {};
17043 if (roles.length === 0) {
17044 const none = document.createElement("span");
17045 none.textContent = __("No role");
17046 none.style.cssText = "color:var(--wp-admin-theme-fg-muted, #8c8f94);font-style:italic;";
17047 cell.appendChild(none);
17048 return cell;
17049 }
17050 for (const slug of roles) {
17051 const chip = document.createElement("span");
17052 chip.textContent = labels[slug] ?? slug;
17053 chip.style.cssText = [
17054 "display:inline-flex",
17055 "align-items:center",
17056 "padding:2px 8px",
17057 "border-radius:10px",
17058 "font-size:11px",
17059 "font-weight:600",
17060 "background:rgba(34,113,177,0.10)",
17061 "color:#0a4b78",
17062 "white-space:nowrap"
17063 ].join(";");
17064 cell.appendChild(chip);
17065 }
17066 return cell;
17067 }
17068 function buildStatsCell(row) {
17069 const stats = row.desktop_mode_user_stats ?? {
17070 posts: 0,
17071 pages: 0,
17072 comments: 0
17073 };
17074 const cell = document.createElement("span");
17075 cell.style.cssText = "display:inline-flex;align-items:center;gap:10px;font-size:12px;font-variant-numeric:tabular-nums;";
17076 const mk = (dashicon, count, label) => {
17077 const span = document.createElement("span");
17078 span.style.cssText = "display:inline-flex;align-items:center;gap:3px;";
17079 span.title = label;
17080 const ic = document.createElement("wpd-icon");
17081 ic.setAttribute("name", dashicon);
17082 ic.setAttribute("size", "14");
17083 ic.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
17084 span.appendChild(ic);
17085 const txt = document.createElement("span");
17086 txt.textContent = String(count);
17087 if (count === 0) {
17088 txt.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
17089 }
17090 span.appendChild(txt);
17091 return span;
17092 };
17093 cell.appendChild(mk("admin-post", stats.posts, __("Posts")));
17094 cell.appendChild(mk("admin-page", stats.pages, __("Pages")));
17095 cell.appendChild(
17096 mk("admin-comments", stats.comments, __("Comments"))
17097 );
17098 return cell;
17099 }
17100 function relativeTime(ts) {
17101 const now = Math.floor(Date.now() / 1e3);
17102 const delta = now - ts;
17103 if (delta < 60) {
17104 return __("just now");
17105 }
17106 if (delta < 3600) {
17107 const m = Math.floor(delta / 60);
17108 return sprintf(__("%d min ago"), m);
17109 }
17110 if (delta < 86400) {
17111 const h = Math.floor(delta / 3600);
17112 return sprintf(__("%d h ago"), h);
17113 }
17114 if (delta < 86400 * 30) {
17115 const d = Math.floor(delta / 86400);
17116 return sprintf(__("%d d ago"), d);
17117 }
17118 if (delta < 86400 * 365) {
17119 const mo = Math.floor(delta / (86400 * 30));
17120 return sprintf(__("%d mo ago"), mo);
17121 }
17122 const y = Math.floor(delta / (86400 * 365));
17123 return sprintf(__("%d y ago"), y);
17124 }
17125 function buildLastLoginCell(row) {
17126 const cell = document.createElement("span");
17127 cell.style.cssText = "font-size:13px;font-variant-numeric:tabular-nums;";
17128 const ts = row.desktop_mode_last_login;
17129 if (!ts || typeof ts !== "number") {
17130 cell.textContent = __("Never");
17131 cell.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
17132 return cell;
17133 }
17134 cell.textContent = relativeTime(ts);
17135 const dt = new Date(ts * 1e3);
17136 cell.title = dt.toLocaleString();
17137 return cell;
17138 }
17139 function buildRegisteredCell(row) {
17140 const cell = document.createElement("span");
17141 cell.style.cssText = "font-size:13px;font-variant-numeric:tabular-nums;";
17142 const raw = typeof row.registered_date === "string" ? row.registered_date : "";
17143 if (!raw) {
17144 cell.textContent = "—";
17145 cell.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
17146 return cell;
17147 }
17148 const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(raw);
17149 const ts = Math.floor(Date.parse(hasTz ? raw : raw + "Z") / 1e3);
17150 if (!Number.isFinite(ts)) {
17151 cell.textContent = raw;
17152 return cell;
17153 }
17154 cell.textContent = relativeTime(ts);
17155 cell.title = new Date(ts * 1e3).toLocaleString();
17156 return cell;
17157 }
17158 function buildActionsCell(row, cfg, client) {
17159 const cell = document.createElement("span");
17160 cell.style.cssText = "display:inline-flex;gap:4px;align-items:center;";
17161 const canEditViewer = cfg.canEdit === true;
17162 const canEditRow = row.desktop_mode_can_edit === true;
17163 if (!canEditViewer || !canEditRow) {
17164 cell.textContent = "—";
17165 cell.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)";
17166 return cell;
17167 }
17168 const mk = (label, dashicon, fn) => {
17169 const btn = document.createElement("button");
17170 btn.type = "button";
17171 btn.title = label;
17172 btn.setAttribute("aria-label", label);
17173 Object.assign(btn.style, {
17174 appearance: "none",
17175 border: "1px solid var(--wp-admin-theme-border, #dcdcde)",
17176 background: "var(--wp-admin-theme-bg, #fff)",
17177 color: "inherit",
17178 padding: "4px 6px",
17179 borderRadius: "4px",
17180 cursor: "pointer",
17181 lineHeight: "1"
17182 });
17183 const ic = document.createElement("wpd-icon");
17184 ic.setAttribute("name", dashicon);
17185 ic.setAttribute("size", "14");
17186 btn.appendChild(ic);
17187 btn.addEventListener("click", (e) => {
17188 e.stopPropagation();
17189 fn();
17190 });
17191 return btn;
17192 };
17193 cell.appendChild(
17194 mk(
17195 __("Send password reset"),
17196 "email-alt",
17197 async () => {
17198 const ok = await wpdConfirmGlobal({
17199 title: __("Send password reset email?"),
17200 message: sprintf(
17201 // translators: %s is a user name.
17202 __("WordPress will email %s a password-reset link."),
17203 row.name
17204 ),
17205 confirmLabel: __("Send reset email")
17206 });
17207 if (!ok) {
17208 return;
17209 }
17210 const result = await client.sendPasswordReset(row.id);
17211 if (result.ok) {
17212 notifyToast(
17213 sprintf(
17214 // translators: %s is the user's email address.
17215 __("Reset email sent to %s."),
17216 result.email ?? row.email ?? ""
17217 ),
17218 { kind: "success" }
17219 );
17220 } else {
17221 notifyToast(
17222 sprintf(
17223 // translators: %s is an error code.
17224 __("Could not send reset email (%s)."),
17225 result.error ?? "unknown"
17226 ),
17227 { kind: "error" }
17228 );
17229 }
17230 }
17231 )
17232 );
17233 cell.appendChild(
17234 mk(
17235 __("Resend welcome email"),
17236 "megaphone",
17237 async () => {
17238 const ok = await wpdConfirmGlobal({
17239 title: __("Resend welcome email?"),
17240 message: sprintf(
17241 // translators: %s is a user name.
17242 __(
17243 "WordPress will resend the original welcome email to %s."
17244 ),
17245 row.name
17246 ),
17247 confirmLabel: __("Resend")
17248 });
17249 if (!ok) {
17250 return;
17251 }
17252 const result = await client.resendWelcome(row.id);
17253 if (result.ok) {
17254 notifyToast(
17255 sprintf(
17256 // translators: %s is the user's email address.
17257 __("Welcome email resent to %s."),
17258 result.email ?? row.email ?? ""
17259 ),
17260 { kind: "success" }
17261 );
17262 } else {
17263 notifyToast(
17264 sprintf(
17265 // translators: %s is an error code.
17266 __("Could not resend welcome (%s)."),
17267 result.error ?? "unknown"
17268 ),
17269 { kind: "error" }
17270 );
17271 }
17272 }
17273 )
17274 );
17275 return cell;
17276 }
17277 function buildColumns(cache, cfg, client) {
17278 const cols = [
17279 {
17280 key: "identity",
17281 label: __("Name"),
17282 sortable: false,
17283 sticky: true,
17284 minWidth: "260px",
17285 render: (_v, row) => memoUserCell(
17286 cache,
17287 row.id,
17288 "identity",
17289 () => buildIdentityCell(row, cfg)
17290 )
17291 },
17292 {
17293 key: "email",
17294 label: __("Email"),
17295 minWidth: "220px",
17296 render: (_v, row) => memoUserCell(cache, row.id, "email", () => buildEmailCell(row))
17297 },
17298 {
17299 key: "role",
17300 label: __("Role"),
17301 width: "180px",
17302 render: (_v, row) => memoUserCell(
17303 cache,
17304 row.id,
17305 "role",
17306 () => buildRoleCell(row, cfg)
17307 )
17308 },
17309 {
17310 key: "stats",
17311 label: __("Content"),
17312 width: "160px",
17313 sortValue: (row) => {
17314 const s = row.desktop_mode_user_stats;
17315 return s ? s.posts + s.pages + s.comments : 0;
17316 },
17317 render: (_v, row) => memoUserCell(cache, row.id, "stats", () => buildStatsCell(row))
17318 },
17319 {
17320 key: "last_login",
17321 label: __("Last login"),
17322 width: "140px",
17323 sortable: false,
17324 sortValue: (row) => typeof row.desktop_mode_last_login === "number" ? row.desktop_mode_last_login : 0,
17325 render: (_v, row) => memoUserCell(
17326 cache,
17327 row.id,
17328 "last_login",
17329 () => buildLastLoginCell(row)
17330 )
17331 },
17332 {
17333 key: "registered",
17334 label: __("Registered"),
17335 width: "140px",
17336 sortable: true,
17337 render: (_v, row) => memoUserCell(
17338 cache,
17339 row.id,
17340 "registered",
17341 () => buildRegisteredCell(row)
17342 )
17343 }
17344 ];
17345 if (cfg.canEdit === true) {
17346 cols.push({
17347 key: "actions",
17348 label: __("Actions"),
17349 width: "110px",
17350 sortable: false,
17351 render: (_v, row) => (
17352 // Actions cell is intentionally NOT memoized — its closure
17353 // captures `row` and the row payload changes between
17354 // fetches. Cheap to rebuild, fewer surprises.
17355 buildActionsCell(row, cfg, client)
17356 )
17357 });
17358 }
17359 return cols;
17360 }
17361 function defaultStatusSegments() {
17362 return [
17363 { value: "", label: __("All") },
17364 { value: "online", label: __("Online") },
17365 { value: "recent", label: __("Active 30d") },
17366 { value: "never", label: __("Never logged in") }
17367 ];
17368 }
17369 function applyClientStatusFilter(rows, status) {
17370 if (!status) {
17371 return rows;
17372 }
17373 if (status === "online") {
17374 return rows.filter((r) => r.desktop_mode_presence === "online");
17375 }
17376 if (status === "recent") {
17377 const now = Math.floor(Date.now() / 1e3);
17378 return rows.filter((r) => {
17379 const ts = r.desktop_mode_last_login;
17380 return typeof ts === "number" && ts > 0 && now - ts < 86400 * 30;
17381 });
17382 }
17383 if (status === "never") {
17384 return rows.filter(
17385 (r) => !r.desktop_mode_last_login || typeof r.desktop_mode_last_login !== "number"
17386 );
17387 }
17388 return rows;
17389 }
17390 async function renderUsersWindow(body, client) {
17391 const root = body.querySelector(ROOT);
17392 const table = body.querySelector(TABLE);
17393 if (!root || !table) {
17394 return;
17395 }
17396 table.addEventListener("wpd-table-row-click", (e) => {
17397 const detail = e.detail;
17398 const id = detail?.row?.id;
17399 if (typeof id !== "number" || id <= 0) {
17400 return;
17401 }
17402 void openUserEditWindow(id);
17403 });
17404 maybeShowUsersIntro(client);
17405 const cfg = client.getConfig();
17406 const view = {
17407 page: 1,
17408 perPage: Math.max(1, cfg.defaultPerPage || 20),
17409 search: "",
17410 status: "",
17411 orderby: "name",
17412 order: "asc",
17413 roles: [],
17414 searchDebounce: null
17415 };
17416 const cellCache = /* @__PURE__ */ new Map();
17417 table.columns = buildColumns(cellCache, cfg, client);
17418 table.getRowId = (row) => row.id;
17419 table.sort = { key: "name", direction: "asc" };
17420 if (!cfg.canEdit && !cfg.canPromote && !cfg.canDelete) {
17421 table.removeAttribute("selectable");
17422 }
17423 let totalPages = 0;
17424 let totalRows = 0;
17425 let refreshSeq = 0;
17426 const perPageEl = root.querySelector(PER_PAGE);
17427 if (perPageEl) {
17428 perPageEl.value = String(view.perPage);
17429 }
17430 const clearSelectionOnQueryChange = () => {
17431 table.clearSelection();
17432 };
17433 const indicator = root.querySelector(PAGE_INDICATOR);
17434 const prevBtn = root.querySelector(PREV);
17435 const nextBtn = root.querySelector(NEXT);
17436 const bulkBar = root.querySelector(BULK);
17437 const countEl = root.querySelector(COUNT);
17438 const bulkActionsHost = root.querySelector(BULK_ACTIONS_HOST);
17439 const statusHost = root.querySelector(STATUS);
17440 if (statusHost) {
17441 statusHost.replaceChildren();
17442 for (const seg of defaultStatusSegments()) {
17443 const el = document.createElement("wpd-segment");
17444 el.setAttribute("value", seg.value);
17445 el.textContent = seg.label;
17446 statusHost.appendChild(el);
17447 }
17448 statusHost.addEventListener("wpd-pick", (e) => {
17449 const detail = e.detail;
17450 view.status = detail?.value ?? "";
17451 view.page = 1;
17452 clearSelectionOnQueryChange();
17453 void refresh();
17454 });
17455 }
17456 const searchEl = root.querySelector(SEARCH);
17457 if (searchEl) {
17458 searchEl.addEventListener("input", () => {
17459 if (view.searchDebounce !== null) {
17460 clearTimeout(view.searchDebounce);
17461 }
17462 view.searchDebounce = window.setTimeout(() => {
17463 view.search = searchEl.value.trim();
17464 view.page = 1;
17465 clearSelectionOnQueryChange();
17466 void refresh();
17467 }, SEARCH_DEBOUNCE_MS);
17468 });
17469 }
17470 const refreshBtn = root.querySelector(REFRESH);
17471 refreshBtn?.addEventListener("click", () => {
17472 void refresh();
17473 });
17474 const newBtn = root.querySelector(NEW_BTN);
17475 if (newBtn) {
17476 if (!cfg.canCreate) {
17477 newBtn.style.display = "none";
17478 } else {
17479 newBtn.addEventListener("click", (e) => {
17480 e.preventDefault();
17481 const tabs = body.querySelector(
17482 "[data-desktop-mode-users-tabs]"
17483 );
17484 if (!tabs) {
17485 return;
17486 }
17487 tabs.value = "add-new";
17488 tabs.setAttribute("value", "add-new");
17489 });
17490 }
17491 }
17492 perPageEl?.addEventListener("change", () => {
17493 const n = parseInt(perPageEl.value, 10);
17494 if (Number.isFinite(n) && n > 0) {
17495 view.perPage = n;
17496 view.page = 1;
17497 clearSelectionOnQueryChange();
17498 void refresh();
17499 }
17500 });
17501 const renderBulkBar = () => {
17502 if (!bulkBar || !bulkActionsHost) {
17503 return;
17504 }
17505 const sel = table.selection;
17506 const ids = sel ? Array.from(sel) : [];
17507 if (ids.length === 0) {
17508 bulkBar.hidden = true;
17509 return;
17510 }
17511 bulkBar.hidden = false;
17512 if (countEl) {
17513 countEl.textContent = sprintf(
17514 // translators: %d is a count of selected users.
17515 __("%d selected"),
17516 ids.length
17517 );
17518 }
17519 bulkActionsHost.replaceChildren();
17520 const assignable = cfg.assignableRoles ?? {};
17521 const assignableKeys = Object.keys(assignable);
17522 if (cfg.canPromote && assignableKeys.length > 0) {
17523 const wrap = document.createElement("span");
17524 wrap.style.cssText = "display:inline-flex;align-items:center;gap:6px;";
17525 const roleDropdown = document.createElement("select");
17526 Object.assign(roleDropdown.style, {
17527 padding: "4px 8px",
17528 borderRadius: "4px",
17529 border: "1px solid var(--wp-admin-theme-border, #dcdcde)",
17530 background: "var(--wp-admin-theme-bg, #fff)",
17531 color: "inherit",
17532 font: "inherit",
17533 fontSize: "13px"
17534 });
17535 const placeholder = document.createElement("option");
17536 placeholder.value = "";
17537 placeholder.textContent = __("Set role to…");
17538 roleDropdown.appendChild(placeholder);
17539 for (const slug of assignableKeys) {
17540 const opt = document.createElement("option");
17541 opt.value = slug;
17542 opt.textContent = assignable[slug];
17543 roleDropdown.appendChild(opt);
17544 }
17545 const apply = document.createElement("wpd-button");
17546 apply.setAttribute("variant", "primary");
17547 apply.textContent = __("Apply");
17548 apply.addEventListener("click", async (e) => {
17549 e.preventDefault();
17550 const role = roleDropdown.value;
17551 if (!role) {
17552 return;
17553 }
17554 const targetIds = Array.from(
17555 table.selection ?? []
17556 ).map((id) => Number(id));
17557 if (targetIds.length === 0) {
17558 return;
17559 }
17560 const ok = await wpdConfirmGlobal({
17561 title: __("Change role for selected users?"),
17562 message: sprintf(
17563 // translators: %1$d is a user count, %2$s is a role label.
17564 __("Set %1$d user(s)' role to %2$s?"),
17565 targetIds.length,
17566 assignable[role]
17567 ),
17568 confirmLabel: __("Set role")
17569 });
17570 if (!ok) {
17571 return;
17572 }
17573 const out = await client.bulkSetRole(targetIds, role).catch((err) => {
17574 notifyToast(
17575 String(err.message ?? err),
17576 { kind: "error" }
17577 );
17578 return null;
17579 });
17580 if (!out) {
17581 return;
17582 }
17583 const successes = Object.values(out.results).filter(
17584 (r) => r.ok
17585 ).length;
17586 const failures = targetIds.length - successes;
17587 if (successes > 0) {
17588 notifyToast(
17589 sprintf(
17590 // translators: %1$d users updated, %2$d failed.
17591 __("Role updated for %1$d user(s) (%2$d skipped)."),
17592 successes,
17593 failures
17594 ),
17595 { kind: failures > 0 ? "info" : "success" }
17596 );
17597 } else {
17598 notifyToast(__("No users updated."), { kind: "error" });
17599 }
17600 table.clearSelection();
17601 void refresh();
17602 });
17603 wrap.appendChild(roleDropdown);
17604 wrap.appendChild(apply);
17605 bulkActionsHost.appendChild(wrap);
17606 }
17607 };
17608 table.addEventListener("wpd-table-selection-change", renderBulkBar);
17609 prevBtn?.addEventListener("click", () => {
17610 if (view.page > 1) {
17611 view.page -= 1;
17612 clearSelectionOnQueryChange();
17613 void refresh();
17614 }
17615 });
17616 nextBtn?.addEventListener("click", () => {
17617 if (view.page < totalPages) {
17618 view.page += 1;
17619 clearSelectionOnQueryChange();
17620 void refresh();
17621 }
17622 });
17623 const updatePager = () => {
17624 if (indicator) {
17625 indicator.textContent = sprintf(
17626 // translators: %1$d current page, %2$d total pages, %3$d total rows.
17627 __("Page %1$d of %2$d · %3$d users"),
17628 view.page,
17629 Math.max(1, totalPages),
17630 totalRows
17631 );
17632 }
17633 if (prevBtn) {
17634 prevBtn.disabled = view.page <= 1;
17635 }
17636 if (nextBtn) {
17637 nextBtn.disabled = view.page >= totalPages;
17638 }
17639 };
17640 const buildParams = () => {
17641 return {
17642 page: view.page,
17643 perPage: view.perPage,
17644 search: view.search || void 0,
17645 roles: view.roles.length > 0 ? view.roles : void 0,
17646 orderby: view.orderby,
17647 order: view.order
17648 };
17649 };
17650 const refresh = async () => {
17651 const mySeq = ++refreshSeq;
17652 table.toggleAttribute("loading", true);
17653 try {
17654 const result = await client.fetchUsers(buildParams());
17655 if (mySeq !== refreshSeq) {
17656 return;
17657 }
17658 if (result.items.length === 0 && view.page > 1 && result.totalPages > 0 && view.page > result.totalPages) {
17659 view.page = 1;
17660 await refresh();
17661 return;
17662 }
17663 cellCache.clear();
17664 const filtered = applyClientStatusFilter(result.items, view.status);
17665 table.data = filtered;
17666 totalRows = result.total;
17667 totalPages = result.totalPages;
17668 updatePager();
17669 renderBulkBar();
17670 } catch (err) {
17671 console.error("[users-window] fetch failed:", err);
17672 notifyToast(
17673 __("Could not load users. Try Refresh."),
17674 { kind: "error" }
17675 );
17676 } finally {
17677 table.toggleAttribute("loading", false);
17678 }
17679 };
17680 mountAddUserForm(body, client, cfg, {
17681 afterCreate: () => {
17682 const tabs = body.querySelector(
17683 "[data-desktop-mode-users-tabs]"
17684 );
17685 if (tabs) {
17686 tabs.value = "all";
17687 tabs.setAttribute("value", "all");
17688 }
17689 view.page = 1;
17690 void refresh();
17691 }
17692 });
17693 wireProfileSubTab(body, cfg);
17694 const patchUserRow = async (id) => {
17695 try {
17696 const updated = await client.fetchOneUser(id);
17697 const list = table.data;
17698 const idx = list.findIndex((r) => r.id === id);
17699 if (idx < 0) {
17700 return;
17701 }
17702 if (!updated) {
17703 const next2 = list.slice();
17704 next2.splice(idx, 1);
17705 table.data = next2;
17706 return;
17707 }
17708 for (const k of Array.from(cellCache.keys())) {
17709 if (k.startsWith(`${id}::`)) {
17710 cellCache.delete(k);
17711 }
17712 }
17713 const next = list.slice();
17714 next[idx] = updated;
17715 table.data = applyClientStatusFilter(next, view.status);
17716 } catch (err) {
17717 console.warn("[users-window] row patch failed, falling back to refresh", err);
17718 void refresh();
17719 }
17720 };
17721 const subscribeApi = window.wp?.desktop;
17722 const unsubscribe = subscribeApi?.subscribe?.(
17723 "desktop-mode.user.changed",
17724 (payload) => {
17725 const ids = payload?.ids;
17726 if (!Array.isArray(ids)) {
17727 return;
17728 }
17729 for (const raw of ids) {
17730 const id = typeof raw === "number" ? raw : Number(raw);
17731 if (Number.isFinite(id) && id > 0) {
17732 void patchUserRow(id);
17733 }
17734 }
17735 }
17736 );
17737 if (unsubscribe) {
17738 document.addEventListener(
17739 "desktop-mode-window-closed",
17740 (e) => {
17741 const detail = e.detail;
17742 if (detail?.windowId === "desktop-mode-users") {
17743 unsubscribe();
17744 }
17745 },
17746 { once: false }
17747 );
17748 }
17749 void refresh();
17750 }
17751 function wireProfileSubTab(body, cfg) {
17752 const profile = body.querySelector(
17753 "wpd-user-profile[data-wpd-user-profile-self]"
17754 );
17755 if (!profile) {
17756 return;
17757 }
17758 const viewerId = cfg.currentUserId;
17759 if (typeof viewerId === "number" && viewerId > 0) {
17760 profile.setAttribute("user-id", String(viewerId));
17761 }
17762 }
17763 function mountAddUserForm(body, client, cfg, opts) {
17764 const formNullable = body.querySelector(
17765 "[data-desktop-mode-users-add-form]"
17766 );
17767 if (!formNullable) {
17768 return;
17769 }
17770 const form = formNullable;
17771 const defaultRole = cfg.defaultRole ?? "subscriber";
17772 const assignableRoles = cfg.assignableRoles && Object.keys(cfg.assignableRoles).length > 0 ? cfg.assignableRoles : { [defaultRole]: defaultRole };
17773 mountSelect(form, "role", __("Role"), assignableRoles, defaultRole);
17774 mountSelect(
17775 form,
17776 "locale",
17777 __("Language"),
17778 cfg.locales ?? { "": __("Site default") },
17779 ""
17780 );
17781 const generateBtn = form.querySelector(
17782 '[data-action="generate-password"]'
17783 );
17784 generateBtn?.addEventListener("click", (e) => {
17785 e.preventDefault();
17786 e.stopPropagation();
17787 const pwd = generateStrongPassword(18);
17788 const pwdField = form.querySelector(
17789 'wpd-text-field[name="password"]'
17790 );
17791 if (pwdField) {
17792 pwdField.value = pwd;
17793 pwdField.setAttribute("value", pwd);
17794 }
17795 void navigator.clipboard?.writeText(pwd).catch(() => {
17796 });
17797 notifyToast(__("Generated password copied to clipboard."), {
17798 kind: "success"
17799 });
17800 });
17801 let pending = false;
17802 form.addEventListener("wpd-form-submit", (e) => {
17803 const detail = e.detail;
17804 void onSubmit(detail.values);
17805 });
17806 async function onSubmit(values) {
17807 if (pending) {
17808 return;
17809 }
17810 pending = true;
17811 form.setBusy(true);
17812 form.clearErrors();
17813 const payload = {
17814 username: String(values.username ?? "").trim(),
17815 email: String(values.email ?? "").trim(),
17816 first_name: optionalString(values.first_name),
17817 last_name: optionalString(values.last_name),
17818 url: optionalString(values.url),
17819 locale: String(values.locale ?? ""),
17820 password: optionalString(values.password),
17821 role: optionalString(values.role),
17822 send_notification: Boolean(values.send_notification)
17823 };
17824 const result = await client.createUser(payload);
17825 pending = false;
17826 form.setBusy(false);
17827 if (!result.ok) {
17828 handleCreateError(form, result.error, result.message, payload);
17829 return;
17830 }
17831 notifyToast(
17832 sprintf(
17833 // translators: %s is the user's email address.
17834 __("User created — welcome email sent to %s."),
17835 result.email ?? payload.email
17836 ),
17837 { kind: "success" }
17838 );
17839 opts.afterCreate();
17840 }
17841 }
17842 function mountSelect(form, name, _label, optionsMap, initialValue) {
17843 const select = form.querySelector(
17844 `wpd-select[name="${name}"]`
17845 );
17846 if (!select) {
17847 return;
17848 }
17849 const items = Object.entries(optionsMap).map(([value, label]) => ({
17850 value,
17851 label
17852 }));
17853 select.items = items;
17854 if (initialValue && optionsMap[initialValue] !== void 0) {
17855 select.value = initialValue;
17856 select.setAttribute("value", initialValue);
17857 }
17858 }
17859 function handleCreateError(form, code, message, payload) {
17860 let summary = message;
17861 if (!summary) {
17862 switch (code) {
17863 case "desktop_mode_users_username_exists":
17864 case "existing_user_login":
17865 summary = __("That username is already in use.");
17866 break;
17867 case "desktop_mode_users_email_exists":
17868 case "existing_user_email":
17869 summary = __("That email is already in use.");
17870 break;
17871 case "desktop_mode_users_username_invalid":
17872 summary = __("Username is not valid.");
17873 break;
17874 case "desktop_mode_users_email_invalid":
17875 summary = __("A valid email address is required.");
17876 break;
17877 case "desktop_mode_users_role_forbidden":
17878 summary = __("You are not allowed to assign that role.");
17879 break;
17880 default:
17881 summary = __("Could not create the user.");
17882 }
17883 }
17884 form.setError(summary);
17885 if (code === "desktop_mode_users_username_exists" || code === "existing_user_login" || code === "desktop_mode_users_username_invalid") {
17886 form.setFieldInvalid("username");
17887 }
17888 if (code === "desktop_mode_users_email_exists" || code === "existing_user_email" || code === "desktop_mode_users_email_invalid") {
17889 form.setFieldInvalid("email");
17890 }
17891 if (code === "desktop_mode_users_role_forbidden") {
17892 form.setFieldInvalid("role");
17893 }
17894 notifyToast(summary, { kind: "error" });
17895 console.warn("[users-window] create failed", { code, payload });
17896 }
17897 function optionalString(value) {
17898 if (typeof value !== "string") {
17899 return void 0;
17900 }
17901 const trimmed = value.trim();
17902 return trimmed === "" ? void 0 : trimmed;
17903 }
17904 function generateStrongPassword(length) {
17905 const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
17906 const lower = "abcdefghjkmnpqrstuvwxyz";
17907 const digits = "23456789";
17908 const symbols = "!@#$%^&*-_=+";
17909 const all = upper + lower + digits + symbols;
17910 const buf = new Uint32Array(length);
17911 crypto.getRandomValues(buf);
17912 let out = "";
17913 for (let i = 0; i < length; i += 1) {
17914 out += all[buf[i] % all.length];
17915 }
17916 return out;
17917 }
17918 const usersRender = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
17919 __proto__: null,
17920 renderUsersWindow
17921 }, Symbol.toStringTag, { value: "Module" }));
17922 exports.renderPostsWindow = renderPostsWindow;
17923 Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17924 return exports;
17925 }({});
17926