Skip to the content

Capsid menu

components/bulk-bar/bulk-bar.ts

425 lines and 54 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.

Functions

The functions of bulk-bar.ts
FunctionLinesComplexityCoveredCRAPMutation
countText25 to 271No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
plural30 to 323No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
fillCount35 to 372No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
previewItems41 to 431No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:42>42 to 421No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
orderOutcomes58 to 601No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:59>59 to 591No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:59>59 to 591No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
outcomeSummary63 to 695No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:64>64 to 641No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
performBulk96 to 12715No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
perform107 to 1091No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:122>122 to 1221No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
bindBulk142 to 1441No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
listOf146 to 1492No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
visible151 to 1532No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
boxesOf156 to 1581No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
labelOf160 to 1654No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
selectedItems169 to 1752No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:173>173 to 1734No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:174>174 to 1741No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
itemsInView178 to 1801No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:179>179 to 1793No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
fire182 to 1841No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
setAllInView187 to 1934No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
part195 to 1951No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
hasOutcomes199 to 2022No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
svg204 to 2121No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
showOutcomes218 to 27411No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
refresh281 to 31619No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:292>292 to 2942No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
returnTarget320 to 3246No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
clearSelection328 to 3373No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
valueFor339 to 3443No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
invoke347 to 37711No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:362>362 to 3621No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
attachBulkBar379 to 4556No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:380>380 to 3801No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onChange385 to 3893No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:392>392 to 3921No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onKey394 to 3996No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:403>403 to 4031No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:404>404 to 4041No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:405>405 to 4051No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:406>406 to 4061No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onHead409 to 4121No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:414>414 to 4141No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onClick417 to 44416No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:446>446 to 4461No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:451>451 to 4541No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:452>452 to 4521No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
enhance459 to 4621No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:461>461 to 4611No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:461>461 to 4611No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet

Imports

It imports components/confirm-dialog/confirm-dialog.ts, components/message/message.ts. Imported by components/bulk-bar/bulk-bar.react.tsx, components/content/content.react.tsx, components/content/content.ts, components/media/media.ts.

Source

// The bulk action bar: what a selection can be done to, said where the selection is. It counts
// the selected items ("3 selected", a live status), offers the actions, and clears. A
// reversible action runs at once and offers Undo through the message region (`z` runs it); a
// destructive one previews every item in the shared confirm dialog and runs only when the
// person says so, with focus on Cancel. No framework; the React wrapper calls the same
// functions.

import { confirm } from "../confirm-dialog/confirm-dialog.ts";
import { GLYPHS, fail, say } from "../message/message.ts";

const READY = "data-cap-ready";
const SELECT = "input[type='checkbox'][data-cap-select]";

// ---------------------------------------------------------------------------------------
// Pure helpers.

export interface BulkItem {
  // What the app calls it (the checkbox's value).
  id: string;
  // What the person calls it: a filename, a post title. Listed in the preview.
  label: string;
}

// "3 selected".
export function countText(n: number): string {
  return `${n} selected`;
}

// "item" or "items" for a count; `noun` is the singular.
export function plural(n: number, noun: string, many?: string): string {
  return n === 1 ? noun : (many ?? `${noun}s`);
}

// Fills {n} and {s} in a template: "Bin {n} item{s}" gives "Bin 12 items" and "Bin 1 item".
export function fillCount(template: string, n: number): string {
  return template.replace(/\{n\}/g, String(n)).replace(/\{s\}/g, n === 1 ? "" : "s");
}

// Every item of a destructive action, one line each, for the confirm dialog's list. The title
// carries the count; the list is not shortened, because a preview that hides items is not one.
export function previewItems(items: readonly BulkItem[]): string[] {
  return items.map((i) => i.label);
}

// One item's outcome after an action that acted on several, for the bar's outcome list. A site
// can refuse one item and do the rest, so each says its own result.
export interface BulkOutcome {
  id: string;
  label: string;
  ok: boolean;
  // Why it was not done, or a note on what was: "Changed since you opened it."
  message?: string;
  // Where it is still used, when that is why it was refused: one line each.
  usedBy?: readonly string[];
}

