reviewzy 0.2.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,76 +1,100 @@
1
- // reviewzy dashboard behaviors. Load after htmx.min.js.
1
+ // reviewzy dashboard behaviors. Loads as a module after htmx.min.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";
8
+
2
9
  (() => {
3
10
  "use strict";
4
11
 
5
- // --- Navbar ink (cloudy-ui navbar component) ---
6
- // Positions the sliding underline under the active link; re-positions on resize without animating.
7
- const navbarNav = document.getElementById("navbar-nav");
8
- const navbarInk = document.getElementById("navbar-ink");
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
+ }
9
31
 
10
- function positionNavbarInk() {
11
- if (!navbarNav || !navbarInk) return;
12
- const active = navbarNav.querySelector(".navbar-link.active");
13
- if (!active) {
14
- navbarInk.style.opacity = "0";
15
- return;
16
- }
17
- const navRect = navbarNav.getBoundingClientRect();
18
- const linkRect = active.getBoundingClientRect();
19
- navbarInk.style.left = `${linkRect.left - navRect.left}px`;
20
- navbarInk.style.width = `${linkRect.width}px`;
21
- navbarInk.style.opacity = "1";
32
+ function raiseErrorToast(title, message, retry) {
33
+ const toast = showToast("danger", title, message);
34
+ if (toast) wireRetry(toast, retry);
22
35
  }
23
36
 
24
- window.addEventListener("resize", () => {
25
- navbarInk.style.transition = "none";
26
- positionNavbarInk();
27
- requestAnimationFrame(() => {
28
- navbarInk.style.transition = "";
29
- });
30
- });
31
- requestAnimationFrame(positionNavbarInk);
32
-
33
- // --- Filter errors ---
34
- // A failed filter request (HTTP error or network error) replaces the list with the error box
35
- // from the page's #error-box template; the retry button re-submits the filter form.
36
- const form = document.getElementById("filters");
37
- const list = document.getElementById("entries-list");
38
- const errorTemplate = document.getElementById("error-box");
39
-
40
- function showFilterError() {
41
- if (!form || !list || !errorTemplate) return;
42
- const box = errorTemplate.content.cloneNode(true);
43
- const retry = box.querySelector("[data-retry]");
44
- if (retry) {
45
- retry.addEventListener("click", () => {
46
- // htmx listens for submit directly on the form it processed, so a native requestSubmit
47
- // reaches it; the reload fallback covers the case where htmx did not load.
48
- if (typeof form.requestSubmit === "function") {
49
- form.requestSubmit();
50
- } else {
51
- window.location.reload();
52
- }
53
- });
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();
54
44
  }
55
- list.replaceChildren(box);
56
45
  }
57
46
 
58
- if (form) {
59
- form.addEventListener("htmx:responseError", showFilterError);
60
- 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
+ });
61
88
  }
62
89
 
63
90
  // --- Filter auto-submit ---
64
- // The Apply button is gone: changing a select or typing in the search fires the filter request
65
- // itself. requestSubmit() reaches htmx's submit listener on the form; the debounce collapses a
66
- // burst of keystrokes into one request. Only the change and input events below trigger a submit,
67
- // and both fire on user action alone, so a programmatic value reset (which sets .value directly
68
- // 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.
69
96
  function submitFilters() {
70
- const target = document.getElementById("filters");
71
- if (target && typeof target.requestSubmit === "function") {
72
- target.requestSubmit();
73
- }
97
+ submitForm(document.getElementById("filters"));
74
98
  }
75
99
 
76
100
  let searchTimer;
@@ -79,59 +103,228 @@
79
103
  searchTimer = setTimeout(submitFilters, 300);
80
104
  }
81
105
 
82
- if (form) {
83
- const statusSelect = document.getElementById("status");
84
- 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) {
85
135
  const searchInput = document.getElementById("q");
86
- statusSelect?.addEventListener("change", submitFilters);
87
- projectSelect?.addEventListener("change", submitFilters);
88
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
+ });
89
148
  }
90
149
 
91
- // --- Style guide save errors ---
92
- // A failed save (HTTP error or network error) puts the error box from the page's
93
- // #style-guide-error-box template above the form; the retry button re-submits the save form.
94
- // Delegated on document: a successful swap replaces the form element, so listeners bound to it
95
- // would die.
96
- 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
+ });
97
165
 
