@profpowell/vb-project-planning 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/dist/vb-project-planning.css +69 -3
- package/dist/vb-project-planning.js +3191 -2544
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +221 -197
- package/package.json +7 -4
- package/src/lib/board-surface.js +158 -0
- package/src/lib/bundle-registry.js +2 -75
- package/src/lib/dates.js +125 -0
- package/src/lib/escape.js +34 -0
- package/src/lib/hash.js +16 -0
- package/src/lib/portrait-url.js +3 -29
- package/src/lib/src-loader.js +78 -0
- package/src/lib/vb-view-transition.js +7 -1
- package/src/utils/copy-init.js +56 -17
- package/src/web-components/_ux-base.js +409 -24
- package/src/web-components/adr-wc/README.md +18 -16
- package/src/web-components/adr-wc/api.json +7 -5
- package/src/web-components/adr-wc/logic.js +50 -136
- package/src/web-components/burndown-chart/api.json +8 -6
- package/src/web-components/burndown-chart/logic.js +75 -39
- package/src/web-components/empathy-map/api.json +13 -13
- package/src/web-components/empathy-map/logic.js +161 -168
- package/src/web-components/empathy-map/static.html +3 -3
- package/src/web-components/empathy-map/styles.js +1 -0
- package/src/web-components/gantt-chart/README.md +12 -6
- package/src/web-components/gantt-chart/api.json +23 -7
- package/src/web-components/gantt-chart/logic.js +456 -431
- package/src/web-components/gantt-chart/styles.css +14 -1
- package/src/web-components/impact-effort/api.json +9 -7
- package/src/web-components/impact-effort/logic.js +238 -209
- package/src/web-components/impact-effort/static.html +12 -4
- package/src/web-components/iron-triangle/_capacity.js +0 -18
- package/src/web-components/kanban-board/api.json +8 -6
- package/src/web-components/kanban-board/logic.js +289 -333
- package/src/web-components/product-roadmap/api.json +14 -13
- package/src/web-components/product-roadmap/logic.js +393 -246
- package/src/web-components/product-roadmap/styles.css +40 -0
- package/src/web-components/quadrant-grid/api.json +9 -4
- package/src/web-components/quadrant-grid/logic.js +80 -12
- package/src/web-components/quadrant-grid/styles.css +7 -0
- package/src/web-components/quality-target/_quality-utils.js +2 -46
- package/src/web-components/review-surface/README.md +9 -5
- package/src/web-components/review-surface/api.json +10 -8
- package/src/web-components/review-surface/logic.js +349 -218
- package/src/web-components/risk-register/api.json +1 -6
- package/src/web-components/risk-register/logic.js +71 -24
- package/src/web-components/risk-register/styles.css +9 -1
- package/src/web-components/story-map/api.json +8 -6
- package/src/web-components/story-map/logic.js +280 -281
- package/src/web-components/story-map/static.html +15 -5
- package/src/web-components/traceability-matrix/api.json +10 -3
- package/src/web-components/traceability-matrix/logic.js +112 -63
- package/src/web-components/traceability-matrix/styles.css +22 -1
- package/src/web-components/user-journey/api.json +25 -24
- package/src/web-components/user-journey/logic.js +76 -127
- package/src/web-components/user-journey/static.html +2 -2
- package/src/web-components/user-persona/api.json +15 -14
- package/src/web-components/user-persona/logic.js +71 -117
- package/src/web-components/user-persona/styles.js +1 -0
- package/src/web-components/user-story/api.json +13 -11
- package/src/web-components/user-story/logic.js +164 -195
- package/src/web-components/user-story/styles.js +28 -1
- package/src/web-components/work-item/README.md +3 -3
- package/src/web-components/work-item/api.json +13 -11
- package/src/web-components/work-item/logic.js +58 -154
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
import { styles } from './styles.js';
|
|
46
46
|
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
47
47
|
import { esc, initials, hashColor, lucideSvg, UX_ICONS } from '../_ux-base.js';
|
|
48
|
+
import { SrcLoader } from '../../lib/src-loader.js';
|
|
48
49
|
import { VBStore } from '../../lib/vb-store.js';
|
|
49
50
|
import { copyText } from '../../utils/copy-init.js';
|
|
50
51
|
|
|
@@ -63,11 +64,19 @@ class MemoryAdapter {
|
|
|
63
64
|
return pin;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Merge `changes` into the stored pin. Upserts: a pin this adapter has
|
|
69
|
+
* never seen (loaded via `src` or `.pins`) is stored from `pin` (or just
|
|
70
|
+
* its id) plus the changes, so resolving / replying always works.
|
|
71
|
+
* @param {string} id
|
|
72
|
+
* @param {object} changes
|
|
73
|
+
* @param {object} [pin] the component's full copy of the pin
|
|
74
|
+
*/
|
|
75
|
+
async update(id, changes, pin) {
|
|
76
|
+
const stored = this.#pins.get(id) ?? { ...(pin ?? {}), id };
|
|
77
|
+
Object.assign(stored, changes);
|
|
78
|
+
this.#pins.set(id, stored);
|
|
79
|
+
return stored;
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
async remove(id) {
|
|
@@ -103,10 +112,16 @@ class LocalStorageAdapter {
|
|
|
103
112
|
return pin;
|
|
104
113
|
}
|
|
105
114
|
|
|
106
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Merge `changes` into the stored pin; upserts like MemoryAdapter.update.
|
|
117
|
+
* @param {string} id
|
|
118
|
+
* @param {object} changes
|
|
119
|
+
* @param {object} [pin]
|
|
120
|
+
*/
|
|
121
|
+
async update(id, changes, pin) {
|
|
107
122
|
const pins = await this.#read();
|
|
108
|
-
|
|
109
|
-
if (idx === -1)
|
|
123
|
+
let idx = pins.findIndex(p => p.id === id);
|
|
124
|
+
if (idx === -1) idx = pins.push({ ...(pin ?? {}), id }) - 1;
|
|
110
125
|
Object.assign(pins[idx], changes);
|
|
111
126
|
await this.#write(pins);
|
|
112
127
|
return pins[idx];
|
|
@@ -163,16 +178,66 @@ class RestAdapter {
|
|
|
163
178
|
|
|
164
179
|
/* ── Component ─────────────────────────────────────── */
|
|
165
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Clamp a pin coordinate to a 0–100 percentage. Non-numeric input → 0.
|
|
183
|
+
* @param {unknown} v
|
|
184
|
+
* @returns {number}
|
|
185
|
+
*/
|
|
186
|
+
const pct = (v) => {
|
|
187
|
+
const n = Number(v);
|
|
188
|
+
return Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 0;
|
|
189
|
+
};
|
|
190
|
+
|
|
166
191
|
class ReviewSurface extends HTMLElement {
|
|
167
192
|
static get observedAttributes() {
|
|
168
193
|
return ['src', 'editable', 'adapter', 'endpoint', 'storage-key', 'author', 'compact', 'show-resolved'];
|
|
169
194
|
}
|
|
170
195
|
|
|
171
|
-
|
|
196
|
+
/** True after the first connect (adapter resolved, initial load started). */
|
|
197
|
+
#initialized = false;
|
|
198
|
+
/** True once the initial data (src / adapter / .pins) has settled. */
|
|
199
|
+
#loaded = false;
|
|
200
|
+
#readyFired = false;
|
|
201
|
+
/** Set when `.pins` is assigned; stops the first connect reloading from the adapter. */
|
|
202
|
+
#pinsAssigned = false;
|
|
203
|
+
/** Bumped by every load and every `.pins` / importPins() so stale adapter loads are dropped. */
|
|
204
|
+
#loadSeq = 0;
|
|
205
|
+
/** The src request; aborted by a newer src, src removal, `.pins`, importPins() or an adapter load. */
|
|
206
|
+
#loader = new SrcLoader();
|
|
207
|
+
|
|
208
|
+
/** @type {HTMLElement} */ #overlay;
|
|
209
|
+
/** @type {HTMLElement} */ #popoverHost;
|
|
210
|
+
/** @type {HTMLElement} */ #toolbarHost;
|
|
211
|
+
/** @type {HTMLElement} */ #status;
|
|
212
|
+
/** @type {HTMLElement} */ #live;
|
|
172
213
|
|
|
173
214
|
constructor() {
|
|
174
215
|
super();
|
|
175
|
-
this.attachShadow({ mode: 'open' });
|
|
216
|
+
const root = this.attachShadow({ mode: 'open' });
|
|
217
|
+
|
|
218
|
+
/* The skeleton is built once. _render() only refills the overlay,
|
|
219
|
+
popover and toolbar hosts, so the live region persists and focus
|
|
220
|
+
can be restored to the equivalent control after a re-render. */
|
|
221
|
+
root.innerHTML = `<style>${styles}</style>
|
|
222
|
+
<div class="rs-status state-msg" hidden></div>
|
|
223
|
+
<div class="rs-container">
|
|
224
|
+
<slot></slot>
|
|
225
|
+
<div class="rs-overlay" role="group" aria-label="Review pins"></div>
|
|
226
|
+
<div class="rs-popover-host" style="display:contents"></div>
|
|
227
|
+
</div>
|
|
228
|
+
<div class="rs-toolbar-host" style="display:contents"></div>
|
|
229
|
+
<div class="rs-live" role="status" aria-live="polite" aria-atomic="true"></div>`;
|
|
230
|
+
|
|
231
|
+
const q = (/** @type {string} */ sel) => /** @type {HTMLElement} */ (root.querySelector(sel));
|
|
232
|
+
this.#status = q('.rs-status');
|
|
233
|
+
this.#overlay = q('.rs-overlay');
|
|
234
|
+
this.#popoverHost = q('.rs-popover-host');
|
|
235
|
+
this.#toolbarHost = q('.rs-toolbar-host');
|
|
236
|
+
this.#live = q('.rs-live');
|
|
237
|
+
|
|
238
|
+
/* Delegated once — nodes inside the hosts are replaced on render. */
|
|
239
|
+
root.addEventListener('click', (e) => this.#onClick(e));
|
|
240
|
+
root.addEventListener('keydown', (e) => this.#onKeydown(/** @type {KeyboardEvent} */ (e)));
|
|
176
241
|
|
|
177
242
|
/** @type {import('./logic.js').Pin[]} */
|
|
178
243
|
this.__pins = [];
|
|
@@ -191,16 +256,21 @@ class ReviewSurface extends HTMLElement {
|
|
|
191
256
|
|
|
192
257
|
/**
|
|
193
258
|
* Replace the pin list and re-render. Idempotent — assigning the
|
|
194
|
-
* same array reference is a no-op.
|
|
195
|
-
*
|
|
196
|
-
*
|
|
259
|
+
* same array reference is a no-op. May be assigned before the element
|
|
260
|
+
* is connected: the first connect then keeps these pins instead of
|
|
261
|
+
* loading from the adapter. Emits review-surface:pins-changed with
|
|
262
|
+
* `source: 'property'`.
|
|
197
263
|
* @param {Object[]} data
|
|
198
264
|
*/
|
|
199
265
|
set pins(data) {
|
|
200
266
|
const next = Array.isArray(data) ? data : [];
|
|
201
267
|
if (this.__pins === next) return;
|
|
202
|
-
this.__pins = next;
|
|
203
|
-
|
|
268
|
+
this.__pins = /** @type {any} */ (next);
|
|
269
|
+
this.#pinsAssigned = true;
|
|
270
|
+
this.#loader.cancel();
|
|
271
|
+
this.#loadSeq++;
|
|
272
|
+
this.#loaded = true;
|
|
273
|
+
if (this.#initialized) this._render();
|
|
204
274
|
this.dispatchEvent(new CustomEvent('review-surface:pins-changed', {
|
|
205
275
|
detail: { pins: next, source: 'property' },
|
|
206
276
|
bubbles: true, composed: true,
|
|
@@ -210,33 +280,36 @@ class ReviewSurface extends HTMLElement {
|
|
|
210
280
|
/** @returns {MemoryAdapter|LocalStorageAdapter|RestAdapter|null} */
|
|
211
281
|
get adapter() { return this.__adapter; }
|
|
212
282
|
|
|
213
|
-
/**
|
|
283
|
+
/**
|
|
284
|
+
* Set a persistence adapter. Before connect it is simply kept (the
|
|
285
|
+
* first connect loads from it unless `.pins` was assigned); once
|
|
286
|
+
* connected, pins are reloaded from it.
|
|
287
|
+
* @param {Object} adapterInstance
|
|
288
|
+
*/
|
|
214
289
|
set adapter(adapterInstance) {
|
|
215
|
-
this.__adapter = adapterInstance;
|
|
216
|
-
if (this.isConnected) this._loadFromAdapter();
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/* ── Slot caching ───────────────────────────────── */
|
|
220
|
-
|
|
221
|
-
#cacheSlotValues() {
|
|
222
|
-
for (const child of this.children) {
|
|
223
|
-
const slot = child.getAttribute('slot');
|
|
224
|
-
if (slot) this.#slotCache.set(slot, child.textContent.trim());
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
_resolve(attr) {
|
|
229
|
-
return this.getAttribute(attr) || this.#slotCache.get(attr) || '';
|
|
290
|
+
this.__adapter = /** @type {any} */ (adapterInstance);
|
|
291
|
+
if (this.#initialized && this.isConnected) this._loadFromAdapter();
|
|
230
292
|
}
|
|
231
293
|
|
|
232
294
|
/* ── Lifecycle ─────────────────────────────────── */
|
|
233
295
|
|
|
234
296
|
connectedCallback() {
|
|
235
|
-
this.#cacheSlotValues();
|
|
236
297
|
this.setAttribute('data-upgraded', '');
|
|
237
|
-
this.#resolveAdapter();
|
|
238
298
|
|
|
239
|
-
|
|
299
|
+
/* Reconnect (e.g. the element was moved): keep pins and adapter. */
|
|
300
|
+
if (this.#initialized) {
|
|
301
|
+
this._render();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.#initialized = true;
|
|
305
|
+
if (!this.__adapter) this.#resolveAdapter();
|
|
306
|
+
|
|
307
|
+
this.dispatchEvent(new CustomEvent('review-surface:upgraded', { bubbles: true, composed: true }));
|
|
308
|
+
|
|
309
|
+
if (this.#pinsAssigned) {
|
|
310
|
+
this.#loaded = true;
|
|
311
|
+
this._render();
|
|
312
|
+
} else if (this.hasAttribute('src')) {
|
|
240
313
|
this._loadSrc(this.getAttribute('src'));
|
|
241
314
|
} else {
|
|
242
315
|
this._loadFromAdapter();
|
|
@@ -246,12 +319,15 @@ class ReviewSurface extends HTMLElement {
|
|
|
246
319
|
disconnectedCallback() {
|
|
247
320
|
this.removeAttribute('data-upgraded');
|
|
248
321
|
this.removeAttribute('data-annotating');
|
|
322
|
+
this._annotating = false;
|
|
249
323
|
}
|
|
250
324
|
|
|
251
325
|
attributeChangedCallback(name) {
|
|
252
|
-
if (!this.isConnected) return;
|
|
326
|
+
if (!this.#initialized || !this.isConnected) return;
|
|
253
327
|
if (name === 'src') {
|
|
254
|
-
this.
|
|
328
|
+
const src = this.getAttribute('src');
|
|
329
|
+
if (src) this._loadSrc(src);
|
|
330
|
+
else if (this.#loader.cancel()) this._loadFromAdapter(); // src removed mid-load
|
|
255
331
|
} else if (name === 'adapter' || name === 'endpoint' || name === 'storage-key') {
|
|
256
332
|
this.#resolveAdapter();
|
|
257
333
|
this._loadFromAdapter();
|
|
@@ -289,33 +365,57 @@ class ReviewSurface extends HTMLElement {
|
|
|
289
365
|
|
|
290
366
|
/* ── Data loading ──────────────────────────────── */
|
|
291
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Fetch pins from `src` (an array, or `{ pins }`). A newer src aborts the
|
|
370
|
+
* older request; `.pins`, importPins(), an adapter load or removing src
|
|
371
|
+
* abort it too.
|
|
372
|
+
* @param {string | null} src
|
|
373
|
+
*/
|
|
292
374
|
async _loadSrc(src) {
|
|
293
|
-
if (!src
|
|
294
|
-
this
|
|
295
|
-
|
|
375
|
+
if (!src) return;
|
|
376
|
+
this.#loadSeq++;
|
|
377
|
+
this.#showStatus('Loading…');
|
|
378
|
+
let result;
|
|
296
379
|
try {
|
|
297
|
-
|
|
298
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
299
|
-
const data = await res.json();
|
|
300
|
-
this.__pins = Array.isArray(data) ? data : data.pins || [];
|
|
301
|
-
this._render();
|
|
380
|
+
result = await this.#loader.fetchJson(src);
|
|
302
381
|
} catch (err) {
|
|
303
382
|
const msg = err instanceof Error ? err.message : String(err);
|
|
304
|
-
this
|
|
305
|
-
|
|
383
|
+
this.#showStatus(`Could not load pins: ${msg}`, true);
|
|
384
|
+
return;
|
|
306
385
|
}
|
|
386
|
+
if (!result) return;
|
|
387
|
+
const { data } = result;
|
|
388
|
+
this.__pins = Array.isArray(data) ? data : data?.pins || [];
|
|
389
|
+
this.#loaded = true;
|
|
390
|
+
this._render();
|
|
307
391
|
}
|
|
308
392
|
|
|
309
393
|
async _loadFromAdapter() {
|
|
310
394
|
if (!this.__adapter) return;
|
|
395
|
+
this.#loader.cancel();
|
|
396
|
+
const seq = ++this.#loadSeq;
|
|
397
|
+
let pins;
|
|
311
398
|
try {
|
|
312
|
-
|
|
399
|
+
pins = await this.__adapter.load();
|
|
313
400
|
} catch {
|
|
314
|
-
|
|
401
|
+
pins = [];
|
|
315
402
|
}
|
|
403
|
+
if (seq !== this.#loadSeq) return;
|
|
404
|
+
this.__pins = Array.isArray(pins) ? pins : [];
|
|
405
|
+
this.#loaded = true;
|
|
316
406
|
this._render();
|
|
317
407
|
}
|
|
318
408
|
|
|
409
|
+
/**
|
|
410
|
+
* @param {string} message
|
|
411
|
+
* @param {boolean} [error]
|
|
412
|
+
*/
|
|
413
|
+
#showStatus(message, error = false) {
|
|
414
|
+
this.#status.textContent = message;
|
|
415
|
+
this.#status.classList.toggle('state-msg--error', error);
|
|
416
|
+
this.#status.hidden = false;
|
|
417
|
+
}
|
|
418
|
+
|
|
319
419
|
/* ── Public API ────────────────────────────────── */
|
|
320
420
|
|
|
321
421
|
/**
|
|
@@ -326,8 +426,8 @@ class ReviewSurface extends HTMLElement {
|
|
|
326
426
|
async addPin(pinData) {
|
|
327
427
|
const pin = {
|
|
328
428
|
id: crypto.randomUUID(),
|
|
329
|
-
x:
|
|
330
|
-
y:
|
|
429
|
+
x: pct(pinData.x),
|
|
430
|
+
y: pct(pinData.y),
|
|
331
431
|
text: pinData.text || '',
|
|
332
432
|
author: pinData.author || this.getAttribute('author') || 'Anonymous',
|
|
333
433
|
createdAt: new Date().toISOString(),
|
|
@@ -389,7 +489,7 @@ class ReviewSurface extends HTMLElement {
|
|
|
389
489
|
};
|
|
390
490
|
|
|
391
491
|
if (this.__adapter) {
|
|
392
|
-
await this.__adapter.update(id, changes);
|
|
492
|
+
await this.__adapter.update(id, changes, pin);
|
|
393
493
|
}
|
|
394
494
|
Object.assign(pin, changes);
|
|
395
495
|
this._render();
|
|
@@ -412,7 +512,7 @@ class ReviewSurface extends HTMLElement {
|
|
|
412
512
|
const changes = { resolved: false, resolvedBy: null, resolvedAt: null };
|
|
413
513
|
|
|
414
514
|
if (this.__adapter) {
|
|
415
|
-
await this.__adapter.update(id, changes);
|
|
515
|
+
await this.__adapter.update(id, changes, pin);
|
|
416
516
|
}
|
|
417
517
|
Object.assign(pin, changes);
|
|
418
518
|
this._render();
|
|
@@ -438,6 +538,9 @@ class ReviewSurface extends HTMLElement {
|
|
|
438
538
|
*/
|
|
439
539
|
importPins(data) {
|
|
440
540
|
this.__pins = Array.isArray(data) ? structuredClone(data) : [];
|
|
541
|
+
this.#loader.cancel();
|
|
542
|
+
this.#loadSeq++;
|
|
543
|
+
this.#loaded = true;
|
|
441
544
|
this._activePin = null;
|
|
442
545
|
this._render();
|
|
443
546
|
}
|
|
@@ -452,41 +555,101 @@ class ReviewSurface extends HTMLElement {
|
|
|
452
555
|
}
|
|
453
556
|
|
|
454
557
|
_render() {
|
|
455
|
-
if (!this.shadowRoot) return;
|
|
456
558
|
const editable = this.hasAttribute('editable');
|
|
457
559
|
const visible = this.#visiblePins();
|
|
458
560
|
const activePin = this._activePin ? this.__pins.find(p => p.id === this._activePin) : null;
|
|
561
|
+
if (this._activePin && !activePin) this._activePin = null;
|
|
459
562
|
|
|
460
563
|
this.setAttribute('pin-count', String(visible.length));
|
|
461
564
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
565
|
+
const focus = this.#captureFocus();
|
|
566
|
+
|
|
567
|
+
this.#status.hidden = true;
|
|
568
|
+
this.#overlay.innerHTML = visible.map((pin, i) => this._renderPin(pin, i + 1)).join('');
|
|
569
|
+
this.#popoverHost.innerHTML = activePin ? this._renderPopover(activePin, editable) : '';
|
|
570
|
+
this.#toolbarHost.innerHTML = editable ? this._renderToolbar(visible.length) : '';
|
|
571
|
+
|
|
572
|
+
this.#restoreFocus(focus);
|
|
573
|
+
|
|
574
|
+
if (this.#loaded && !this.#readyFired) {
|
|
575
|
+
this.#readyFired = true;
|
|
576
|
+
this.dispatchEvent(new CustomEvent('review-surface:ready', {
|
|
577
|
+
bubbles: true, composed: true,
|
|
578
|
+
detail: { pinCount: visible.length },
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* ── Focus preservation ────────────────────────── */
|
|
472
584
|
|
|
473
|
-
|
|
585
|
+
/**
|
|
586
|
+
* Describe the focused shadow control so the equivalent node can be
|
|
587
|
+
* re-focused after its host is re-rendered.
|
|
588
|
+
* @returns {{ selector: string, value?: string, inPopover: boolean } | null}
|
|
589
|
+
*/
|
|
590
|
+
#captureFocus() {
|
|
591
|
+
const el = /** @type {HTMLElement | null} */ (this.shadowRoot?.activeElement ?? null);
|
|
592
|
+
if (!el) return null;
|
|
593
|
+
const inPopover = !!el.closest('.rs-popover');
|
|
594
|
+
let selector = '';
|
|
595
|
+
if (el.classList.contains('rs-pin')) selector = `.rs-pin[data-pin-id="${CSS.escape(el.dataset.pinId ?? '')}"]`;
|
|
596
|
+
else if (el.dataset.action) selector = `[data-action="${CSS.escape(el.dataset.action)}"]`;
|
|
597
|
+
else if (el.classList.contains('rs-input__field')) selector = '.rs-input__field';
|
|
598
|
+
else if (el.classList.contains('rs-popover')) selector = '.rs-popover';
|
|
599
|
+
const value = el instanceof HTMLTextAreaElement ? el.value : undefined;
|
|
600
|
+
return { selector, value, inPopover };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** @param {{ selector: string, value?: string, inPopover: boolean } | null} focus */
|
|
604
|
+
#restoreFocus(focus) {
|
|
605
|
+
if (!focus) return;
|
|
606
|
+
const root = /** @type {ShadowRoot} */ (this.shadowRoot);
|
|
607
|
+
let target = focus.selector ? /** @type {HTMLElement | null} */ (root.querySelector(focus.selector)) : null;
|
|
608
|
+
if (target instanceof HTMLTextAreaElement && focus.value !== undefined) target.value = focus.value;
|
|
609
|
+
/* The control is gone (e.g. Resolve swapped for Re-open): stay in the
|
|
610
|
+
popover if it is still open, else fall back to the active pin. */
|
|
611
|
+
if (!target && focus.inPopover) target = root.querySelector('.rs-popover');
|
|
612
|
+
if (!target) target = this.#pinButton(this._activePin) ?? root.querySelector('[data-action="toggle-mode"]');
|
|
613
|
+
target?.focus();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** @param {string|null|undefined} id */
|
|
617
|
+
#pinButton(id) {
|
|
618
|
+
if (!id) return null;
|
|
619
|
+
return /** @type {HTMLElement | null} */ (this.shadowRoot?.querySelector(`.rs-pin[data-pin-id="${CSS.escape(id)}"]`) ?? null);
|
|
620
|
+
}
|
|
474
621
|
|
|
475
|
-
|
|
622
|
+
/**
|
|
623
|
+
* Open a pin's popover and move focus into it.
|
|
624
|
+
* @param {string} id
|
|
625
|
+
*/
|
|
626
|
+
#openPin(id) {
|
|
627
|
+
this._activePin = id;
|
|
628
|
+
this._render();
|
|
629
|
+
/** @type {HTMLElement | null} */ (this.shadowRoot?.querySelector('.rs-popover') ?? null)?.focus();
|
|
630
|
+
this.dispatchEvent(new CustomEvent('review-surface:select', {
|
|
476
631
|
bubbles: true, composed: true,
|
|
477
|
-
detail: {
|
|
632
|
+
detail: { pin: this.__pins.find(p => p.id === id) },
|
|
478
633
|
}));
|
|
479
634
|
}
|
|
480
635
|
|
|
636
|
+
/** Close the popover and return focus to its pin. */
|
|
637
|
+
#closePin() {
|
|
638
|
+
const id = this._activePin;
|
|
639
|
+
this._activePin = null;
|
|
640
|
+
this._render();
|
|
641
|
+
this.#pinButton(id)?.focus();
|
|
642
|
+
}
|
|
643
|
+
|
|
481
644
|
/* ── Pin marker ────────────────────────────────── */
|
|
482
645
|
|
|
483
646
|
_renderPin(pin, number) {
|
|
484
|
-
const preview = pin.text ? pin.text.slice(0, 50) : 'Empty pin';
|
|
485
|
-
return `<button class="rs-pin"
|
|
647
|
+
const preview = pin.text ? String(pin.text).slice(0, 50) : 'Empty pin';
|
|
648
|
+
return `<button type="button" class="rs-pin"
|
|
486
649
|
data-pin-id="${esc(pin.id)}"
|
|
487
650
|
${pin.resolved ? 'data-resolved' : ''}
|
|
488
651
|
${this._activePin === pin.id ? 'data-active' : ''}
|
|
489
|
-
style="left:${pin.x}%;top:${pin.y}%"
|
|
652
|
+
style="left:${pct(pin.x)}%;top:${pct(pin.y)}%"
|
|
490
653
|
aria-label="Pin ${number}: ${esc(preview)}"
|
|
491
654
|
aria-expanded="${this._activePin === pin.id}"
|
|
492
655
|
aria-haspopup="dialog">
|
|
@@ -498,37 +661,38 @@ class ReviewSurface extends HTMLElement {
|
|
|
498
661
|
|
|
499
662
|
_renderPopover(pin, editable) {
|
|
500
663
|
const number = this.#visiblePins().findIndex(p => p.id === pin.id) + 1;
|
|
664
|
+
const label = number > 0 ? `Pin ${number}` : 'Pin';
|
|
501
665
|
|
|
502
|
-
return `<div class="rs-popover" data-open
|
|
503
|
-
style="left:${Math.min(pin.x, 70)}%;top:${pin.y}%"
|
|
666
|
+
return `<div class="rs-popover" data-open tabindex="-1"
|
|
667
|
+
style="left:${Math.min(pct(pin.x), 70)}%;top:${pct(pin.y)}%"
|
|
504
668
|
role="dialog"
|
|
505
669
|
aria-labelledby="rs-popover-title-${esc(pin.id)}">
|
|
506
670
|
|
|
507
671
|
<div class="rs-popover__header">
|
|
508
672
|
<span class="rs-popover__title" id="rs-popover-title-${esc(pin.id)}">
|
|
509
|
-
|
|
673
|
+
${label}
|
|
510
674
|
${pin.resolved ? `<span class="rs-resolved-badge">${lucideSvg(UX_ICONS.checkCircle)} Resolved</span>` : ''}
|
|
511
675
|
</span>
|
|
512
676
|
<div class="rs-popover__actions">
|
|
513
677
|
${editable && !pin.resolved ? `
|
|
514
|
-
<button class="rs-popover__btn rs-popover__btn--resolve" data-action="resolve" data-pin-id="${esc(pin.id)}"
|
|
678
|
+
<button type="button" class="rs-popover__btn rs-popover__btn--resolve" data-action="resolve" data-pin-id="${esc(pin.id)}"
|
|
515
679
|
aria-label="Resolve pin" title="Resolve">${lucideSvg(UX_ICONS.checkCircle)}</button>` : ''}
|
|
516
680
|
${editable && pin.resolved ? `
|
|
517
|
-
<button class="rs-popover__btn" data-action="unresolve" data-pin-id="${esc(pin.id)}"
|
|
681
|
+
<button type="button" class="rs-popover__btn" data-action="unresolve" data-pin-id="${esc(pin.id)}"
|
|
518
682
|
aria-label="Re-open pin" title="Re-open">${lucideSvg(UX_ICONS.messageCircle)}</button>` : ''}
|
|
519
683
|
${editable ? `
|
|
520
|
-
<button class="rs-popover__btn rs-popover__btn--delete" data-action="delete" data-pin-id="${esc(pin.id)}"
|
|
684
|
+
<button type="button" class="rs-popover__btn rs-popover__btn--delete" data-action="delete" data-pin-id="${esc(pin.id)}"
|
|
521
685
|
aria-label="Delete pin" title="Delete">${lucideSvg(UX_ICONS.x)}</button>` : ''}
|
|
522
|
-
<button class="rs-popover__btn" data-action="close"
|
|
686
|
+
<button type="button" class="rs-popover__btn" data-action="close"
|
|
523
687
|
aria-label="Close">${lucideSvg(UX_ICONS.x)}</button>
|
|
524
688
|
</div>
|
|
525
689
|
</div>
|
|
526
690
|
|
|
527
691
|
<div class="rs-comment">
|
|
528
692
|
<div class="rs-comment__meta">
|
|
529
|
-
<span class="rs-comment__avatar" style="background:${hashColor(pin.author || 'Anonymous')}">${initials(pin.author || 'Anonymous')}</span>
|
|
693
|
+
<span class="rs-comment__avatar" style="background:${hashColor(pin.author || 'Anonymous')}">${esc(initials(pin.author || 'Anonymous'))}</span>
|
|
530
694
|
<span class="rs-comment__author">${esc(pin.author || 'Anonymous')}</span>
|
|
531
|
-
<span class="rs-comment__time">${this.#formatTime(pin.createdAt)}</span>
|
|
695
|
+
<span class="rs-comment__time">${esc(this.#formatTime(pin.createdAt))}</span>
|
|
532
696
|
</div>
|
|
533
697
|
<div class="rs-comment__text">${esc(pin.text)}</div>
|
|
534
698
|
</div>
|
|
@@ -538,9 +702,9 @@ class ReviewSurface extends HTMLElement {
|
|
|
538
702
|
${pin.replies.map(reply => `
|
|
539
703
|
<div class="rs-reply">
|
|
540
704
|
<div class="rs-comment__meta">
|
|
541
|
-
<span class="rs-comment__avatar" style="background:${hashColor(reply.author || 'Anonymous')}">${initials(reply.author || 'Anonymous')}</span>
|
|
705
|
+
<span class="rs-comment__avatar" style="background:${hashColor(reply.author || 'Anonymous')}">${esc(initials(reply.author || 'Anonymous'))}</span>
|
|
542
706
|
<span class="rs-comment__author">${esc(reply.author || 'Anonymous')}</span>
|
|
543
|
-
<span class="rs-comment__time">${this.#formatTime(reply.createdAt)}</span>
|
|
707
|
+
<span class="rs-comment__time">${esc(this.#formatTime(reply.createdAt))}</span>
|
|
544
708
|
</div>
|
|
545
709
|
<div class="rs-comment__text">${esc(reply.text)}</div>
|
|
546
710
|
</div>
|
|
@@ -549,9 +713,9 @@ class ReviewSurface extends HTMLElement {
|
|
|
549
713
|
|
|
550
714
|
${editable ? `
|
|
551
715
|
<div class="rs-input">
|
|
552
|
-
<textarea class="rs-input__field" placeholder="Reply
|
|
553
|
-
aria-label="Reply to
|
|
554
|
-
<button class="rs-input__submit" data-action="reply" data-pin-id="${esc(pin.id)}"
|
|
716
|
+
<textarea class="rs-input__field" placeholder="Reply…" rows="1"
|
|
717
|
+
aria-label="Reply to ${label.toLowerCase()}"></textarea>
|
|
718
|
+
<button type="button" class="rs-input__submit" data-action="reply" data-pin-id="${esc(pin.id)}"
|
|
555
719
|
aria-label="Send reply">${lucideSvg(UX_ICONS.send)}</button>
|
|
556
720
|
</div>` : ''}
|
|
557
721
|
|
|
@@ -562,12 +726,12 @@ class ReviewSurface extends HTMLElement {
|
|
|
562
726
|
|
|
563
727
|
_renderToolbar(count) {
|
|
564
728
|
return `<div class="rs-toolbar" role="toolbar" aria-label="Review tools">
|
|
565
|
-
<button class="rs-toolbar__btn" data-action="toggle-mode"
|
|
729
|
+
<button type="button" class="rs-toolbar__btn" data-action="toggle-mode"
|
|
566
730
|
aria-pressed="${this._annotating}"
|
|
567
731
|
title="Toggle annotate mode">
|
|
568
732
|
${lucideSvg(UX_ICONS.mapPin)} Annotate
|
|
569
733
|
</button>
|
|
570
|
-
<button class="rs-toolbar__btn" data-action="export"
|
|
734
|
+
<button type="button" class="rs-toolbar__btn" data-action="export"
|
|
571
735
|
title="Export pins as JSON">
|
|
572
736
|
${lucideSvg(UX_ICONS.download)} Export
|
|
573
737
|
</button>
|
|
@@ -575,122 +739,97 @@ class ReviewSurface extends HTMLElement {
|
|
|
575
739
|
</div>`;
|
|
576
740
|
}
|
|
577
741
|
|
|
578
|
-
/* ── Event
|
|
742
|
+
/* ── Event handling (delegated on the shadow root) ── */
|
|
579
743
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
744
|
+
/** @param {Event} e */
|
|
745
|
+
#onClick(e) {
|
|
746
|
+
const target = e.target;
|
|
747
|
+
if (!(target instanceof Element)) return;
|
|
583
748
|
|
|
584
|
-
/* Pin clicks —
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
749
|
+
/* Pin clicks — toggle popover */
|
|
750
|
+
const pinBtn = /** @type {HTMLElement | null} */ (target.closest('.rs-pin'));
|
|
751
|
+
if (pinBtn) {
|
|
752
|
+
e.stopPropagation();
|
|
753
|
+
const id = pinBtn.dataset.pinId ?? null;
|
|
754
|
+
if (this._activePin === id) this.#closePin();
|
|
755
|
+
else if (id) this.#openPin(id);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/* Popover / toolbar actions */
|
|
760
|
+
const actionBtn = /** @type {HTMLElement | null} */ (target.closest('[data-action]'));
|
|
761
|
+
if (actionBtn) {
|
|
762
|
+
e.stopPropagation();
|
|
763
|
+
const pinId = actionBtn.dataset.pinId;
|
|
764
|
+
switch (actionBtn.dataset.action) {
|
|
765
|
+
case 'close':
|
|
766
|
+
this.#closePin();
|
|
767
|
+
break;
|
|
768
|
+
case 'resolve':
|
|
769
|
+
if (pinId) this.resolvePin(pinId);
|
|
770
|
+
break;
|
|
771
|
+
case 'unresolve':
|
|
772
|
+
if (pinId) this.unresolvePin(pinId);
|
|
773
|
+
break;
|
|
774
|
+
case 'delete':
|
|
775
|
+
if (pinId) this.removePin(pinId);
|
|
776
|
+
break;
|
|
777
|
+
case 'toggle-mode':
|
|
778
|
+
this.#setAnnotating(!this._annotating);
|
|
779
|
+
break;
|
|
780
|
+
case 'export':
|
|
781
|
+
this.#exportToClipboard();
|
|
782
|
+
break;
|
|
783
|
+
case 'reply':
|
|
784
|
+
if (pinId) this.#submitReply(pinId);
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
600
789
|
|
|
601
790
|
/* Overlay click — place new pin in annotate mode */
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
overlay.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
if (target?.closest('.rs-pin')) return;
|
|
609
|
-
|
|
610
|
-
const rect = overlay.getBoundingClientRect();
|
|
611
|
-
const x = ((me.clientX - rect.left) / rect.width) * 100;
|
|
612
|
-
const y = ((me.clientY - rect.top) / rect.height) * 100;
|
|
613
|
-
|
|
614
|
-
this.#promptNewPin(x, y);
|
|
615
|
-
});
|
|
791
|
+
if (target === this.#overlay && this._annotating && this.hasAttribute('editable')) {
|
|
792
|
+
const me = /** @type {MouseEvent} */ (e);
|
|
793
|
+
const rect = this.#overlay.getBoundingClientRect();
|
|
794
|
+
const x = ((me.clientX - rect.left) / rect.width) * 100;
|
|
795
|
+
const y = ((me.clientY - rect.top) / rect.height) * 100;
|
|
796
|
+
this.#promptNewPin(x, y);
|
|
616
797
|
}
|
|
798
|
+
}
|
|
617
799
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
e.stopPropagation();
|
|
622
|
-
const action = /** @type {HTMLElement} */ (btn).dataset.action;
|
|
623
|
-
const pinId = /** @type {HTMLElement} */ (btn).dataset.pinId;
|
|
624
|
-
|
|
625
|
-
switch (action) {
|
|
626
|
-
case 'close':
|
|
627
|
-
this._activePin = null;
|
|
628
|
-
this._render();
|
|
629
|
-
break;
|
|
630
|
-
case 'resolve':
|
|
631
|
-
if (pinId) this.resolvePin(pinId);
|
|
632
|
-
break;
|
|
633
|
-
case 'unresolve':
|
|
634
|
-
if (pinId) this.unresolvePin(pinId);
|
|
635
|
-
break;
|
|
636
|
-
case 'delete':
|
|
637
|
-
if (pinId) this.removePin(pinId);
|
|
638
|
-
break;
|
|
639
|
-
case 'toggle-mode':
|
|
640
|
-
this._annotating = !this._annotating;
|
|
641
|
-
if (this._annotating) {
|
|
642
|
-
this.setAttribute('data-annotating', '');
|
|
643
|
-
} else {
|
|
644
|
-
this.removeAttribute('data-annotating');
|
|
645
|
-
}
|
|
646
|
-
this._render();
|
|
647
|
-
this.dispatchEvent(new CustomEvent('review-surface:mode', {
|
|
648
|
-
bubbles: true, composed: true,
|
|
649
|
-
detail: { mode: this._annotating ? 'annotate' : 'view' },
|
|
650
|
-
}));
|
|
651
|
-
break;
|
|
652
|
-
case 'export':
|
|
653
|
-
this.#exportToClipboard();
|
|
654
|
-
break;
|
|
655
|
-
case 'reply':
|
|
656
|
-
this.#submitReply(pinId);
|
|
657
|
-
break;
|
|
658
|
-
}
|
|
659
|
-
});
|
|
660
|
-
});
|
|
800
|
+
/** @param {KeyboardEvent} e */
|
|
801
|
+
#onKeydown(e) {
|
|
802
|
+
const target = e.target;
|
|
661
803
|
|
|
662
804
|
/* Reply input — Enter to submit (Shift+Enter for newline) */
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
ke.preventDefault();
|
|
669
|
-
const replyBtn = /** @type {HTMLElement | null} */ (root.querySelector('[data-action="reply"]'));
|
|
670
|
-
const pinId = replyBtn?.dataset.pinId;
|
|
671
|
-
if (pinId) this.#submitReply(pinId);
|
|
672
|
-
}
|
|
673
|
-
});
|
|
805
|
+
if (e.key === 'Enter' && !e.shiftKey && target instanceof Element && target.matches('.rs-input__field')) {
|
|
806
|
+
e.preventDefault();
|
|
807
|
+
const pinId = /** @type {HTMLElement | null} */ (this.shadowRoot?.querySelector('[data-action="reply"]') ?? null)?.dataset.pinId;
|
|
808
|
+
if (pinId) this.#submitReply(pinId);
|
|
809
|
+
return;
|
|
674
810
|
}
|
|
675
811
|
|
|
676
|
-
/*
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
this._annotating = false;
|
|
685
|
-
this.removeAttribute('data-annotating');
|
|
686
|
-
this._render();
|
|
687
|
-
this.dispatchEvent(new CustomEvent('review-surface:mode', {
|
|
688
|
-
bubbles: true, composed: true,
|
|
689
|
-
detail: { mode: 'view' },
|
|
690
|
-
}));
|
|
691
|
-
}
|
|
812
|
+
/* Escape — close the popover (focus back to its pin), else exit annotate mode */
|
|
813
|
+
if (e.key === 'Escape') {
|
|
814
|
+
if (this._activePin) {
|
|
815
|
+
e.stopPropagation();
|
|
816
|
+
this.#closePin();
|
|
817
|
+
} else if (this._annotating) {
|
|
818
|
+
e.stopPropagation();
|
|
819
|
+
this.#setAnnotating(false);
|
|
692
820
|
}
|
|
693
|
-
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** @param {boolean} on */
|
|
825
|
+
#setAnnotating(on) {
|
|
826
|
+
this._annotating = on;
|
|
827
|
+
this.toggleAttribute('data-annotating', on);
|
|
828
|
+
this._render();
|
|
829
|
+
this.dispatchEvent(new CustomEvent('review-surface:mode', {
|
|
830
|
+
bubbles: true, composed: true,
|
|
831
|
+
detail: { mode: on ? 'annotate' : 'view' },
|
|
832
|
+
}));
|
|
694
833
|
}
|
|
695
834
|
|
|
696
835
|
/* ── Internal helpers ──────────────────────────── */
|
|
@@ -699,15 +838,10 @@ class ReviewSurface extends HTMLElement {
|
|
|
699
838
|
const author = this.getAttribute('author') || 'Anonymous';
|
|
700
839
|
const pin = await this.addPin({ x, y, text: '', author });
|
|
701
840
|
|
|
702
|
-
/* Open the new pin's popover
|
|
841
|
+
/* Open the new pin's popover and focus the reply field for text entry */
|
|
703
842
|
this._activePin = pin.id;
|
|
704
843
|
this._render();
|
|
705
|
-
|
|
706
|
-
/* Focus the reply field for immediate text entry */
|
|
707
|
-
requestAnimationFrame(() => {
|
|
708
|
-
const field = /** @type {HTMLTextAreaElement | null} */ (this.shadowRoot?.querySelector('.rs-input__field') ?? null);
|
|
709
|
-
field?.focus();
|
|
710
|
-
});
|
|
844
|
+
/** @type {HTMLElement | null} */ (this.shadowRoot?.querySelector('.rs-input__field') ?? null)?.focus();
|
|
711
845
|
}
|
|
712
846
|
|
|
713
847
|
async #submitReply(pinId) {
|
|
@@ -727,33 +861,25 @@ class ReviewSurface extends HTMLElement {
|
|
|
727
861
|
createdAt: new Date().toISOString(),
|
|
728
862
|
};
|
|
729
863
|
|
|
730
|
-
if (!pin.replies) pin.replies = [];
|
|
731
|
-
|
|
732
864
|
/* If pin has no text yet (just placed), promote first reply to pin text */
|
|
733
|
-
|
|
734
|
-
pin.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
pin.replies.push(reply);
|
|
740
|
-
if (this.__adapter) {
|
|
741
|
-
await this.__adapter.update(pinId, { replies: pin.replies });
|
|
742
|
-
}
|
|
865
|
+
const changes = pin.text
|
|
866
|
+
? { replies: [...(pin.replies ?? []), reply] }
|
|
867
|
+
: { text };
|
|
868
|
+
|
|
869
|
+
if (this.__adapter) {
|
|
870
|
+
await this.__adapter.update(pinId, changes, pin);
|
|
743
871
|
}
|
|
872
|
+
Object.assign(pin, changes);
|
|
873
|
+
if (!pin.replies) pin.replies = [];
|
|
744
874
|
|
|
875
|
+
field.value = '';
|
|
745
876
|
this._render();
|
|
746
877
|
this.#announce('Reply added');
|
|
747
|
-
|
|
748
|
-
/* Re-focus input */
|
|
749
|
-
requestAnimationFrame(() => {
|
|
750
|
-
const newField = /** @type {HTMLTextAreaElement | null} */ (this.shadowRoot?.querySelector('.rs-input__field') ?? null);
|
|
751
|
-
newField?.focus();
|
|
752
|
-
});
|
|
878
|
+
/** @type {HTMLElement | null} */ (this.shadowRoot?.querySelector('.rs-input__field') ?? null)?.focus();
|
|
753
879
|
|
|
754
880
|
this.dispatchEvent(new CustomEvent('review-surface:update', {
|
|
755
881
|
bubbles: true, composed: true,
|
|
756
|
-
detail: { pin, changes
|
|
882
|
+
detail: { pin, changes },
|
|
757
883
|
}));
|
|
758
884
|
}
|
|
759
885
|
|
|
@@ -777,6 +903,7 @@ class ReviewSurface extends HTMLElement {
|
|
|
777
903
|
if (!isoString) return '';
|
|
778
904
|
try {
|
|
779
905
|
const date = new Date(isoString);
|
|
906
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
780
907
|
const now = new Date();
|
|
781
908
|
const diffMs = now.getTime() - date.getTime();
|
|
782
909
|
const diffMins = Math.floor(diffMs / 60000);
|
|
@@ -796,9 +923,13 @@ class ReviewSurface extends HTMLElement {
|
|
|
796
923
|
}
|
|
797
924
|
}
|
|
798
925
|
|
|
926
|
+
/**
|
|
927
|
+
* Write to the persistent live region (never re-rendered, so the
|
|
928
|
+
* message survives the render that follows every change).
|
|
929
|
+
* @param {string} message
|
|
930
|
+
*/
|
|
799
931
|
#announce(message) {
|
|
800
|
-
|
|
801
|
-
if (live) live.textContent = message;
|
|
932
|
+
this.#live.textContent = message;
|
|
802
933
|
}
|
|
803
934
|
}
|
|
804
935
|
|