// The outcomes to list, refusals first: what needs the person comes before what is done.
export function orderOutcomes(outcomes: readonly BulkOutcome[]): BulkOutcome[] {
  return [...outcomes.filter((o) => !o.ok), ...outcomes.filter((o) => o.ok)];
}

// The outcome list's one line: "All 3 done.", "None done: 2 refused.", "2 done, 1 not done."
export function outcomeSummary(outcomes: readonly BulkOutcome[]): string {
  const done = outcomes.filter((o) => o.ok).length;
  const not = outcomes.length - done;
  if (not === 0) return done === 1 ? "Done." : `All ${done} done.`;
  if (done === 0) return not === 1 ? "Not done." : `None done: ${not} refused.`;
  return `${done} done, ${not} not done.`;
}

export interface BulkActionSpec {
  // The action's name: "bin", "archive".
  action: string;
  items: readonly BulkItem[];
  // A value from beside the button, such as a tag to add.
  value?: string;
  // Runs the action. Reject with an Error whose message says what happened.
  run: (items: readonly BulkItem[], value?: string) => void | Promise<void>;
  // Reverses it, for a reversible action: the message offers Undo.
  undo?: (items: readonly BulkItem[], value?: string) => void | Promise<void>;
  // A destructive action previews first: the dialog's title, lead and button, as templates with {n}.
  destructive?: boolean;
  confirmTitle?: string;
  confirmLead?: string;
  confirmAction?: string;
  // What the region says afterwards ("Archived {n} drafts") and after Undo.
  said?: string;
  undone?: string;
  // Where focus goes once the selection is cleared and the opener is gone.
  returnTo?: HTMLElement | null;
}

// Runs one bulk action the way the family does it. Resolves true when it ran, false when it was
// cancelled or failed. Reversible: at once, then a message with Undo. Destructive: a confirm
// dialog lists every item, "Bin 12 items" performs, focus starts on Cancel.
export async function performBulk(spec: BulkActionSpec): Promise<boolean> {
  const n = spec.items.length;
  if (n === 0) return false;
  const verb = spec.action.charAt(0).toUpperCase() + spec.action.slice(1);
  if (spec.destructive) {
    const done = await confirm({
      title: fillCount(spec.confirmTitle ?? `${verb} {n} item{s}?`, n),
      lead: spec.confirmLead ? fillCount(spec.confirmLead, n) : undefined,
      body: previewItems(spec.items),
      action: fillCount(spec.confirmAction ?? `${verb} {n} item{s}`, n),
      returnTo: spec.returnTo,
      perform: async () => {
        await spec.run(spec.items, spec.value);
      },
    });
    if (done) say(fillCount(spec.said ?? `${verb}: {n} item{s}.`, n));
    return done;
  }
  try {
    await spec.run(spec.items, spec.value);
  } catch (err) {
    fail(fillCount(`Could not ${verb.toLowerCase()} {n} item{s}: ${err instanceof Error && err.message ? err.message : "nothing says why"}`, n));
    return false;
  }
  const undo = spec.undo;
  say(fillCount(spec.said ?? `${verb}: {n} item{s}.`, n), {
    undo: undo ? () => undo(spec.items, spec.value) : undefined,
    undone: spec.undone ? fillCount(spec.undone, n) : undefined,
    returnFocus: spec.returnTo ?? undefined,
  });
  return true;
}

// ---------------------------------------------------------------------------------------
// The DOM.

export interface BulkHandler {
  run: BulkActionSpec["run"];
  undo?: BulkActionSpec["undo"];
  said?: string;
  undone?: string;
}

const handlers = new WeakMap<HTMLElement, Record<string, BulkHandler>>();

// Says what each action of a bar does. The keys are the buttons' data-cap-bulk-action values.
export function bindBulk(bar: HTMLElement, actions: Record<string, BulkHandler>): void {
  handlers.set(bar, actions);
}

export function listOf(bar: HTMLElement): HTMLElement | null {
  const id = bar.dataset.capList;
  return id ? document.getElementById(id) : null;
}

function visible(el: HTMLElement): boolean {
  return !el.closest("[hidden]") && el.getClientRects().length > 0;
}

// The selectable items of a list: its checkboxes marked data-cap-select.
export function boxesOf(list: ParentNode): HTMLInputElement[] {
  return Array.from(list.querySelectorAll<HTMLInputElement>(SELECT));
}