98
- function showStyleGuideError(event) {
99
- if (!(event.target instanceof Element) || event.target.id !== "style-guide-form") return;
100
- const region = document.getElementById("style-guide-region");
101
- if (!region || !styleGuideErrorTemplate) return;
102
- // The box goes above the form, never replacing it: the retry re-submits the very form the
103
- // failed request came from, and a replacement would leave nothing to re-submit. A success or
104
- // refusal swap replaces the whole region and takes the box with it; a repeated error drops
105
- // the previous box first so the notices cannot stack.
106
- region.querySelector("[data-style-guide-error]")?.remove();
107
- const box = styleGuideErrorTemplate.content.cloneNode(true);
108
- box.querySelector(".callout")?.setAttribute("data-style-guide-error", "");
109
- const retry = box.querySelector("[data-retry]");
110
- if (retry) {
111
- retry.addEventListener("click", () => {
112
- // htmx listens for submit directly on the form it processed, so a native requestSubmit
113
- // reaches it; the reload fallback covers the case where htmx did not load.
114
- const target = document.getElementById("style-guide-form");
115
- if (target && typeof target.requestSubmit === "function") {
116
- target.requestSubmit();
117
- } else {
118
- window.location.reload();
119
- }
120
- });
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;
121
182
  }
122
- region.prepend(box);
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");
192
+ }
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;
123
206
  }
124
207
 
125
- document.addEventListener("htmx:responseError", showStyleGuideError);
126
- 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
+ }
127
319
 
128
320
  // --- Editor live metrics ---
129
- // The constraint panel lives outside the htmx swap region, so the char count and the placeholder
130
- // checklist track the textarea as the user types. This is a convenience preview only: the server
131
- // 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.
132
324
  function refreshEditorMetrics() {
133
325
  const textarea = document.getElementById("editor-text");
134
326
  if (!textarea) return;
327
+ autosizeTextareas();
135
328
  const announcements = [];
136
329
 
137
330
  const charCount = document.getElementById("char-count");
@@ -160,7 +353,9 @@
160
353
  state.textContent = present ? "Present" : "Missing";
161
354
  state.classList.toggle("is-present", present);
162
355
  if (present !== wasPresent) {
163
- 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"}`);
164
359
  }
165
360
  }
166
361
  }
@@ -173,21 +368,28 @@
173
368
  }
174
369
 
175
370
  document.addEventListener("input", (event) => {
176
- 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") {
177
374
  refreshEditorMetrics();
375
+ } else if (target.matches("textarea[data-autosize]")) {
376
+ autosizeTextareas();
178
377
  }
179
378
  });
180
379
 
181
380
  // A save swaps the editor region in; the textarea survives via hx-preserve, so one pass after
182
- // 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.
183
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);
184
386
 
185
387
  // "Use" on a revision loads its text into the editor and re-syncs the panel, ready to save.
186
388
  document.addEventListener("click", (event) => {
187
389
  const target = event.target instanceof Element ? event.target : null;
188
390
  const useButton = target ? target.closest("[data-use-revision]") : null;
189
391
  if (!useButton) return;
190
- const textNode = useButton.closest(".revision")?.querySelector(".revision-text");
392
+ const textNode = useButton.closest(".timeline-item")?.querySelector(".timeline-body");
191
393
  const textarea = document.getElementById("editor-text");
192
394
  if (!textNode || !textarea) return;
193
395
  textarea.value = textNode.textContent ?? "";
@@ -195,116 +397,36 @@
195
397
  textarea.focus();
196
398
  });
197
399
 
198
- // --- Batch select-all and live selection count ---
199
- // The list region is swapped wholesale on every filter and action round, so no listener bound to
200
- // a checkbox or the button survives a swap: change events are delegated on document, and one pass
201
- // after each swap recomputes the select-all states and the approve button's count. All of it is
202
- // progressive enhancement — without JS the approve button keeps its static label, stays enabled,
203
- // and the native form still submits the checked rows.
204
- const DRAFT_SELECTOR = 'input[type="checkbox"][name="id"]';
205
- const SELECT_ALL_SELECTOR = 'input[type="checkbox"].select-all';
206
-
207
- function selectionCount() {
208
- return document.querySelectorAll(`#batch-form ${DRAFT_SELECTOR}:checked`).length;
209
- }
210
-
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.
211
405
  function updateApproveButton() {
212
406
  const button = document.querySelector('#batch-form .batch-bar button[type="submit"]');
213
407
  if (!button) return;
214
- const count = selectionCount();
408
+ const count = document.querySelectorAll('#batch-form input[type="checkbox"][name="id"]:checked').length;
215
409
  button.textContent = count === 0 ? "Approve selected" : `Approve ${count}`;
216
410
  button.disabled = count === 0;
217
411
  }
