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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| countText | 25 to 27 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| plural | 30 to 32 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| fillCount | 35 to 37 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| previewItems | 41 to 43 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:42> | 42 to 42 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| orderOutcomes | 58 to 60 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:59> | 59 to 59 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:59> | 59 to 59 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| outcomeSummary | 63 to 69 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:64> | 64 to 64 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| performBulk | 96 to 127 | 15 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| perform | 107 to 109 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:122> | 122 to 122 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| bindBulk | 142 to 144 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| listOf | 146 to 149 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| visible | 151 to 153 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| boxesOf | 156 to 158 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| labelOf | 160 to 165 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| selectedItems | 169 to 175 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:173> | 173 to 173 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:174> | 174 to 174 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| itemsInView | 178 to 180 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:179> | 179 to 179 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| fire | 182 to 184 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| setAllInView | 187 to 193 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| part | 195 to 195 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| hasOutcomes | 199 to 202 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| svg | 204 to 212 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| showOutcomes | 218 to 274 | 11 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| refresh | 281 to 316 | 19 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:292> | 292 to 294 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| returnTarget | 320 to 324 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| clearSelection | 328 to 337 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| valueFor | 339 to 344 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| invoke | 347 to 377 | 11 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:362> | 362 to 362 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| attachBulkBar | 379 to 455 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:380> | 380 to 380 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| onChange | 385 to 389 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:392> | 392 to 392 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| onKey | 394 to 399 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:403> | 403 to 403 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:404> | 404 to 404 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:405> | 405 to 405 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:406> | 406 to 406 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| onHead | 409 to 412 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:414> | 414 to 414 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| onClick | 417 to 444 | 16 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:446> | 446 to 446 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:451> | 451 to 454 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:452> | 452 to 452 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| enhance | 459 to 462 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:461> | 461 to 461 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:461> | 461 to 461 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No 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());
}