export function labelOf(box: HTMLInputElement): string {
  const named = box.dataset.label ?? box.closest<HTMLElement>("[data-label]")?.dataset.label;
  if (named) return named;
  const text = box.labels?.[0]?.textContent?.trim();
  return text || box.value;
}

// The items now selected, in document order. An item that is out of sight (a filter hid it) is
// not acted on, so an action never reaches what the person cannot see.
export function selectedItems(bar: HTMLElement): BulkItem[] {
  const list = listOf(bar);
  if (!list) return [];
  return boxesOf(list)
    .filter((b) => b.checked && !b.disabled && visible(b.closest<HTMLElement>("[data-label], li, tr, label") ?? b))
    .map((b) => ({ id: b.value, label: labelOf(b) }));
}

// The selectable items the person can see now (a search or a filter may hide some).
export function itemsInView(list: ParentNode): HTMLInputElement[] {
  return boxesOf(list).filter((b) => !b.disabled && visible(b.closest<HTMLElement>("[data-label], li, tr, label") ?? b));
}

function fire(box: HTMLInputElement): void {
  box.dispatchEvent(new Event("change", { bubbles: true }));
}

// Selects (or clears) every item in view. Real checkboxes change, so a form posts them.
export function setAllInView(list: ParentNode, on: boolean): void {
  for (const box of on ? itemsInView(list) : boxesOf(list)) {
    if (box.checked === on) continue;
    box.checked = on;
    fire(box);
  }
}

const part = <T extends HTMLElement = HTMLElement>(bar: HTMLElement, name: string) => bar.querySelector<T>(`[data-cap-part='${name}']`);

// True while the bar lists outcomes: it stays in view with nothing selected, so the results of
// the action just run can be read.
function hasOutcomes(bar: HTMLElement): boolean {
  const list = part(bar, "results");
  return !!list && !list.hidden;
}

function svg(glyph: string): SVGSVGElement {
  const el = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  el.setAttribute("class", "cap-status-glyph");
  el.setAttribute("viewBox", "0 0 16 16");
  el.setAttribute("aria-hidden", "true");
  el.setAttribute("focusable", "false");
  el.innerHTML = glyph;
  return el;
}

let resultsSeq = 0;

// Writes the outcome list into the bar (the markup on the doc page), replacing any before it, and
// shows the bar. An empty list removes it.
export function showOutcomes(bar: HTMLElement, outcomes: readonly BulkOutcome[]): void {
  part(bar, "results")?.remove();
  if (outcomes.length > 0) {
    const box = document.createElement("div");
    box.className = "cap-bulk-results";
    box.dataset.capPart = "results";
    box.setAttribute("role", "group");
    const summary = document.createElement("p");
    summary.className = "cap-bulk-results-summary";
    summary.id = `${bar.id || "cap-bulk"}-results-${++resultsSeq}`;
    summary.textContent = outcomeSummary(outcomes);
    box.setAttribute("aria-labelledby", summary.id);
    const list = document.createElement("ul");
    list.className = "cap-bulk-results-list";
    for (const o of orderOutcomes(outcomes)) {
      const li = document.createElement("li");
      li.dataset.ok = String(o.ok);
      const word = document.createElement("span");
      word.className = "cap-status";
      word.dataset.tone = o.ok ? "ok" : "crit";
      word.append(svg(o.ok ? GLYPHS.ok : GLYPHS.failure), o.ok ? "Done" : "Not done");
      const label = document.createElement("span");
      label.className = "cap-bulk-result-label";
      label.textContent = o.label;
      li.append(word, " ", label);
      if (o.message) {
        const m = document.createElement("span");
        m.className = "cap-bulk-result-message";
        m.textContent = o.message;
        li.append(" ", m);
      }
      if (o.usedBy?.length) {
        const uses = document.createElement("ul");
        uses.className = "cap-bulk-result-uses";
        for (const u of o.usedBy) {
          const item = document.createElement("li");
          item.textContent = u;
          uses.append(item);
        }
        li.append(uses);
      }
      list.append(li);
    }
    const dismiss = document.createElement("button");
    dismiss.type = "button";
    dismiss.className = "cap-btn";
    dismiss.dataset.variant = "quiet";
    dismiss.dataset.size = "sm";
    dismiss.dataset.capPart = "dismiss-results";
    dismiss.textContent = "Dismiss results";
    box.append(summary, list, dismiss);
    bar.append(box);
  }
  refresh(bar);
  // A bar with no list is not counted by refresh; outcomes alone put it in view.
  if (outcomes.length > 0) bar.hidden = false;
}
const lastTouched = new WeakMap<HTMLElement, HTMLInputElement>();

