258 lines
7.3 KiB
JavaScript
258 lines
7.3 KiB
JavaScript
function clampDigits(raw) {
|
|
const parsed = Number(raw);
|
|
if (!Number.isFinite(parsed)) return 4;
|
|
return Math.max(1, Math.min(4, Math.round(parsed)));
|
|
}
|
|
|
|
function sanitizeValue(raw, maxDigits) {
|
|
return String(raw || "")
|
|
.replace(/\D+/g, "")
|
|
.slice(0, maxDigits);
|
|
}
|
|
|
|
function sanitizeName(raw) {
|
|
return String(raw || "")
|
|
.replace(/\s+/g, " ")
|
|
.trimStart();
|
|
}
|
|
|
|
function isBlankRow(row) {
|
|
return !row || (!String(row.value || "").trim() && !String(row.name || "").trim());
|
|
}
|
|
|
|
function normalizeRows(raw, maxDigits) {
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw
|
|
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
|
|
.map((row) => ({
|
|
value: sanitizeValue(row.value, maxDigits),
|
|
name: sanitizeName(row.name),
|
|
}));
|
|
}
|
|
|
|
function ensureTrailingBlankRow(rows) {
|
|
const next = rows.map((row) => ({
|
|
value: String(row.value || ""),
|
|
name: String(row.name || ""),
|
|
}));
|
|
if (!next.length || !isBlankRow(next[next.length - 1])) {
|
|
next.push({ value: "", name: "" });
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function persistedRows(rows) {
|
|
return rows
|
|
.filter((row) => !isBlankRow(row))
|
|
.map((row) => ({
|
|
value: String(row.value || ""),
|
|
name: String(row.name || ""),
|
|
}));
|
|
}
|
|
|
|
function totalValue(rows) {
|
|
return persistedRows(rows).reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0);
|
|
}
|
|
|
|
function normalizeConfig(state) {
|
|
const maxDigits = clampDigits(state.max_digits);
|
|
return {
|
|
leftLabel: String(state.left_label || "Value").trim() || "Value",
|
|
rightLabel: String(state.right_label || "Item").trim() || "Item",
|
|
totalLabel: String(state.total_label || "Total").trim() || "Total",
|
|
totalSuffix: String(state.total_suffix || "").trim(),
|
|
maxDigits,
|
|
score:
|
|
typeof state.score === "number" && Number.isFinite(state.score)
|
|
? Math.max(0, Math.min(100, state.score))
|
|
: 24,
|
|
rows: ensureTrailingBlankRow(normalizeRows(state.rows, maxDigits)),
|
|
};
|
|
}
|
|
|
|
function configState(config) {
|
|
return {
|
|
left_label: config.leftLabel,
|
|
right_label: config.rightLabel,
|
|
total_label: config.totalLabel,
|
|
total_suffix: config.totalSuffix,
|
|
max_digits: config.maxDigits,
|
|
score: config.score,
|
|
rows: persistedRows(config.rows),
|
|
};
|
|
}
|
|
|
|
function autoscore(config) {
|
|
if (typeof config.score === "number" && Number.isFinite(config.score) && config.score > 0) {
|
|
return config.score;
|
|
}
|
|
return persistedRows(config.rows).length ? 24 : 16;
|
|
}
|
|
|
|
export function mount({ root, state, host }) {
|
|
const labelsEl = root.querySelector(".list-total-card-ui__labels");
|
|
const rowsEl = root.querySelector(".list-total-card-ui__rows");
|
|
const statusEl = root.querySelector(".list-total-card-ui__status");
|
|
const totalLabelEl = root.querySelector(".list-total-card-ui__total-label");
|
|
const totalEl = root.querySelector(".list-total-card-ui__total-value");
|
|
|
|
if (
|
|
!(labelsEl instanceof HTMLElement) ||
|
|
!(rowsEl instanceof HTMLElement) ||
|
|
!(statusEl instanceof HTMLElement) ||
|
|
!(totalLabelEl instanceof HTMLElement) ||
|
|
!(totalEl instanceof HTMLElement)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const leftLabelEl = labelsEl.children.item(0);
|
|
const rightLabelEl = labelsEl.children.item(1);
|
|
if (!(leftLabelEl instanceof HTMLElement) || !(rightLabelEl instanceof HTMLElement)) return;
|
|
|
|
let config = normalizeConfig(state);
|
|
let saveTimer = null;
|
|
let busy = false;
|
|
|
|
const clearSaveTimer = () => {
|
|
if (saveTimer !== null) {
|
|
window.clearTimeout(saveTimer);
|
|
saveTimer = null;
|
|
}
|
|
};
|
|
|
|
const setStatus = (text, kind) => {
|
|
statusEl.textContent = text || "";
|
|
statusEl.dataset.kind = kind || "";
|
|
};
|
|
|
|
const publishLiveContent = () => {
|
|
host.setLiveContent({
|
|
kind: "list_total",
|
|
item_count: persistedRows(config.rows).length,
|
|
total: totalValue(config.rows),
|
|
total_suffix: config.totalSuffix || null,
|
|
score: autoscore(config),
|
|
});
|
|
};
|
|
|
|
const renderTotal = () => {
|
|
const total = totalValue(config.rows);
|
|
totalEl.textContent = `${total.toLocaleString()}${config.totalSuffix || ""}`;
|
|
publishLiveContent();
|
|
};
|
|
|
|
const persist = async () => {
|
|
clearSaveTimer();
|
|
busy = true;
|
|
setStatus("Saving", "ok");
|
|
try {
|
|
const nextState = configState(config);
|
|
await host.replaceState(nextState);
|
|
config = normalizeConfig(nextState);
|
|
setStatus("", "");
|
|
} catch (error) {
|
|
console.error("List total card save failed", error);
|
|
setStatus("Unavailable", "error");
|
|
} finally {
|
|
busy = false;
|
|
render();
|
|
}
|
|
};
|
|
|
|
const schedulePersist = () => {
|
|
clearSaveTimer();
|
|
saveTimer = window.setTimeout(() => {
|
|
void persist();
|
|
}, 280);
|
|
};
|
|
|
|
const normalizeRowsAfterBlur = () => {
|
|
config.rows = ensureTrailingBlankRow(persistedRows(config.rows));
|
|
render();
|
|
schedulePersist();
|
|
};
|
|
|
|
const renderRows = () => {
|
|
rowsEl.innerHTML = "";
|
|
config.rows.forEach((row, index) => {
|
|
const rowEl = document.createElement("div");
|
|
rowEl.className = "list-total-card-ui__row";
|
|
|
|
const valueInput = document.createElement("input");
|
|
valueInput.className = "list-total-card-ui__input list-total-card-ui__value";
|
|
valueInput.type = "text";
|
|
valueInput.inputMode = "numeric";
|
|
valueInput.maxLength = config.maxDigits;
|
|
valueInput.placeholder = "0";
|
|
valueInput.value = row.value;
|
|
valueInput.disabled = busy;
|
|
|
|
const nameInput = document.createElement("input");
|
|
nameInput.className = "list-total-card-ui__input list-total-card-ui__name";
|
|
nameInput.type = "text";
|
|
nameInput.placeholder = "Item";
|
|
nameInput.value = row.name;
|
|
nameInput.disabled = busy;
|
|
|
|
valueInput.addEventListener("input", () => {
|
|
config.rows[index].value = sanitizeValue(valueInput.value, config.maxDigits);
|
|
valueInput.value = config.rows[index].value;
|
|
if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) {
|
|
config.rows = ensureTrailingBlankRow(config.rows);
|
|
render();
|
|
schedulePersist();
|
|
return;
|
|
}
|
|
renderTotal();
|
|
schedulePersist();
|
|
});
|
|
|
|
nameInput.addEventListener("input", () => {
|
|
config.rows[index].name = sanitizeName(nameInput.value);
|
|
nameInput.value = config.rows[index].name;
|
|
if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) {
|
|
config.rows = ensureTrailingBlankRow(config.rows);
|
|
render();
|
|
schedulePersist();
|
|
return;
|
|
}
|
|
renderTotal();
|
|
schedulePersist();
|
|
});
|
|
|
|
valueInput.addEventListener("blur", normalizeRowsAfterBlur);
|
|
nameInput.addEventListener("blur", normalizeRowsAfterBlur);
|
|
|
|
rowEl.append(valueInput, nameInput);
|
|
rowsEl.appendChild(rowEl);
|
|
});
|
|
};
|
|
|
|
const render = () => {
|
|
leftLabelEl.textContent = config.leftLabel;
|
|
rightLabelEl.textContent = config.rightLabel;
|
|
totalLabelEl.textContent = config.totalLabel;
|
|
renderRows();
|
|
renderTotal();
|
|
};
|
|
|
|
host.setRefreshHandler(() => {
|
|
config.rows = ensureTrailingBlankRow(config.rows);
|
|
render();
|
|
});
|
|
|
|
render();
|
|
|
|
return {
|
|
update({ state: nextState }) {
|
|
config = normalizeConfig(nextState);
|
|
render();
|
|
},
|
|
destroy() {
|
|
clearSaveTimer();
|
|
host.setRefreshHandler(null);
|
|
host.setLiveContent(null);
|
|
},
|
|
};
|
|
}
|