reviewzy 0.3.0 → 0.4.0

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.
@@ -1,52 +1,100 @@
1
1
  // reviewzy dashboard behaviors. Loads as a module after htmx.min.js.
2
- // The navbar's ink, its dock state and the table header label boxes come from ui.js;
3
- // initTables is re-run after every htmx swap so a freshly rendered table head gets its boxes.
4
- import { initTables } from "./ui.js";
2
+ // The sidebar's rail and drawer, the selects, the tabs, the theme toggle, the toasts and the
3
+ // table header label boxes come from ui.js; initTables, initTabs and initDismiss are re-run
4
+ // after every htmx swap so a freshly rendered table head gets its boxes and its selection state
5
+ // machine, the OOB tab strip gets its ink, and a banner the swap replaced out of band gets its
6
+ // dismiss control back.
7
+ import { initDismiss, initScrollFade, initTables, initTabs, initViewportWidth, showToast } from "./ui.js";
5
8
 
6
9
  (() => {
7
10
  "use strict";
8
11
 
9
- // --- Filter errors ---
10
- // A failed filter request (HTTP error or network error) replaces the list with the error box
11
- // from the page's #error-box template; the retry button re-submits the filter form.
12
- const form = document.getElementById("filters");
13
- const list = document.getElementById("entries-list");
14
- const errorTemplate = document.getElementById("error-box");
15
-
16
- function showFilterError() {
17
- if (!form || !list || !errorTemplate) return;
18
- const box = errorTemplate.content.cloneNode(true);
19
- const retry = box.querySelector("[data-retry]");
20
- if (retry) {
21
- retry.addEventListener("click", () => {
22
- // htmx listens for submit directly on the form it processed, so a native requestSubmit
23
- // reaches it; the reload fallback covers the case where htmx did not load.
24
- if (typeof form.requestSubmit === "function") {
25
- form.requestSubmit();
26
- } else {
27
- window.location.reload();
28
- }
29
- });
12
+ // The shell is a sidebar shell, so the full-bleed sticky bar measures the page column, not the
13
+ // viewport: --vp-w on the column keeps the stuck bar inside the page beside the sidebar.
14
+ initViewportWidth(document.querySelector("main.page") ?? undefined);
15
+
16
+ // --- Error toasts ---
17
+ // A failed htmx round (HTTP error or network error) raises a transient toast carrying the
18
+ // failure and a Retry button. The toast dies on its own timer; the durable way out is the
19
+ // control the failed request came from, which the failure never removes: the filters form
20
+ // re-renders the list, and the style guide form keeps the typed values.
21
+ function wireRetry(toast, retry) {
22
+ const body = toast.querySelector(".toast-body");
23
+ if (!body) return;
24
+ const button = document.createElement("button");
25
+ button.type = "button";
26
+ button.className = "btn btn-secondary btn-sm";
27
+ button.textContent = "Retry";
28
+ button.addEventListener("click", retry);
29
+ body.appendChild(button);
30
+ }
31
+
32
+ function raiseErrorToast(title, message, retry) {
33
+ const toast = showToast("danger", title, message);
34
+ if (toast) wireRetry(toast, retry);
35
+ }
36
+
37
+ // htmx listens for submit directly on the form it processed, so a native requestSubmit
38
+ // reaches it; the reload fallback covers the case where htmx did not load.
39
+ function submitForm(form) {
40
+ if (form && typeof form.requestSubmit === "function") {
41
+ form.requestSubmit();
42
+ } else {
43
+ window.location.reload();
30
44
  }
31
- list.replaceChildren(box);
32
45
  }
33
46
 
34
- if (form) {
35
- form.addEventListener("htmx:responseError", showFilterError);
36
- form.addEventListener("htmx:sendError", showFilterError);
47
+ const filtersForm = document.getElementById("filters");
48
+ if (filtersForm) {
49
+ const retryFilters = () => submitForm(document.getElementById("filters"));
50
+ const raiseFilterError = () => raiseErrorToast("The list could not be loaded", "The filter request failed. Try it again.", retryFilters);
51
+ filtersForm.addEventListener("htmx:responseError", raiseFilterError);
52
+ filtersForm.addEventListener("htmx:sendError", raiseFilterError);
53
+ }
54
+
55
+ // Delegated on document: a successful swap replaces the style guide form, so listeners bound
56
+ // to it would die. The event target names the form the failure came from.
57
+ document.addEventListener("htmx:responseError", (event) => {
58
+ if (!(event.target instanceof Element) || event.target.id !== "style-guide-form") return;
59
+ raiseErrorToast("The guide could not be saved", "The save request failed. Try it again.", () => submitForm(document.getElementById("style-guide-form")));
60
+ });
61
+ document.addEventListener("htmx:sendError", (event) => {
62
+ if (!(event.target instanceof Element) || event.target.id !== "style-guide-form") return;
63
+ raiseErrorToast("The guide could not be saved", "The save request failed. Try it again.", () => submitForm(document.getElementById("style-guide-form")));
64
+ });
65
+
66
+ // The editor's and the list's own failures (save, reject, notes, images, row and batch actions):
67
+ // htmx swaps nothing on an error, so without a toast the click reads as ignored. Retry re-fires
68
+ // the control that failed; the title names which surface failed.
69
+ for (const type of ["htmx:responseError", "htmx:sendError"]) {
70
+ document.addEventListener(type, (event) => {
71
+ const el = event.target instanceof Element ? event.target : null;
72
+ if (!el) return;
73
+ // A failed live diff is a preview miss, not a failed save: leave the marks and stay quiet.
74
+ if (el.id === "editor-text") return;
75
+ const inEditor = el.closest("#editor-view") !== null;
76
+ const inList = el.closest("#batch-form") !== null;
77
+ if (!inEditor && !inList) return;
78
+ const form = el.closest("form");
79
+ raiseErrorToast(
80
+ inEditor ? "The change was not saved" : "The action did not finish",
81
+ "The request failed. Try it again.",
82
+ () => {
83
+ if (el instanceof HTMLButtonElement) el.click();
84
+ else if (form) submitForm(form);
85
+ },
86
+ );
87
+ });
37
88
  }
38
89
 
39
90
  // --- Filter auto-submit ---
40
- // The Apply button is gone: changing a select or typing in the search fires the filter request
41
- // itself. requestSubmit() reaches htmx's submit listener on the form; the debounce collapses a
42
- // burst of keystrokes into one request. Only the change and input events below trigger a submit,
43
- // and both fire on user action alone, so a programmatic value reset (which sets .value directly
44
- // and emits no event) never re-fires.
91
+ // Changing a select or typing in the search fires the filter request itself. requestSubmit()
92
+ // reaches htmx's submit listener on the form; the debounce collapses a burst of keystrokes
93
+ // into one request. Only the change and input events below trigger a submit, and both fire on
94
+ // user action alone, so a programmatic value reset (which sets .value directly and emits no
95
+ // event) never re-fires.
45
96
  function submitFilters() {
46
- const target = document.getElementById("filters");
47
- if (target && typeof target.requestSubmit === "function") {
48
- target.requestSubmit();
49
- }
97
+ submitForm(document.getElementById("filters"));
50
98
  }
51
99
 
52
100
  let searchTimer;
@@ -55,59 +103,228 @@ import { initTables } from "./ui.js";
55
103
  searchTimer = setTimeout(submitFilters, 300);
56
104
  }
57
105
 
58
- if (form) {
59
- const statusSelect = document.getElementById("status");
60
- const projectSelect = document.getElementById("project");
106
+ // The hidden inputs ship disabled so a no-JS submit reads the noscript native selects instead
107
+ // of a stale hidden value; with the widgets live they carry the picked value.
108
+ for (const field of document.querySelectorAll('#filters input[data-filter-field]')) {
109
+ field.disabled = false;
110
+ }
111
+
112
+ // With the widget path live, the hidden status field is the only status the form sends. A submit
113
+ // button's own name would ride along otherwise, and on a submit with no submitter (requestSubmit,
114
+ // or a native fallback when htmx did not load) the form's default button adds `status=""` ahead
115
+ // of the hidden value, which hono reads first. No-JS keeps the names for its native path.
116
+ for (const button of document.querySelectorAll('#filters button[type="submit"]')) {
117
+ button.removeAttribute("name");
118
+ }
119
+
120
+ // ui.js's select controller commits the picked option onto the root before the change event;
121
+ // the option's own data-value holds what the form submits.
122
+ for (const root of document.querySelectorAll('#filters .select[data-filter]')) {
123
+ root.addEventListener("change", () => {
124
+ const field = root.dataset.filter ?? "";
125
+ const picked = root.querySelector('.menu-item[aria-selected="true"]');
126
+ const hidden = document.querySelector(`#filters input[data-filter-field="${field}"]`);
127
+ if (hidden && picked) {
128
+ hidden.value = picked.getAttribute("data-value") ?? "";
129
+ submitFilters();
130
+ }
131
+ });
132
+ }
133
+
134
+ if (filtersForm) {
61
135
  const searchInput = document.getElementById("q");
62
- statusSelect?.addEventListener("change", submitFilters);
63
- projectSelect?.addEventListener("change", submitFilters);
64
136
  searchInput?.addEventListener("input", scheduleSearchSubmit);
137
+ // Enter in the search field would submit through the first tab button (Open) and silently
138
+ // drop the chosen status; the debounced path already carries it from the hidden field, so
139
+ // Enter takes that path too.
140
+ searchInput?.addEventListener("keydown", (event) => {
141
+ if (event.key !== "Enter") return;
142
+ // An IME confirms a composition with Enter; that key belongs to the composition.
143
+ if (event.isComposing || event.keyCode === 229) return;
144
+ event.preventDefault();
145
+ clearTimeout(searchTimer);
146
+ submitFilters();
147
+ });
65
148
  }
66
149
 
67
- // --- Style guide save errors ---
68
- // A failed save (HTTP error or network error) puts the error box from the page's
69
- // #style-guide-error-box template above the form; the retry button re-submits the save form.
70
- // Delegated on document: a successful swap replaces the form element, so listeners bound to it
71
- // would die.
72
- const styleGuideErrorTemplate = document.getElementById("style-guide-error-box");
150
+ // A status tab submits the form itself: the button carries the new value, the hidden field
151
+ // mirrors it so every other submit path (the search debounce, the project select) keeps it, and
152
+ // the enhanced path prevents the native submit whose name would duplicate it. Without JS the
153
+ // buttons keep their names and the native path carries the status.
154
+ let restoreStatusTabFocus = false;
155
+ document.addEventListener("click", (event) => {
156
+ const target = event.target instanceof Element ? event.target : null;
157
+ const tab = target ? target.closest("#filters .tab") : null;
158
+ if (!tab) return;
159
+ event.preventDefault();
160
+ const hidden = document.querySelector('#filters input[data-filter-field="status"]');
161
+ if (hidden) hidden.value = tab.value;
162
+ restoreStatusTabFocus = true;
163
+ submitFilters();
164
+ });
73
165
 
74
- function showStyleGuideError(event) {
75
- if (!(event.target instanceof Element) || event.target.id !== "style-guide-form") return;
76
- const region = document.getElementById("style-guide-region");
77
- if (!region || !styleGuideErrorTemplate) return;
78
- // The box goes above the form, never replacing it: the retry re-submits the very form the
79
- // failed request came from, and a replacement would leave nothing to re-submit. A success or
80
- // refusal swap replaces the whole region and takes the box with it; a repeated error drops
81
- // the previous box first so the notices cannot stack.
82
- region.querySelector("[data-style-guide-error]")?.remove();
83
- const box = styleGuideErrorTemplate.content.cloneNode(true);
84
- box.querySelector(".callout")?.setAttribute("data-style-guide-error", "");
85
- const retry = box.querySelector("[data-retry]");
86
- if (retry) {
87
- retry.addEventListener("click", () => {
88
- // htmx listens for submit directly on the form it processed, so a native requestSubmit
89
- // reaches it; the reload fallback covers the case where htmx did not load.
90
- const target = document.getElementById("style-guide-form");
91
- if (target && typeof target.requestSubmit === "function") {
92
- target.requestSubmit();
93
- } else {
94
- window.location.reload();
95
- }
96
- });
166
+ // --- Swap focus and announcements ---
167
+ // An htmx swap replaces the node the user acted on, so focus falls to <body>; the tab strip's
168
+ // out-of-band replacement does the same on the page's primary control. Put focus back where
169
+ // the keyboard user left it, and announce the outcome through a persistent live region (an
170
+ // out-of-band banner arrives already populated, which a live region does not reliably read).
171
+ let editorFocusAfterSwap = null;
172
+ let listActionFocus = false;
173
+ let listActionRowHref = null;
174
+ document.addEventListener("htmx:beforeRequest", (event) => {
175
+ const el = event.target instanceof Element ? event.target : null;
176
+ if (!el) return;
177
+ // A row action or the batch submit is replaced by the list swap; the row the user acted on
178
+ // (or the outcome banner) is where their focus belongs afterwards.
179
+ if (el.closest("#batch-form")) {
180
+ listActionFocus = true;
181
+ listActionRowHref = el.closest("tr")?.querySelector('a[href^="/entries/"]')?.getAttribute("href") ?? null;
182
+ }
183
+ // The live diff is the textarea's own request: it swaps the current-text card, and focus and
184
+ // announcements stay exactly where the typist left them.
185
+ if (el.id === "editor-text") return;
186
+ if (!el.closest("#editor-view")) return;
187
+ // Any real editor action aborts a pending preview, or its late response would overwrite the
188
+ // freshly saved marks with ones computed from older text.
189
+ if (typeof htmx !== "undefined") {
190
+ const pending = document.getElementById("editor-text");
191
+ if (pending) htmx.trigger(pending, "htmx:abort");
97
192
  }
98
- region.prepend(box);
193
+ if (el.id === "editor-form") editorFocusAfterSwap = "save";
194
+ else if (el.id === "notes-form") editorFocusAfterSwap = "notes";
195
+ else if (el.closest("#image-upload") !== null || el.closest('form[action*="/images"]') !== null) editorFocusAfterSwap = "image";
196
+ else editorFocusAfterSwap = "transition";
197
+ });
198
+
199
+ function announceListOutcome() {
200
+ const live = document.getElementById("list-live");
201
+ if (!live) return;
202
+ const banner = document.querySelector("#page-banner .banner");
203
+ const meta = document.querySelector("#list-meta .pagination-count");
204
+ const message = [banner?.textContent?.trim(), meta?.textContent?.trim()].filter(Boolean).join(". ");
205
+ if (message !== "" && message !== live.textContent) live.textContent = message;
99
206
  }
100
207
 
101
- document.addEventListener("htmx:responseError", showStyleGuideError);
102
- document.addEventListener("htmx:sendError", showStyleGuideError);
208
+ function announceEditorOutcome(action) {
209
+ const live = document.getElementById("editor-live");
210
+ if (!live) return;
211
+ // Only a refusal speaks to the outcome; the read-only state callout is a standing fact.
212
+ const refusal = document.querySelector("#editor-view .callout-danger");
213
+ if (refusal) {
214
+ const title = refusal.querySelector(".callout-title")?.textContent?.trim();
215
+ const body = refusal.querySelector(".callout-body")?.textContent?.trim();
216
+ live.textContent = [title, body].filter(Boolean).join(". ");
217
+ return;
218
+ }
219
+ if (action === "notes") {
220
+ live.textContent = "Notes saved.";
221
+ return;
222
+ }
223
+ if (action === "image") {
224
+ live.textContent = "Image updated.";
225
+ return;
226
+ }
227
+ const status = document.querySelector("#entry-status .tag")?.textContent?.trim();
228
+ if (status) live.textContent = `Entry is now ${status}.`;
229
+ }
230
+
231
+ // Keep the current tab inside its scroller on a deep link or after an out-of-band swap.
232
+ function revealActiveTab() {
233
+ for (const wrap of document.querySelectorAll(".status-scroll, .style-guide-projects")) {
234
+ const active = wrap.querySelector(".tab.active");
235
+ if (!active) continue;
236
+ const box = wrap.getBoundingClientRect();
237
+ const tab = active.getBoundingClientRect();
238
+ if (tab.left < box.left || tab.right > box.right) {
239
+ wrap.scrollLeft += tab.left - box.left - (box.width - tab.width) / 2;
240
+ }
241
+ }
242
+ }
243
+
244
+ // The live diff re-renders the current-text card on every pause; keep the reader's place in it.
245
+ let currentPaneScroll = 0;
246
+ document.addEventListener("htmx:beforeSwap", (event) => {
247
+ const target = event.target instanceof Element ? event.target : null;
248
+ if (target?.id !== "current-text") return;
249
+ currentPaneScroll = target.querySelector(".editor-pane")?.scrollTop ?? 0;
250
+ });
251
+
252
+ document.addEventListener("htmx:afterSwap", (event) => {
253
+ // The live diff swaps only the current-text card; editor focus and announcements belong to the
254
+ // view and region swaps, so a preview must not consume a pending round's flag.
255
+ const swapTarget = event.target instanceof Element ? event.target : null;
256
+ const editorSwap = swapTarget !== null && (swapTarget.id === "editor-view" || swapTarget.id === "editor-region");
257
+ if (swapTarget?.id === "current-text") {
258
+ const pane = document.querySelector("#current-text .editor-pane");
259
+ if (pane) pane.scrollTop = currentPaneScroll;
260
+ }
261
+ if (restoreStatusTabFocus) {
262
+ restoreStatusTabFocus = false;
263
+ const active = document.querySelector("#status-tabs .tab.active") ?? document.querySelector("#status-tabs .tab");
264
+ active?.focus();
265
+ }
266
+ if (listActionFocus) {
267
+ listActionFocus = false;
268
+ const rowLink = listActionRowHref === null ? null : document.querySelector(`#entries-list a[href="${listActionRowHref}"]`);
269
+ listActionRowHref = null;
270
+ const banner = document.querySelector("#page-banner .banner");
271
+ const firstLink = document.querySelector("#entries-list .entry-link");
272
+ (rowLink ?? banner ?? firstLink)?.focus();
273
+ }
274
+ if (editorFocusAfterSwap !== null && editorSwap) {
275
+ const refused = document.querySelector("#editor-view .callout-danger") !== null;
276
+ const target =
277
+ editorFocusAfterSwap === "save"
278
+ ? refused
279
+ ? // A refusal names a fix in the text the user must edit; the caret goes back there.
280
+ document.querySelector("#editor-text")
281
+ : document.querySelector('#editor-form button[type="submit"]')
282
+ : editorFocusAfterSwap === "notes"
283
+ ? document.querySelector('#notes-form button[type="submit"]')
284
+ : editorFocusAfterSwap === "image"
285
+ ? document.querySelector("#image-upload button")
286
+ : document.querySelector("#editor-region");
287
+ target?.focus();
288
+ const action = editorFocusAfterSwap;
289
+ editorFocusAfterSwap = null;
290
+ announceEditorOutcome(action);
291
+ }
292
+ announceListOutcome();
293
+ revealActiveTab();
294
+ });
295
+
296
+ // A failed round swaps nothing, so a focus flag set for it must not steer the next, unrelated
297
+ // swap's focus.
298
+ for (const type of ["htmx:responseError", "htmx:sendError"]) {
299
+ document.addEventListener(type, () => {
300
+ restoreStatusTabFocus = false;
301
+ listActionFocus = false;
302
+ listActionRowHref = null;
303
+ editorFocusAfterSwap = null;
304
+ });
305
+ }
306
+
307
+ // --- Textareas ---
308
+ // Every data-autosize field grows to its content, capped at 60% of the viewport so the actions
309
+ // below a long text never leave the screen; only a genuinely long box scrolls. The page's CSS
310
+ // sets the resting min-height per field and the autosizer's inline height rides over it.
311
+ function autosizeTextareas() {
312
+ const cap = Math.round(window.innerHeight * 0.6);
313
+ for (const textarea of document.querySelectorAll("textarea[data-autosize]")) {
314
+ textarea.style.height = "auto";
315
+ textarea.style.height = `${Math.min(textarea.scrollHeight + 2, cap)}px`;
316
+ textarea.style.overflowY = textarea.scrollHeight + 2 > cap ? "auto" : "hidden";
317
+ }
318
+ }
103
319
 
104
320
  // --- Editor live metrics ---
105
- // The constraint panel lives outside the htmx swap region, so the char count and the placeholder
106
- // checklist track the textarea as the user types. This is a convenience preview only: the server
107
- // store layer owns the refusal, and the panel re-syncs on any swap.
321
+ // The constraint row lives inside the htmx swap region now, so it re-renders with the swap;
322
+ // between swaps the char count and the placeholder checklist track the textarea as the user
323
+ // types. This is a convenience preview only: the server store layer owns the refusal.
108
324
  function refreshEditorMetrics() {
109
325
  const textarea = document.getElementById("editor-text");
110
326
  if (!textarea) return;
327
+ autosizeTextareas();
111
328
  const announcements = [];
112
329
 
113
330
  const charCount = document.getElementById("char-count");
@@ -136,7 +353,9 @@ import { initTables } from "./ui.js";
136
353
  state.textContent = present ? "Present" : "Missing";
137
354
  state.classList.toggle("is-present", present);
138
355
  if (present !== wasPresent) {
139
- announcements.push(`Placeholder {${placeholder}} ${present ? "present" : "missing"}`);
356
+ // The filed token may already carry its braces; the announcement shows one pair.
357
+ const bare = placeholder.replace(/^\{+|\}+$/g, "");
358
+ announcements.push(`Placeholder {${bare}} ${present ? "present" : "missing"}`);
140
359
  }
141
360
  }
142
361
  }
@@ -149,21 +368,28 @@ import { initTables } from "./ui.js";
149
368
  }
150
369
 
151
370
  document.addEventListener("input", (event) => {
152
- if (event.target instanceof HTMLTextAreaElement && event.target.id === "editor-text") {
371
+ const target = event.target;
372
+ if (!(target instanceof HTMLTextAreaElement)) return;
373
+ if (target.id === "editor-text") {
153
374
  refreshEditorMetrics();
375
+ } else if (target.matches("textarea[data-autosize]")) {
376
+ autosizeTextareas();
154
377
  }
155
378
  });
156
379
 
157
380
  // A save swaps the editor region in; the textarea survives via hx-preserve, so one pass after
158
- // the swap keeps the panel in sync with the value the server kept.
381
+ // the swap keeps the panel and every autosized box in sync with what the server kept.
159
382
  document.addEventListener("htmx:afterSwap", refreshEditorMetrics);
383
+ document.addEventListener("htmx:afterSwap", autosizeTextareas);
384
+ // The cap is a share of the current viewport, so a resize or rotation re-derives it.
385
+ addEventListener("resize", autosizeTextareas);
160
386
 
161
387
  // "Use" on a revision loads its text into the editor and re-syncs the panel, ready to save.
162
388
  document.addEventListener("click", (event) => {
163
389
  const target = event.target instanceof Element ? event.target : null;
164
390
  const useButton = target ? target.closest("[data-use-revision]") : null;
165
391
  if (!useButton) return;
166
- const textNode = useButton.closest(".revision")?.querySelector(".revision-text");
392
+ const textNode = useButton.closest(".timeline-item")?.querySelector(".timeline-body");
167
393
  const textarea = document.getElementById("editor-text");
168
394
  if (!textNode || !textarea) return;
169
395
  textarea.value = textNode.textContent ?? "";
@@ -171,117 +397,36 @@ import { initTables } from "./ui.js";
171
397
  textarea.focus();
172
398
  });
173
399
 
174
- // --- Batch select-all and live selection count ---
175
- // The list region is swapped wholesale on every filter and action round, so no listener bound to
176
- // a checkbox or the button survives a swap: change events are delegated on document, and one pass
177
- // after each swap recomputes the select-all states and the approve button's count. All of it is
178
- // progressive enhancement — without JS the approve button keeps its static label, stays enabled,
179
- // and the native form still submits the checked rows.
180
- const DRAFT_SELECTOR = 'input[type="checkbox"][name="id"]';
181
- const SELECT_ALL_SELECTOR = 'input[type="checkbox"].select-all';
182
-
183
- function selectionCount() {
184
- return document.querySelectorAll(`#batch-form ${DRAFT_SELECTOR}:checked`).length;
185
- }
186
-
400
+ // --- Batch selection count ---
401
+ // The boxes and the select-all state machine belong to ui.js's initTables (re-run after every
402
+ // swap); this page only renders the count onto the approve button. It ships enabled so the no-JS
403
+ // form can fire it; the boot call below disables it at first paint and each selection event
404
+ // re-syncs it, so an empty selection reads as dead with JS.
187
405
  function updateApproveButton() {
188
406
  const button = document.querySelector('#batch-form .batch-bar button[type="submit"]');
189
407
  if (!button) return;
190
- const count = selectionCount();
408
+ const count = document.querySelectorAll('#batch-form input[type="checkbox"][name="id"]:checked').length;
191
409
  button.textContent = count === 0 ? "Approve selected" : `Approve ${count}`;
192
410
  button.disabled = count === 0;
193
411
  }
194
412
 
195
- function updateSelectAll(selectAll) {
196
- const batch = selectAll.closest(".batch");
197
- if (!batch) return;
198
- const drafts = batch.querySelectorAll(DRAFT_SELECTOR);
199
- const checked = batch.querySelectorAll(`${DRAFT_SELECTOR}:checked`).length;
200
- selectAll.checked = drafts.length > 0 && checked === drafts.length;
201
- selectAll.indeterminate = checked > 0 && checked < drafts.length;
202
- }
203
-
204
- function refreshSelection() {
205
- for (const selectAll of document.querySelectorAll(SELECT_ALL_SELECTOR)) {
206
- updateSelectAll(selectAll);
207
- }
208
- updateApproveButton();
209
- }
413
+ document.addEventListener("table:selection", updateApproveButton);
414
+ document.addEventListener("htmx:afterSwap", updateApproveButton);
210
415
 
211
- document.addEventListener("change", (event) => {
212
- const target = event.target instanceof Element ? event.target : null;
213
- if (!target) return;
214
- if (target.matches(SELECT_ALL_SELECTOR)) {
215
- const batch = target.closest(".batch");
216
- if (batch) {
217
- for (const draft of batch.querySelectorAll(DRAFT_SELECTOR)) {
218
- draft.checked = target.checked;
219
- }
220
- }
221
- }
222
- refreshSelection();
223
- });
224
-
225
- document.addEventListener("htmx:afterSwap", refreshSelection);
226
-
227
- // --- Collapsible project sections and batches ---
228
- // The server renders every section and batch open; JS collapses the ones this viewer collapsed
229
- // before, from localStorage, and persists each toggle. Re-applied after every swap because a
230
- // filter or action round replaces the whole list region. Without JS the toggles stay inert and
231
- // everything stays open.
232
- const COLLAPSE_STORAGE = "reviewzy.list-collapsed";
233
-
234
- function readCollapsed() {
235
- try {
236
- const raw = localStorage.getItem(COLLAPSE_STORAGE);
237
- const parsed = raw ? JSON.parse(raw) : {};
238
- // A parsed array is not a key map: property sets on it would survive only until the next
239
- // JSON.stringify, which drops them, so persistence would silently stop.
240
- return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
241
- } catch {
242
- return {};
243
- }
244
- }
245
-
246
- function persistCollapsed(key, collapsed) {
247
- const map = readCollapsed();
248
- if (collapsed) {
249
- map[key] = true;
250
- } else {
251
- delete map[key];
252
- }
253
- try {
254
- localStorage.setItem(COLLAPSE_STORAGE, JSON.stringify(map));
255
- } catch {
256
- // Storage can be unavailable (private windows); the toggle still works for the page.
257
- }
258
- }
259
-
260
- function applyCollapseState() {
261
- const collapsed = readCollapsed();
262
- for (const toggle of document.querySelectorAll("[data-collapse-toggle]")) {
263
- const key = toggle.dataset.collapseKey ?? "";
264
- const target = document.getElementById(toggle.dataset.collapseTarget ?? "");
265
- if (!target) continue;
266
- const hidden = collapsed[key] === true;
267
- target.hidden = hidden;
268
- toggle.setAttribute("aria-expanded", String(!hidden));
269
- }
270
- }
271
-
272
- document.addEventListener("click", (event) => {
273
- const target = event.target instanceof Element ? event.target : null;
274
- const toggle = target ? target.closest("[data-collapse-toggle]") : null;
275
- if (!toggle) return;
276
- const el = document.getElementById(toggle.dataset.collapseTarget ?? "");
277
- if (!el) return;
278
- const hide = !el.hidden;
279
- el.hidden = hide;
280
- toggle.setAttribute("aria-expanded", String(!hide));
281
- persistCollapsed(toggle.dataset.collapseKey ?? "", hide);
282
- });
283
-
284
- applyCollapseState();
285
- document.addEventListener("htmx:afterSwap", applyCollapseState);
286
416
  document.addEventListener("htmx:afterSwap", initTables);
417
+ // The OOB swap replaces the status tab strip: the fresh row needs its ink placed.
418
+ document.addEventListener("htmx:afterSwap", initTabs);
419
+ // A swapped list brings fresh table wraps and textareas; the cue controller skips the ones it
420
+ // has already dressed.
421
+ document.addEventListener("htmx:afterSwap", initScrollFade);
422
+ // The list fragment replaces the page banner out of band; a banner the swap brought in needs
423
+ // its dismiss control wired, since ui.js's one sweep ran at boot.
424
+ document.addEventListener("htmx:afterSwap", initDismiss);
425
+
426
+ // First paint: the boxes take their content's height, the metrics row reads the prefill, and
427
+ // the batch button reflects the (empty) selection before the viewer touches anything.
428
+ autosizeTextareas();
429
+ refreshEditorMetrics();
430
+ updateApproveButton();
431
+ revealActiveTab();
287
432
  })();