// Redraws the bar from the list: the count, hidden when there is none, the select-all label
// and the header checkbox's state. The count is written a frame after the bar appears, so a
// screen reader announces it (a status region that is shown and filled in one step is often
// missed).
export function refresh(bar: HTMLElement): number {
  const list = listOf(bar);
  if (!list) return Number.parseInt(part(bar, "count")?.textContent ?? bar.querySelector(".cap-bulk-count")?.textContent ?? "0", 10) || 0;
  const items = selectedItems(bar);
  const n = items.length;
  const count = bar.querySelector<HTMLElement>(".cap-bulk-count");
  const text = countText(n);
  if (count) {
    if (n > 0 && bar.hidden) {
      bar.hidden = false;
      count.textContent = "";
      requestAnimationFrame(() => {
        if (!bar.hidden) count.textContent = countText(selectedItems(bar).length);
      });
    } else if (n > 0) {
      if (count.textContent !== text) count.textContent = text;
    } else if (hasOutcomes(bar)) {
      bar.hidden = false;
      if (count.textContent !== "Nothing selected") count.textContent = "Nothing selected";
    } else {
      bar.hidden = true;
    }
  } else bar.hidden = n === 0 && !hasOutcomes(bar);
  const total = itemsInView(list).length;
  const all = part(bar, "select-all");
  if (all) {
    all.textContent = fillCount(all.dataset.capLabel ?? "Select all {n} in view", total);
    all.hidden = total === 0 || n >= total;
  }
  for (const head of document.querySelectorAll<HTMLInputElement>(`input[data-cap-select-all][data-cap-list='${CSS.escape(list.id)}']`)) {
    head.checked = total > 0 && n >= total;
    head.indeterminate = n > 0 && n < total;
  }
  bar.dispatchEvent(new CustomEvent("cap-bulk-change", { bubbles: true, detail: { items, count: n } }));
  return n;
}

// Where focus goes when the bar goes away: the bar's data-cap-return selector, the item last
// touched, or the first item.
function returnTarget(bar: HTMLElement): HTMLElement | null {
  const list = listOf(bar);
  const named = bar.dataset.capReturn ? document.querySelector<HTMLElement>(bar.dataset.capReturn) : null;
  return named ?? lastTouched.get(bar) ?? (list ? boxesOf(list)[0] : null) ?? null;
}

// Clears the selection. When focus was in the bar (which hides), it goes back to the list: the
// bar's data-cap-return selector, the item last touched, or the first item.
export function clearSelection(bar: HTMLElement): void {
  const list = listOf(bar);
  if (!list) return;
  const had = bar.contains(document.activeElement);
  setAllInView(list, false);
  refresh(bar);
  bar.dispatchEvent(new CustomEvent("cap-bulk-clear", { bubbles: true }));
  if (!had) return;
  returnTarget(bar)?.focus();
}

function valueFor(button: HTMLElement): string | undefined {
  const sel = button.dataset.capValue;
  if (!sel) return undefined;
  const el = document.querySelector<HTMLInputElement>(sel);
  return el?.value.trim() ?? "";
}

// Presses one action button: the preview or the immediate run, as the button says.
export async function invoke(bar: HTMLElement, button: HTMLElement): Promise<boolean> {
  const items = selectedItems(bar);
  const action = button.dataset.capBulkAction ?? "";
  if (items.length === 0 || !action) return false;
  const value = valueFor(button);
  if (value === "") {
    const input = document.querySelector<HTMLElement>(button.dataset.capValue ?? "");
    input?.focus();
    return false;
  }
  const h = handlers.get(bar)?.[action];
  const ok = await performBulk({
    action: button.dataset.capLabel ?? action,
    items,
    value,
    run: h?.run ?? (() => undefined),
    undo: h?.undo,
    said: h?.said ?? button.dataset.capSaid,
    undone: h?.undone ?? button.dataset.capUndone,
    destructive: button.hasAttribute("data-destructive"),
    confirmTitle: button.dataset.capConfirmTitle,
    confirmLead: button.dataset.capConfirmLead,
    confirmAction: button.dataset.capConfirmAction,
    returnTo: returnTarget(bar),
  });
  if (ok) {
    bar.dispatchEvent(new CustomEvent("cap-bulk-action", { bubbles: true, detail: { action, items, value } }));
    clearSelection(bar);
  }
  return ok;
}

