feat: navigation clavier et barre de formule dans la visionneuse XLSX #153
A7 : barre de formule sous la barre d'outils (nom de cellule active A1 + miroir du contenu, édition live, Entrée valide, Échap annule puis refocalise) ; flèches et Tab déplacent la cellule active dans les 4 directions (Shift inverse Tab), outline persistant sur la cellule active quand le focus passe à la barre ; Maj+Entrée ne valide plus (réservé au multiligne A7+). td.tabIndex=0 pour la focalisation clavier. Vérifié : xlsx-viewer.test.mjs 25/25 (6 nouveaux), E2E 9/9 (barre + flèches + Échap sur fixture réelle), validate-imports, i18n parity (xlsx.formula_bar_placeholder, xlsx.active_cell). 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
+125
-2
@@ -1077,6 +1077,12 @@ export function renderXlsxViewer(area, data) {
|
||||
</span>
|
||||
</div>
|
||||
${lossWarning}
|
||||
<div class="xlsx-formula-bar">
|
||||
<span class="xlsx-active-cell" id="xlsx-active-cell">A1</span>
|
||||
<i data-lucide="chevron-right" class="xlsx-formula-sep"></i>
|
||||
<input type="text" class="xlsx-formula-input" id="xlsx-formula-input"
|
||||
spellcheck="false" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" />
|
||||
</div>
|
||||
<div class="xlsx-panels">${panels}</div>
|
||||
</div>`;
|
||||
|
||||
@@ -1158,21 +1164,135 @@ export function renderXlsxViewer(area, data) {
|
||||
});
|
||||
};
|
||||
|
||||
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
|
||||
// One active cell per viewer: clicking or arrowing into a cell shows its
|
||||
// A1 name in the bar; the input mirrors the cell text and typing there
|
||||
// edits the cell live (Enter commits, Escape reverts, then re-focuses).
|
||||
let activeTd = null;
|
||||
const activeCellEl = area.querySelector("#xlsx-active-cell");
|
||||
const formulaInput = area.querySelector("#xlsx-formula-input");
|
||||
const syncing = { value: false }; // guard against input-event feedback loops
|
||||
|
||||
const cellName = (td) => td?.dataset.cell || "";
|
||||
|
||||
const setActiveCell = (td) => {
|
||||
activeTd = td || null;
|
||||
if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active");
|
||||
if (!td) {
|
||||
activeCellEl.textContent = "—";
|
||||
formulaInput.value = "";
|
||||
formulaInput.disabled = true;
|
||||
return;
|
||||
}
|
||||
td.classList.add("xlsx-active");
|
||||
activeCellEl.textContent = cellName(td);
|
||||
formulaInput.disabled = false;
|
||||
formulaInput.value = td.textContent;
|
||||
};
|
||||
|
||||
const syncFormulaBar = (td) => {
|
||||
if (td !== activeTd) return;
|
||||
if (!syncing.value) formulaInput.value = td.textContent;
|
||||
};
|
||||
|
||||
// Parse an A1 reference into its (row, col) parts.
|
||||
const parseRef = (ref) => {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(ref || "");
|
||||
if (!m) return null;
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return { row: Number(m[2]), col };
|
||||
};
|
||||
|
||||
const findTd = (panel, row, col) =>
|
||||
panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
|
||||
|
||||
const columnName = (col) => {
|
||||
let name = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
name = String.fromCharCode(65 + rem) + name;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const moveActive = (td, key, forward = true) => {
|
||||
const ref = parseRef(cellName(td));
|
||||
if (!ref) return;
|
||||
let { row, col } = ref;
|
||||
if (key === "Tab") col += forward ? 1 : -1;
|
||||
else if (key === "ArrowRight") col += 1;
|
||||
else if (key === "ArrowLeft") col -= 1;
|
||||
else if (key === "ArrowDown") row += 1;
|
||||
else if (key === "ArrowUp") row -= 1;
|
||||
if (row < 1 || col < 1) return;
|
||||
const panel = td.closest(".xlsx-panel");
|
||||
const next = findTd(panel, row, col);
|
||||
if (!next) return; // edge of the rendered window: no wrap
|
||||
td.blur();
|
||||
next.focus();
|
||||
setActiveCell(next);
|
||||
};
|
||||
|
||||
formulaInput.addEventListener("input", () => {
|
||||
if (!activeTd) return;
|
||||
syncing.value = true;
|
||||
activeTd.textContent = formulaInput.value;
|
||||
syncing.value = false;
|
||||
activeTd.classList.add("xlsx-dirty");
|
||||
refreshSaveState();
|
||||
});
|
||||
formulaInput.addEventListener("keydown", (e) => {
|
||||
if (!activeTd) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
activeTd.blur();
|
||||
activeTd.focus();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
activeTd.textContent = activeTd.dataset.orig;
|
||||
activeTd.classList.remove("xlsx-dirty");
|
||||
syncFormulaBar(activeTd);
|
||||
refreshSaveState();
|
||||
activeTd.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const setupCell = (td) => {
|
||||
td.contentEditable = "true";
|
||||
td.spellcheck = false;
|
||||
// Focusable without a pointing device: JSDOM requires it to fire focus
|
||||
// events on contenteditable cells, and a keyboard user tabbing into the
|
||||
// table from outside lands on the first cell thanks to it.
|
||||
td.tabIndex = 0;
|
||||
td.dataset.orig = td.textContent;
|
||||
td.addEventListener("input", () => {
|
||||
td.classList.add("xlsx-dirty");
|
||||
syncFormulaBar(td);
|
||||
refreshSaveState();
|
||||
});
|
||||
td.addEventListener("focus", () => setActiveCell(td));
|
||||
td.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); td.blur(); }
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
|
||||
if (e.key === "Escape") {
|
||||
td.textContent = td.dataset.orig;
|
||||
td.classList.remove("xlsx-dirty");
|
||||
syncFormulaBar(td);
|
||||
refreshSaveState();
|
||||
}
|
||||
if (e.key === "Tab" || e.key === "ArrowUp" || e.key === "ArrowDown"
|
||||
|| e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
// Excel-like: arrows and Tab move to the neighbour cell. Direction is
|
||||
// the key's own (Left/Up = back, Right/Down = forward); Tab follows
|
||||
// Shift. Prevented so Tab never leaves the table and arrows never
|
||||
// move the caret (they move the SELECTION instead).
|
||||
e.preventDefault();
|
||||
const forward = e.key === "Tab"
|
||||
? !e.shiftKey
|
||||
: (e.key === "ArrowRight" || e.key === "ArrowDown");
|
||||
moveActive(td, e.key, forward);
|
||||
}
|
||||
});
|
||||
td.addEventListener("paste", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -1187,9 +1307,11 @@ export function renderXlsxViewer(area, data) {
|
||||
el.title = t("xlsx.cached_value_title");
|
||||
});
|
||||
|
||||
// Editable cells: Enter blurs, Escape reverts, paste stays single-line.
|
||||
// Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate.
|
||||
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
|
||||
panelEls.forEach(wireLazyRows);
|
||||
// The formula bar starts disabled: nothing is selected yet.
|
||||
setActiveCell(null);
|
||||
|
||||
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
@@ -1253,6 +1375,7 @@ export function renderXlsxViewer(area, data) {
|
||||
td.classList.remove("xlsx-dirty");
|
||||
td.dataset.orig = td.textContent;
|
||||
});
|
||||
if (activeTd) syncFormulaBar(activeTd);
|
||||
refreshSaveState();
|
||||
showToast(t("editor.saved"), "success");
|
||||
} catch (err) {
|
||||
|
||||
@@ -1836,6 +1836,8 @@
|
||||
"xlsx.load_more": "Load more",
|
||||
"xlsx.loading_more": "Loading…",
|
||||
"xlsx.load_error": "Could not load the remaining rows",
|
||||
"xlsx.formula_bar_placeholder": "Active cell content",
|
||||
"xlsx.active_cell": "Cell",
|
||||
"xlsx.feature_cached_values": "cached values",
|
||||
"xlsx.feature_slicers": "slicers and timelines",
|
||||
"xlsx.feature_form_controls": "form controls",
|
||||
|
||||
@@ -1836,6 +1836,8 @@
|
||||
"xlsx.load_more": "Charger la suite",
|
||||
"xlsx.loading_more": "Chargement…",
|
||||
"xlsx.load_error": "Chargement de la suite impossible",
|
||||
"xlsx.formula_bar_placeholder": "Contenu de la cellule active",
|
||||
"xlsx.active_cell": "Cellule",
|
||||
"xlsx.feature_cached_values": "valeurs calculées",
|
||||
"xlsx.feature_slicers": "segments et chronologies",
|
||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||
|
||||
@@ -10996,6 +10996,56 @@ body.desktop-mode .editor-container {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* #153 A7 — the active cell keeps its outline even when focus moves to the
|
||||
formula bar, so the user never loses track of what the bar edits. */
|
||||
.xlsx-table td.xlsx-active:not(:focus) {
|
||||
outline: 2px dashed var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* #153 A7 — formula bar under the toolbar: [ A1 | > | input ] */
|
||||
.xlsx-formula-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.xlsx-active-cell {
|
||||
min-width: 52px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.xlsx-formula-sep {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.xlsx-formula-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.xlsx-formula-input:focus {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.xlsx-formula-input:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.xlsx-table td.xlsx-dirty {
|
||||
background: rgba(255, 196, 0, 0.18);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user