From 72da123a51dbe58362d7296373b8665b447baedd Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 29 Sep 2026 15:00:56 -0400 Subject: [PATCH] feat: undo/redo, chargement IntersectionObserver et ARIA pour la visionneuse XLSX #154 --- CHANGELOG.md | 16 +++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md | 3 + docs/ROADMAP.md | 11 ++- docs/features/xlsx-ui-redesign.md | 20 +++-- frontend/js/viewer.js | 104 ++++++++++++++++++++++-- frontend/locales/en.json | 2 + frontend/locales/fr.json | 2 + package.json | 2 +- tests/frontend/xlsx-viewer.test.mjs | 37 +++++++++ 15 files changed, 189 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42fa9ab..14015e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.42.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.43.0**. --- @@ -14,6 +14,20 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.43.0] — 2026-09-29 + +### Ajouté + +- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 4) : undo/redo, défilement et accessibilité.** + Les éditions de cellules peuvent être **annulées / rétablies** (boutons dans le ruban, + raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`). Les fenêtres de lignes d'une feuille tronquée se + chargent via **`IntersectionObserver`** (repli sur l'écouteur de défilement quand l'API est + absente). La grille expose désormais une sémantique **ARIA** (`role="grid"` / `row` / + `gridcell` / `columnheader` / `rowheader`). Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs` + (47) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts. Aucun changement backend. + +--- + ## [2.42.0] — 2026-09-29 ### Ajouté diff --git a/README.fr.md b/README.fr.md index bf4022c..3c46007 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.42.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.43.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.42.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.43.0). --- -*Projet : ObsiGate | Version : 2.42.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.43.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 935d202..abd7406 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.42.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.43.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.42.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.43.0). --- -*Project: ObsiGate | Version: 2.42.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.43.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 345a83e..5b9cd9a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.42.0 +2.43.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index b2cfa43..c50f5c1 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.42.0" +version = "2.43.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 4ff800f..a6075cd 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.42.0" +version = "2.43.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 91e039c..eafc383 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.42.0", + "version": "2.43.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md index aa00108..7dfd9f4 100644 --- a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md +++ b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md @@ -215,6 +215,9 @@ A1 au-dessus du tableau ; `Tab`/`Maj+Tab` et les flèches circulent entre les cellules, `Entrée` valide, `Maj+Entrée` insère un retour à la ligne, une plage se copie telle quelle vers un tableur. +**Annuler / rétablir** — `Ctrl+Z` (ou le bouton **Annuler** du ruban) revient +sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit. + **Tri, filtre, recherche, export** — chaque colonne se trie (ascendant / descendant, info-bulle : le tri s'applique à l'affichage seul), les lignes se filtrent, la recherche (`Ctrl+F` du panneau) va de correspondance en diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 941b8db..ab66bc5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.42.0 | **Dernière mise à jour :** 2026-09-29 +> **Version :** 2.43.0 | **Dernière mise à jour :** 2026-09-29 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -97,11 +97,13 @@ ### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur) - **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡 -- **Statut :** 🔵 **en cours** — **Lots 1, 2 & 3 livrés le 2026-09-29** (ruban de commandes groupé, +- **Statut :** 🔵 **en cours** — **Lots 1, 2, 3 & 4 livrés le 2026-09-29** (ruban de commandes groupé, onglets de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non recalculées, tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`, bandeau de conflit 409 non bloquant, indicateur *dirty* ; **inspecteur droit repliable** pour le - tableau de bord avec entrée Assistant IA). Lot suivant : découpage modulaire + undo/redo. + tableau de bord avec entrée Assistant IA ; **undo/redo**, chargement via `IntersectionObserver`, + ARIA `role="grid"`). Reste **A5** : découpage modulaire (`frontend/js/xlsx/*`) et finitions + (lien dashboard → grille, inspecteur redimensionnable, split). - **Analyse, architecture cible et plan par lots :** [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) - **Description :** la visionneuse XLSX (#152/#153) est fonctionnelle mais peu conviviale : commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés @@ -113,7 +115,8 @@ badges d'état, tokens de grille et affordances visuelles (Lot 1) - [x] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2) - [x] **A3** Inspecteur droit repliable : Tableau de bord + entrée Assistant IA (Lot 3) - - [ ] **A4** Découpage `frontend/js/xlsx/*` + undo/redo + défilement via `IntersectionObserver` (Lot 4) + - [x] **A4** Undo/redo, chargement via `IntersectionObserver`, sémantique ARIA (Lot 4) + - [ ] **A5** Découpage `frontend/js/xlsx/*` + lien dashboard → grille, inspecteur redimensionnable, split --- diff --git a/docs/features/xlsx-ui-redesign.md b/docs/features/xlsx-ui-redesign.md index 48a35dd..c7fabcd 100644 --- a/docs/features/xlsx-ui-redesign.md +++ b/docs/features/xlsx-ui-redesign.md @@ -2,8 +2,8 @@ > **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md) > **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale) -> **Statut :** 🔵 **en cours** — Lots 1, 2 & 3 livrés le 2026-09-29 -> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 ✅ · Lot 4) +> **Statut :** 🔵 **en cours** — Lots 1, 2, 3 & 4 livrés le 2026-09-29 (reste A5, découpage modulaire) +> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 ✅ · Lot 4 ✅ · Lot 5) > **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte > l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.** @@ -87,12 +87,19 @@ la grille ni le backend. existant) et bouton de fermeture. - **A3.3** Responsive : sous 900 px, l'inspecteur passe sous la grille. -### Lot 4 — Découpage & finitions (à venir) +### Lot 4 — Interactions : undo/redo, défilement, accessibilité ✅ *(2026-09-29)* + +- **A4.1** **Undo/redo** local (pile de commandes) pour les éditions de cellules : boutons + **Annuler / Rétablir** dans le ruban + raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`. +- **A4.2** Chargement des fenêtres via **`IntersectionObserver`** (repli sur l'écouteur de + défilement pour les environnements sans IO). +- **A4.3** Sémantique **ARIA** : `role="grid"` / `row` / `gridcell` / `columnheader` / `rowheader`. + +### Lot 5 — Découpage modulaire & finitions (à venir) - Extraction de `renderXlsxViewer` en modules (`frontend/js/xlsx/*.js`) : `toolbar`, `formula-bar`, - `grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api`. -- **Undo/redo** local (pile de commandes), défilement paresseux via `IntersectionObserver`, - accessibilité ARIA (`role="grid"`, *roving tabindex*). + `grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api` (refactor sans changement de + comportement, sous couvert des tests JSDOM/E2E existants). - Lien **dashboard → grille** (clic sur KPI/plage = scroll + sélection), inspecteur **redimensionnable**, détachement en split via `PaneManager.splitRight()`. @@ -126,3 +133,4 @@ la grille ni le backend. | 2026-09-29 | Audit UX (3 problèmes) + architecture cible + plan par lots ; **Lot 1** livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille) | | 2026-09-29 | **Lot 2** livré : dialogues thémés (`showConfirm`/`showPrompt`) pour la structure et la confirmation de perte, bandeau de conflit 409 non bloquant avec réessai, indicateur *dirty* (bouton + onglet) | | 2026-09-29 | **Lot 3** livré : le Tableau de bord passe dans un **inspecteur droit repliable** (grille toujours visible), en-tête d'inspecteur avec entrée **Assistant IA** et fermeture, responsive < 900 px | +| 2026-09-29 | **Lot 4** livré : **undo/redo** (boutons + `Ctrl+Z`/`Ctrl+Maj+Z`/`Ctrl+Y`), chargement par `IntersectionObserver`, **ARIA** `role="grid"` ; le découpage modulaire est reporté en A5 | diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index b8cb0c1..4817896 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1113,6 +1113,19 @@ export function renderXlsxViewer(area, data) { } } }; + // #154-A4 — ARIA grid semantics (the backend renders a plain ). + 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 ? `
${sheets[0]?.html || data.html || ""}
` : 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(` + + + `); actionGroups.push(` @@ -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); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index ee298f4..20d9029 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 228ec18..0953100 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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", diff --git a/package.json b/package.json index 24fefa9..a39b821 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.42.0", + "version": "2.43.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs index 5d8c649..dca7c76 100644 --- a/tests/frontend/xlsx-viewer.test.mjs +++ b/tests/frontend/xlsx-viewer.test.mjs @@ -842,6 +842,7 @@ await test("a read-only workbook shows the read-only pill and offers no editing" assert.ok(pill.textContent.includes(FR["xlsx.readonly_badge"]), pill.textContent); assert.equal(area.querySelector("#xlsx-tab-add"), null, "no + for read-only"); assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure for read-only"); + assert.equal(area.querySelector("#xlsx-undo-btn"), null, "no history for read-only"); assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("contenteditable"), null, "cells are not editable"); }); @@ -870,6 +871,42 @@ await test("the command bar exposes grouped actions with a primary save", () => assert.ok(area.querySelector(".xlsx-cmd-group[data-group='file']")); }); +// ── #154-A4 — undo/redo, ARIA ─────────────────────────────────────────────── + +await test("undo/redo restores cell edits from the command stack", () => { + const area = mount(); + const td = area.querySelector('td[data-cell="A1"]'); + const undoBtn = area.querySelector("#xlsx-undo-btn"); + const redoBtn = area.querySelector("#xlsx-redo-btn"); + assert.equal(undoBtn.disabled, true, "nothing to undo yet"); + td.focus(); + editCell(area, "A1", "42"); + td.dispatchEvent(new w.Event("blur")); + assert.equal(undoBtn.disabled, false, "undo becomes available after a committed edit"); + undoBtn.click(); + assert.equal(td.textContent, "100", "undo restores the original value"); + assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0, "the cell is clean again"); + assert.equal(redoBtn.disabled, false, "redo becomes available"); + redoBtn.click(); + assert.equal(td.textContent, "42", "redo reapplies the edit"); + assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1, "the edit is dirty again"); +}); + +await test("a no-op undo/redo pair leaves the buttons disabled", () => { + const area = mount(); + assert.equal(area.querySelector("#xlsx-undo-btn").disabled, true); + assert.equal(area.querySelector("#xlsx-redo-btn").disabled, true); +}); + +await test("the grid exposes ARIA grid semantics", () => { + const area = mount(); + const table = area.querySelector(".xlsx-table"); + assert.equal(table.getAttribute("role"), "grid"); + assert.equal(area.querySelector(".xlsx-table thead th").getAttribute("role"), "columnheader"); + assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("role"), "gridcell"); + assert.equal(area.querySelector("th.xlsx-rownum").getAttribute("role"), "rowheader"); +}); + // ── Report ────────────────────────────────────────────────────────────────── console.log(`\n${passCount}/${testCount} tests passed\n`); process.exit(passCount === testCount ? 0 : 1);