218
412
 
219
- function updateSelectAll(selectAll) {
220
- const batch = selectAll.closest(".batch");
221
- if (!batch) return;
222
- const drafts = batch.querySelectorAll(DRAFT_SELECTOR);
223
- const checked = batch.querySelectorAll(`${DRAFT_SELECTOR}:checked`).length;
224
- selectAll.checked = drafts.length > 0 && checked === drafts.length;
225
- selectAll.indeterminate = checked > 0 && checked < drafts.length;
226
- }
227
-
228
- function refreshSelection() {
229
- for (const selectAll of document.querySelectorAll(SELECT_ALL_SELECTOR)) {
230
- updateSelectAll(selectAll);
231
- }
232
- updateApproveButton();
233
- }
234
-
235
- document.addEventListener("change", (event) => {
236
- const target = event.target instanceof Element ? event.target : null;
237
- if (!target) return;
238
- if (target.matches(SELECT_ALL_SELECTOR)) {
239
- const batch = target.closest(".batch");
240
- if (batch) {
241
- for (const draft of batch.querySelectorAll(DRAFT_SELECTOR)) {
242
- draft.checked = target.checked;
243
- }
244
- }
245
- }
246
- refreshSelection();
247
- });
248
-
249
- document.addEventListener("htmx:afterSwap", refreshSelection);
250
-
251
- // --- Collapsible project sections and batches ---
252
- // The server renders every section and batch open; JS collapses the ones this viewer collapsed
253
- // before, from localStorage, and persists each toggle. Re-applied after every swap because a
254
- // filter or action round replaces the whole list region. Without JS the toggles stay inert and
255
- // everything stays open.
256
- const COLLAPSE_STORAGE = "reviewzy.list-collapsed";
257
-
258
- function readCollapsed() {
259
- try {
260
- const raw = localStorage.getItem(COLLAPSE_STORAGE);
261
- const parsed = raw ? JSON.parse(raw) : {};
262
- // A parsed array is not a key map: property sets on it would survive only until the next
263
- // JSON.stringify, which drops them, so persistence would silently stop.
264
- return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
265
- } catch {
266
- return {};
267
- }
268
- }
269
-
270
- function persistCollapsed(key, collapsed) {
271
- const map = readCollapsed();
272
- if (collapsed) {
273
- map[key] = true;
274
- } else {
275
- delete map[key];
276
- }
277
- try {
278
- localStorage.setItem(COLLAPSE_STORAGE, JSON.stringify(map));
279
- } catch {
280
- // Storage can be unavailable (private windows); the toggle still works for the page.
281
- }
282
- }
283
-
284
- function applyCollapseState() {
285
- const collapsed = readCollapsed();
286
- for (const toggle of document.querySelectorAll("[data-collapse-toggle]")) {
287
- const key = toggle.dataset.collapseKey ?? "";
288
- const target = document.getElementById(toggle.dataset.collapseTarget ?? "");
289
- if (!target) continue;
290
- const hidden = collapsed[key] === true;
291
- target.hidden = hidden;
292
- toggle.setAttribute("aria-expanded", String(!hidden));
293
- }
294
- }
295
-
296
- document.addEventListener("click", (event) => {
297
- const target = event.target instanceof Element ? event.target : null;
298
- const toggle = target ? target.closest("[data-collapse-toggle]") : null;
299
- if (!toggle) return;
300
- const el = document.getElementById(toggle.dataset.collapseTarget ?? "");
301
- if (!el) return;
302
- const hide = !el.hidden;
303
- el.hidden = hide;
304
- toggle.setAttribute("aria-expanded", String(!hide));
305
- persistCollapsed(toggle.dataset.collapseKey ?? "", hide);
306
- });
307
-
308
- applyCollapseState();
309
- document.addEventListener("htmx:afterSwap", applyCollapseState);
413
+ document.addEventListener("table:selection", updateApproveButton);
414
+ document.addEventListener("htmx:afterSwap", updateApproveButton);
415
+
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();
310
432
  })();