feat: undo/redo, chargement IntersectionObserver et ARIA pour la visionneuse XLSX #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m45s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 19s

This commit is contained in:
2026-09-29 15:01:00 -04:00
parent e94af0369b
commit 72da123a51
15 changed files with 189 additions and 28 deletions
+98 -6
View File
@@ -1113,6 +1113,19 @@ export function renderXlsxViewer(area, data) {
}
}
};
// #154-A4 — ARIA grid semantics (the backend renders a plain <table>).
const applyGridA11y = (panel) => {
const table = panel.querySelector(".xlsx-table");
if (!table) return;
table.setAttribute("role", "grid");
table.querySelectorAll("thead th").forEach((th) => th.setAttribute("role", "columnheader"));
table.querySelectorAll("tbody tr").forEach((tr) => {
tr.setAttribute("role", "row");
tr.querySelectorAll("th, td").forEach((cell) => {
cell.setAttribute("role", cell.tagName === "TH" ? "rowheader" : "gridcell");
});
});
};
const panels = isCsv
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
: sheets.map((s, i) =>
@@ -1148,6 +1161,14 @@ export function renderXlsxViewer(area, data) {
// later into the dedicated (empty) “view” group.
const actionGroups = [];
if (!readOnly) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
<button class="btn-action" id="xlsx-undo-btn" type="button" title="${escapeHtml(t("xlsx.undo"))}" disabled>
<i data-lucide="undo-2" style="width:14px;height:14px"></i>
</button>
<button class="btn-action" id="xlsx-redo-btn" type="button" title="${escapeHtml(t("xlsx.redo"))}" disabled>
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
</button>
</span>`);
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
@@ -1392,6 +1413,7 @@ export function renderXlsxViewer(area, data) {
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
applySheetMeta(panel);
}
applyGridA11y(panel);
safeCreateIcons();
};
@@ -1415,11 +1437,20 @@ export function renderXlsxViewer(area, data) {
}
};
// Scroll sentinel: near the bottom of the wrapper, pull the next window.
wrapper.addEventListener("scroll", () => {
if (loading || done) return;
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
});
// #154-A4 — prefer IntersectionObserver (less main-thread work than a
// scroll listener); the scroll listener stays as a fallback for
// environments without IO (JSDOM).
if (typeof IntersectionObserver === "function") {
const io = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) loadMore();
}, { root: wrapper, rootMargin: "120px" });
io.observe(foot);
} else {
wrapper.addEventListener("scroll", () => {
if (loading || done) return;
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
});
}
};
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
@@ -1517,6 +1548,65 @@ export function renderXlsxViewer(area, data) {
}
});
// ── #154-A4 — local undo/redo for cell edits ──────────────────────────
// One command per committed edit (a cell loses focus with a new value).
// Structure changes re-render from the server, which rebuilds the whole
// viewer and therefore clears the stacks (they are per-render).
const undoStack = [];
const redoStack = [];
let pendingEdit = null;
const undoBtn = area.querySelector("#xlsx-undo-btn");
const redoBtn = area.querySelector("#xlsx-redo-btn");
const updateUndoButtons = () => {
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
};
const commitEdit = () => {
if (!pendingEdit) return;
const { td, before } = pendingEdit;
pendingEdit = null;
const after = td.textContent;
if (before === after) return;
undoStack.push({ sheet: td.closest(".xlsx-panel")?.dataset.sheet, ref: td.dataset.cell, before, after });
redoStack.length = 0;
updateUndoButtons();
};
const beginEdit = (td) => {
if (pendingEdit && pendingEdit.td !== td) commitEdit();
if (!pendingEdit) pendingEdit = { td, before: td.textContent };
};
const applyHistory = (entry, value) => {
const panel = area.querySelector(`.xlsx-panel[data-sheet="${entry.sheet}"]`) || panelEls[0];
const td = panel?.querySelector(`td[data-cell="${entry.ref}"]`);
if (!td) return;
td.textContent = value;
td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? ""));
if (td === activeTd) syncFormulaBar(td);
refreshSaveState();
};
const undo = () => {
const entry = undoStack.pop();
if (!entry) return;
applyHistory(entry, entry.before);
redoStack.push(entry);
updateUndoButtons();
};
const redo = () => {
const entry = redoStack.pop();
if (!entry) return;
applyHistory(entry, entry.after);
undoStack.push(entry);
updateUndoButtons();
};
if (undoBtn) undoBtn.addEventListener("click", undo);
if (redoBtn) redoBtn.addEventListener("click", redo);
area.querySelector(".xlsx-viewer").addEventListener("keydown", (e) => {
if (!(e.ctrlKey || e.metaKey)) return;
const key = e.key.toLowerCase();
if (key === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
else if (key === "y") { e.preventDefault(); redo(); }
});
const setupCell = (td) => {
td.contentEditable = "true";
td.spellcheck = false;
@@ -1530,7 +1620,8 @@ export function renderXlsxViewer(area, data) {
syncFormulaBar(td);
refreshSaveState();
});
td.addEventListener("focus", () => setActiveCell(td));
td.addEventListener("focus", () => { beginEdit(td); setActiveCell(td); });
td.addEventListener("blur", commitEdit);
td.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
if (e.key === "Escape") {
@@ -1575,6 +1666,7 @@ export function renderXlsxViewer(area, data) {
}
panelEls.forEach(wireLazyRows);
panelEls.forEach(applySheetMeta);
panelEls.forEach(applyGridA11y);
// The formula bar starts disabled: nothing is selected yet.
setActiveCell(null);
+2
View File
@@ -1831,6 +1831,8 @@
"xlsx.conflict_msg": "The workbook was changed elsewhere in the meantime. Your edits are kept: retry the save.",
"xlsx.conflict_retry": "Retry",
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
"xlsx.undo": "Undo the last change",
"xlsx.redo": "Redo the change",
"xlsx.cached_value_title": "Last value calculated by Excel",
"xlsx.tabs_add_sheet": "Add a sheet",
"xlsx.readonly_badge": "Read-only",
+2
View File
@@ -1831,6 +1831,8 @@
"xlsx.conflict_msg": "Le classeur a été modifié ailleurs entre-temps. Vos modifications sont conservées : réessayez l'enregistrement.",
"xlsx.conflict_retry": "Réessayer",
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
"xlsx.undo": "Annuler la dernière modification",
"xlsx.redo": "Rétablir la modification",
"xlsx.cached_value_title": "Dernière valeur calculée par Excel",
"xlsx.tabs_add_sheet": "Ajouter une feuille",
"xlsx.readonly_badge": "Lecture seule",