export function attachBulkBar(bar: HTMLElement): () => void {
  if (bar.hasAttribute(READY)) return () => {};
  bar.setAttribute(READY, "");
  const list = listOf(bar);
  const undo: Array<() => void> = [];
  if (list) {
    const onChange = (e: Event) => {
      const t = e.target;
      if (t instanceof HTMLInputElement && t.matches(SELECT)) lastTouched.set(bar, t);
      refresh(bar);
    };
    list.addEventListener("change", onChange);
    // Items added, removed or hidden (a filter, an upload, a bin) change what can be selected.
    const watch = new MutationObserver(() => refresh(bar));
    watch.observe(list, { childList: true, subtree: true, attributes: true, attributeFilter: ["hidden", "disabled"] });
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Escape" || e.defaultPrevented || e.isComposing || bar.hidden) return;
      if (selectedItems(bar).length === 0) return;
      e.preventDefault();
      clearSelection(bar);
    };
    list.addEventListener("keydown", onKey);
    bar.addEventListener("keydown", onKey);
    undo.push(
      () => list.removeEventListener("change", onChange),
      () => watch.disconnect(),
      () => list.removeEventListener("keydown", onKey),
      () => bar.removeEventListener("keydown", onKey),
    );
    for (const head of document.querySelectorAll<HTMLInputElement>(`input[data-cap-select-all][data-cap-list='${CSS.escape(list.id)}']`)) {
      const onHead = () => {
        setAllInView(list, head.checked);
        refresh(bar);
      };
      head.addEventListener("change", onHead);
      undo.push(() => head.removeEventListener("change", onHead));
    }
  }
  const onClick = (e: MouseEvent) => {
    const target = e.target instanceof Element ? e.target : null;
    const button = target?.closest<HTMLElement>("button, [data-cap-bulk-action]");
    if (!button || !bar.contains(button) || button.getAttribute("aria-disabled") === "true") return;
    if (button.classList.contains("cap-bulk-clear")) return void clearSelection(bar);
    if (button.dataset.capPart === "dismiss-results") {
      const had = bar.contains(document.activeElement);
      showOutcomes(bar, []);
      if (had && bar.hidden) returnTarget(bar)?.focus();
      return;
    }
    if (button.dataset.capPart === "select-all" && list) {
      setAllInView(list, true);
      refresh(bar);
      return;
    }
    // Form mode: a submit button is the browser's own. It posts the form with the button's name
    // and value and the server answers (a destructive intent with its own confirmation page), so
    // nothing here runs, previews or offers Undo. It only refuses a press with nothing ticked.
    if (button instanceof HTMLButtonElement && button.type === "submit") {
      if (list && selectedItems(bar).length === 0) {
        e.preventDefault();
        fail("Tick at least one item first.");
      }
      return;
    }
    if (button.dataset.capBulkAction) void invoke(bar, button);
  };
  bar.addEventListener("click", onClick);
  undo.push(() => bar.removeEventListener("click", onClick));
  // A server-rendered outcome list carries its Dismiss hidden, since it needs script.
  const dismiss = part(bar, "dismiss-results");
  if (dismiss) dismiss.hidden = false;
  if (list) refresh(bar);
  return () => {
    undo.forEach((f) => f());
    bar.removeAttribute(READY);
  };
}

// Attaches to every [data-cap="bulk-bar"] under root not yet attached. Returns a function that
// detaches them all.
export function enhance(root: ParentNode = document): () => void {
  const undo = Array.from(root.querySelectorAll<HTMLElement>("[data-cap='bulk-bar']:not([data-cap-ready])")).map(attachBulkBar);
  return () => undo.forEach((f) => f());
}