Compare commits

...
1 Commits
Author SHA1 Message Date
bruno d79202e698 feat: menus tableur a icones, fermeture au focus et polish mobile #179
CI / lint (push) Successful in 2m52s
CI / security (push) Successful in 2m11s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 2m0s
CI / e2e (push) Failing after 16m50s
2026-10-04 19:27:50 -04:00
18 changed files with 885 additions and 91 deletions
+17 -1
View File
@@ -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.52.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.53.0**.
---
@@ -14,6 +14,22 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.53.0] — 2026-10-04
### Ajouté
- **#179 — Éditeur tableur : icônes des menus & boutons, fermeture au focus, polish mobile**
- Une icône Lucide par entrée (grille, Structure, Mise en forme, Export) et
sur les boutons Enregistrer / « + » ; suppressions marquées danger,
`aria-haspopup`/`aria-expanded` sur les boutons à menu.
- Tout menu se ferme à la perte de focus (clic extérieur, `focusout`,
`Échap`, défilement, redimensionnement) via `trackDismissable()` ;
navigation clavier (`↑`/`↓`/`Home`/`End`) dans le menu grille.
- Mobile : entrées ≥ 44 px (`pointer: coarse`), menus bornés au viewport,
ruban sans débordement horizontal.
---
## [2.52.0] — 2026-10-04
### Ajouté
+3 -3
View File
@@ -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.52.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.53.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.52.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.53.0).
---
*Projet : ObsiGate | Version : 2.52.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.53.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.52.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.53.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.52.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.53.0).
---
*Project: ObsiGate | Version: 2.52.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.53.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.52.0
2.53.0
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.52.0"
version = "2.53.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.52.0"
version = "2.53.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.52.0",
"version": "2.53.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+2 -1
View File
@@ -253,7 +253,8 @@ ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
droit** (ou un **appui long** sur mobile) ouvre un menu : copier, couper,
coller, insérer/supprimer une ligne ou une colonne, trier A→Z / Z→A, effacer le
contenu.
contenu. Chaque entrée porte une **icône** ; le menu se referme dès qu'il perd
le focus (clic ailleurs, `Échap`) et se parcourt au clavier (`↑`/`↓`).
**Tri, filtre, recherche, export** — le tri (ascendant / descendant) s'applique
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent et
+25 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.52.0 | **Dernière mise à jour :** 2026-10-04
> **Version :** 2.53.0 | **Dernière mise à jour :** 2026-10-04
> **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)**
@@ -157,6 +157,30 @@
---
## 🔵 En cours — Polish menus tableur
### 179. Éditeur tableur — icônes des menus & boutons, fermeture au focus, polish mobile
- **Effort :** 1-2 jours | **Impact :** 🟡
- **Statut :** 🔵 en cours depuis 2026-10-04
- **Fiche :** [features/xlsx-menus-179.md](./features/xlsx-menus-179.md)
- **Description :** la grille (#155), les menus Structure/Mise en forme/Export
(#156-A8/A11/A14) et les boutons du ruban (#154) sont textuels et restent
ouverts quand le focus quitte le menu. Ajouter des icônes Lucide
représentatives, fermer tout menu à la perte de focus (clic extérieur,
`focusout`, `Échap`, défilement), naviguer au clavier dans le menu grille, et
garantir des cibles tactiles ≥ 44 px + menus dans le viewport en mobile.
- **Sous-tâches :**
- [x] **A1** Icônes des entrées du menu contextuel de la grille (+ `item.icon`)
- [x] **A2** Icônes des menus Structure / Mise en forme / Export + boutons
Enregistrer / « + » onglet
- [x] **A3** Fermeture à la perte de focus (helper partageable : extérieur,
`focusout`, `Échap`, scroll/resize) + navigation clavier du menu grille
- [x] **A4** Polish mobile (44 px tactile, menus bornés au viewport) + tests
JSDOM + E2E `xlsx-viewer.spec.js`
---
## ✅ Terminé — Navigation
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
+72
View File
@@ -0,0 +1,72 @@
# #179 — Éditeur tableur : icônes des menus & boutons, fermeture au focus, polish mobile
> **Statut :** 🔵 en cours (2026-10-04) | **Effort :** 1-2 jours | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) · [#156](./xlsx-editor-completeness.md)
## 1. Constat
La grille (#155), les menus Structure / Mise en forme / Export (#156-A8/A11/A14)
et les boutons du ruban (#154) étaient textuels, et les menus Structure / Mise
en forme / Export **restaient ouverts** quand le focus les quittait (seul le
menu grille se fermait, au clic extérieur / `Échap` uniquement).
## 2. Livré (A1-A4)
### A1 — Icônes du menu contextuel de la grille
Chaque entrée de `buildContextItems` (`viewer.js`) porte une icône Lucide
(`scissors`, `copy`, `clipboard-paste`, `rows`/`columns`, `trash-2` danger,
`arrow-up`/`arrow-down`, `eraser`). Rendu dans `xlsx/context-menu.js`
(`<i data-lucide>` + `<span class="xlsx-menu-label">`, label en `textContent`
— jamais d'HTML injecté), hydraté par `safeCreateIcons()` (jamais
`lucide.createIcons()` direct).
### A2 — Icônes Structure / Mise en forme / Export + boutons
- Structure : `file-plus`, `pencil`, `copy`, `trash-2` (danger), `rows`,
`columns` ; suppressions marquées `.xlsx-context-danger`.
- Mise en forme : `bold`/`italic`/`underline`, `remove-formatting`,
`align-left/center/right`, `palette`/`paint-bucket` (sélecteurs natifs),
`type`/`hash`/`percent`/`euro`/`calendar`/`pilcrow` (formats),
`combine`/`ungroup`, `snowflake`/`sun` (figer/libérer),
`move-horizontal`/`move-vertical` (largeur/hauteur).
- Export : `file-text` (MD), `code` (HTML), `printer` (impression).
- Ruban : bouton Enregistrer (`save` + libellé), onglet « + » (`plus`),
`aria-haspopup="menu"` / `aria-expanded` sur les 3 boutons à menu.
### A3 — Fermeture à la perte de focus + clavier
- `trackDismissable(menu, { toggle, onClose })` dans `context-menu.js` :
clic extérieur (le toggle garde ses propres clics), `Échap`, `focusout`
(vers menu **et** toggle exemptés), scroll (capture), resize ; `dismiss()`
idempotent qui débranche tout (pas de fuite entre re-rendus).
- Menu grille : `focusout` + scroll/resize en plus de l'existant ;
`enableArrowNav()` (`↓`/`↑`/`Home`/`End`, désactivés sautés), autofocus de
la 1ʳᵉ entrée à l'ouverture (`preventScroll`), `:focus` toujours visible.
- Menus Structure / Mise en forme / Export branchés sur le helper.
### A4 — Polish mobile
- `@media (pointer: coarse)` : entrées ≥ 44 px, police 0,9 rem.
- Menus bornés au viewport (grille : `min(320px, 86vw)` + `max-height: 80vh`
scrollable ; structure : `min(320px, 92vw)` ; export : `86vw`).
- Le ruban s'enroule déjà (`flex-wrap`), l'inspecteur s'empile ≤ 900 px :
vérifié sans débordement horizontal à 393 px (E2E projet mobile).
## 3. Tests
- `tests/frontend/xlsx-menus.test.mjs` (11) : rendu icônes + XSS, autofocus,
navigation clavier, `Échap` / clic / `focusout` / scroll / resize,
`trackDismissable`, icônes du ruban.
- `tests/frontend/xlsx-viewer.test.mjs` (+6, 113 total) : icônes et fermetures
au niveau viewer réel.
- `tests/e2e/xlsx-viewer.spec.js` (+5) : icônes par entrée, fermeture clic/`Échap`,
Structure au focus perdu, Export au clic extérieur, mobile (ruban, 44 px,
viewport) — ce dernier `skip` hors viewport ≤ 768 px.
## 4. Limites assumées
- Noms d'icônes de la gamme Lucide 0.344.0 (CDN `unpkg`) : si le CDN est
injoignable, les libellés restent (dégradation texte, jamais de crash).
- Pas de nouveaux libellés i18n (icônes + `aria-expanded` uniquement).
+147 -61
View File
@@ -19,7 +19,7 @@ import { openAssistant } from './ai-fab.js';
import { parseRef, columnName, findTd, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
import { buildCommandBar } from './xlsx/command-bar.js';
import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.js';
import { openContextMenu, closeContextMenu } from './xlsx/context-menu.js';
import { openContextMenu, closeContextMenu, trackDismissable, enableArrowNav } from './xlsx/context-menu.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -2284,25 +2284,42 @@ export function renderXlsxViewer(area, data) {
// Structure menu: built on demand, positioned under the button.
// (.csv / read-only workbooks have no such button — the menu block above
// is skipped for them.)
// #179 — tracked dismissal of the structure menu (outside, focus loss…).
let dismissStructureMenu = null;
const structureBtn = area.querySelector("#xlsx-structure-btn");
if (structureBtn) structureBtn.addEventListener("click", (e) => {
const old = area.querySelector(".xlsx-structure-menu");
if (old) { old.remove(); return; }
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; return; }
const idx = visibleSheetIndex();
const sheetName = sheets[idx]?.name || "";
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
const parsed = parseRef(activeRef) || { row: 1, col: 1 };
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu";
const item = (label, fn) => {
menu.setAttribute("role", "menu");
// #179 — representative icon per entry; destructive ones read as danger.
const item = (iconName, label, fn, danger = false) => {
const b = document.createElement("button");
b.type = "button";
b.className = "btn-action xlsx-structure-item";
b.textContent = label;
b.addEventListener("click", () => { menu.remove(); fn(); });
b.className = `btn-action xlsx-structure-item${danger ? " xlsx-context-danger" : ""}`;
b.setAttribute("role", "menuitem");
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
b.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = label;
b.appendChild(lab);
b.addEventListener("click", () => {
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; }
fn();
});
menu.appendChild(b);
};
item(t("xlsx.sheet_add"), async () => {
item("file-plus", t("xlsx.sheet_add"), async () => {
const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
@@ -2311,7 +2328,7 @@ export function renderXlsxViewer(area, data) {
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_rename"), async () => {
item("pencil", t("xlsx.sheet_rename"), async () => {
const to = await showPrompt({
title: t("xlsx.sheet_rename"),
message: t("xlsx.structure_prompt_rename"),
@@ -2321,7 +2338,7 @@ export function renderXlsxViewer(area, data) {
const actions = [{ op: "sheet_rename", from: sheetName, to }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_duplicate"), async () => {
item("copy", t("xlsx.sheet_duplicate"), async () => {
const as = await showPrompt({
title: t("xlsx.sheet_duplicate"),
message: t("xlsx.structure_prompt_add"),
@@ -2331,7 +2348,7 @@ export function renderXlsxViewer(area, data) {
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_delete"), async () => {
item("trash-2", t("xlsx.sheet_delete"), async () => {
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
const okDelete = await showConfirm({
title: t("xlsx.sheet_delete"),
@@ -2342,14 +2359,14 @@ export function renderXlsxViewer(area, data) {
if (!okDelete) return;
const actions = [{ op: "sheet_delete", name: sheetName }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
}, true);
if (parsed) {
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
item(t("xlsx.row_insert"), async () => {
item("rows", t("xlsx.row_insert"), async () => {
const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.row_delete"), async () => {
item("trash-2", t("xlsx.row_delete"), async () => {
const okRow = await showConfirm({
title: t("xlsx.row_delete"),
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
@@ -2359,12 +2376,12 @@ export function renderXlsxViewer(area, data) {
if (!okRow) return;
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.col_insert"), async () => {
}, true);
item("columns", t("xlsx.col_insert"), async () => {
const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.col_delete"), async () => {
item("trash-2", t("xlsx.col_delete"), async () => {
const okCol = await showConfirm({
title: t("xlsx.col_delete"),
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
@@ -2374,9 +2391,21 @@ export function renderXlsxViewer(area, data) {
if (!okCol) return;
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
}, true);
}
enableArrowNav(menu, ".xlsx-structure-item");
e.target.closest(".xlsx-toolbar").appendChild(menu);
safeCreateIcons();
structureBtn.setAttribute("aria-expanded", "true");
// #179 — close on outside click, focus loss, Escape, scroll, resize.
dismissStructureMenu = trackDismissable(menu, {
toggle: structureBtn,
onClose: () => {
dismissStructureMenu = null;
structureBtn.setAttribute("aria-expanded", "false");
menu.remove();
},
});
});
// ── #156-A11 — sortie : CSV / Markdown / HTML / impression ────────────
@@ -2491,14 +2520,34 @@ export function renderXlsxViewer(area, data) {
}
};
// #179 — the export menu used to stay open until its button was clicked
// again; it now closes on outside click, focus loss, Escape, scroll, resize.
let dismissExportMenu = null;
const exportMenu = area.querySelector("#xlsx-export-menu");
if (exportMenu) {
area.querySelector("#xlsx-export-btn").addEventListener("click", () => {
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
const exportBtn = area.querySelector("#xlsx-export-btn");
if (exportMenu && exportBtn) {
const hideExportMenu = () => {
if (dismissExportMenu) { dismissExportMenu(); dismissExportMenu = null; }
exportMenu.style.display = "none";
exportBtn.setAttribute("aria-expanded", "false");
};
exportBtn.addEventListener("click", () => {
if (exportMenu.style.display === "block") { hideExportMenu(); return; }
exportMenu.style.display = "block";
exportBtn.setAttribute("aria-expanded", "true");
dismissExportMenu = trackDismissable(exportMenu, {
toggle: exportBtn,
onClose: () => {
dismissExportMenu = null;
exportMenu.style.display = "none";
exportBtn.setAttribute("aria-expanded", "false");
},
});
});
enableArrowNav(exportMenu, "[data-xlsx-export]");
exportMenu.querySelectorAll("[data-xlsx-export]").forEach((item) => {
item.addEventListener("click", () => {
exportMenu.style.display = "none";
hideExportMenu();
if (item.dataset.xlsxExport === "print") printSheet();
else exportDocument(item.dataset.xlsxExport);
});
@@ -2575,19 +2624,36 @@ export function renderXlsxViewer(area, data) {
return Boolean(td && td.style.textDecoration === "underline");
};
// #179 — tracked dismissal of the format menu (outside, focus loss…).
let dismissFormatMenu = null;
const formatBtn = area.querySelector("#xlsx-format-btn");
if (formatBtn) formatBtn.addEventListener("click", () => {
const old = area.querySelector(".xlsx-format-menu");
if (old) { old.remove(); return; }
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; return; }
const sheetName = sheets[visibleSheetIndex()]?.name || "";
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu xlsx-format-menu";
const item = (label, fn) => {
menu.setAttribute("role", "menu");
// #179 — representative icon per entry.
const item = (iconName, label, fn) => {
const b = document.createElement("button");
b.type = "button";
b.className = "btn-action xlsx-structure-item";
b.textContent = label;
b.addEventListener("click", () => { menu.remove(); fn(); });
b.setAttribute("role", "menuitem");
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
b.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = label;
b.appendChild(lab);
b.addEventListener("click", () => {
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; }
fn();
});
menu.appendChild(b);
};
const sep = () => menu.appendChild(
@@ -2598,22 +2664,29 @@ export function renderXlsxViewer(area, data) {
]);
const numFormat = (fmt) => cellStyle({ number_format: fmt });
item(t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
item(t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
item(t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
item(t("xlsx.format_clear"), cellStyle({
item("bold", t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
item("italic", t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
item("underline", t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
item("remove-formatting", t("xlsx.format_clear"), cellStyle({
bold: false, italic: false, underline: false,
font_color: "", fill_color: "", align: "left", number_format: "General",
}));
sep();
item(t("xlsx.format_align_left"), cellStyle({ align: "left" }));
item(t("xlsx.format_align_center"), cellStyle({ align: "center" }));
item(t("xlsx.format_align_right"), cellStyle({ align: "right" }));
item("align-left", t("xlsx.format_align_left"), cellStyle({ align: "left" }));
item("align-center", t("xlsx.format_align_center"), cellStyle({ align: "center" }));
item("align-right", t("xlsx.format_align_right"), cellStyle({ align: "right" }));
sep();
// Colours come from the native picker; applied once when it closes.
const colour = (label, apply) => {
const colour = (iconName, label, apply) => {
const row = document.createElement("label");
row.className = "xlsx-format-colour";
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
row.appendChild(ic);
}
const span = document.createElement("span");
span.textContent = label;
const input = document.createElement("input");
@@ -2623,21 +2696,21 @@ export function renderXlsxViewer(area, data) {
row.appendChild(input);
menu.appendChild(row);
};
colour(t("xlsx.format_font_color"), (e) => runStyle([
colour("palette", t("xlsx.format_font_color"), (e) => runStyle([
{ op: "cell", sheet: sheetName, range: selectionRange(), style: { font_color: e.target.value } },
]));
colour(t("xlsx.format_fill_color"), (e) => runStyle([
colour("paint-bucket", t("xlsx.format_fill_color"), (e) => runStyle([
{ op: "cell", sheet: sheetName, range: selectionRange(), style: { fill_color: e.target.value } },
]));
sep();
item(t("xlsx.format_num_general"), numFormat("General"));
item(t("xlsx.format_num_number"), numFormat("0.00"));
item(t("xlsx.format_num_percent"), numFormat("0.0%"));
item(t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
item(t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
item(t("xlsx.format_num_text"), numFormat("@"));
item("type", t("xlsx.format_num_general"), numFormat("General"));
item("hash", t("xlsx.format_num_number"), numFormat("0.00"));
item("percent", t("xlsx.format_num_percent"), numFormat("0.0%"));
item("euro", t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
item("calendar", t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
item("pilcrow", t("xlsx.format_num_text"), numFormat("@"));
sep();
item(t("xlsx.format_merge"), () => {
item("combine", t("xlsx.format_merge"), () => {
const b = selectionBounds();
if (!b || !selection || selection.panel !== visiblePanel() || (b.r1 === b.r2 && b.c1 === b.c2)) {
showToast(t("xlsx.format_merge_needs_range"), "info");
@@ -2645,16 +2718,16 @@ export function renderXlsxViewer(area, data) {
}
runStyle([{ op: "merge", sheet: sheetName, range: rangeLabel() }]);
});
item(t("xlsx.format_unmerge"), () => {
item("ungroup", t("xlsx.format_unmerge"), () => {
runStyle([{ op: "unmerge", sheet: sheetName, range: selectionRange() }]);
});
sep();
item(t("xlsx.format_freeze"), () => {
item("snowflake", t("xlsx.format_freeze"), () => {
runStyle([{ op: "freeze", sheet: sheetName, cell: selectionRange().split(":")[0] }]);
});
item(t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }]));
item("sun", t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }]));
sep();
item(t("xlsx.format_col_width"), async () => {
item("move-horizontal", t("xlsx.format_col_width"), async () => {
const b = selectionBounds();
const cols = [];
if (b && selection && selection.panel === visiblePanel()) {
@@ -2672,7 +2745,7 @@ export function renderXlsxViewer(area, data) {
if (!answer || !Number.isFinite(width) || width < 0 || width > 255) return;
runStyle(cols.map((col) => ({ op: "col_width", sheet: sheetName, col, width })));
});
item(t("xlsx.format_row_height"), async () => {
item("move-vertical", t("xlsx.format_row_height"), async () => {
const b = selectionBounds();
const rows = [];
if (b && selection && selection.panel === visiblePanel()) {
@@ -2690,7 +2763,19 @@ export function renderXlsxViewer(area, data) {
if (!answer || !Number.isFinite(height) || height < 0 || height > 409) return;
runStyle(rows.map((row) => ({ op: "row_height", sheet: sheetName, row, height })));
});
enableArrowNav(menu, ".xlsx-structure-item");
formatBtn.closest(".xlsx-toolbar").appendChild(menu);
safeCreateIcons();
formatBtn.setAttribute("aria-expanded", "true");
// #179 — close on outside click, focus loss, Escape, scroll, resize.
dismissFormatMenu = trackDismissable(menu, {
toggle: formatBtn,
onClose: () => {
dismissFormatMenu = null;
formatBtn.setAttribute("aria-expanded", "false");
menu.remove();
},
});
});
// ══ #155 — Excel-like selection & context menu ═════════════════════════
@@ -3228,27 +3313,28 @@ export function renderXlsxViewer(area, data) {
const colCount = b.c2 - b.c1 + 1;
const items = [];
// #156-A5 — clipboard entries first, in the spreadsheet order.
items.push({ label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
items.push({ label: t("xlsx.copy"), onClick: () => copySelection() });
items.push({ label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
// #179 — every entry carries a representative Lucide icon.
items.push({ icon: "scissors", label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
items.push({ icon: "copy", label: t("xlsx.copy"), onClick: () => copySelection() });
items.push({ icon: "clipboard-paste", label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
items.push({ separator: true });
if (editable) {
items.push({ label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
items.push({ label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
items.push({ label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
items.push({ label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
items.push({ icon: "rows", label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
items.push({ icon: "rows", label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
items.push({ icon: "columns", label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
items.push({ icon: "columns", label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
items.push({ separator: true });
items.push({ label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
items.push({ label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
items.push({ icon: "trash-2", label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
items.push({ icon: "trash-2", label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
items.push({ separator: true });
}
if (!isCsv) {
items.push({ label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
items.push({ label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
items.push({ icon: "arrow-up", label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
items.push({ icon: "arrow-down", label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
}
if (editable) {
items.push({ separator: true });
items.push({ label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
items.push({ icon: "eraser", label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
}
return items;
};
+9 -9
View File
@@ -23,7 +23,7 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
).join("")}${editable
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}">+</button>`
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}"><i data-lucide="plus" style="width:14px;height:14px" aria-hidden="true"></i></button>`
: ""}</div>`;
// Status pills make the viewer's limits visible up front.
@@ -55,10 +55,10 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
}
if (editable) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}">
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}" aria-haspopup="menu" aria-expanded="false">
<i data-lucide="paintbrush" style="width:14px;height:14px"></i>
</button>
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}" aria-haspopup="menu" aria-expanded="false">
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
</button>
</span>`);
@@ -72,16 +72,16 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
</button>
<span class="export-dropdown xlsx-export-dropdown">
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}">
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}" aria-haspopup="menu" aria-expanded="false">
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
</button>
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none">
<button class="export-menu-item" type="button" data-xlsx-export="md">${escapeHtml(t("xlsx.export_md"))}</button>
<button class="export-menu-item" type="button" data-xlsx-export="html">${escapeHtml(t("xlsx.export_html"))}</button>
<button class="export-menu-item" type="button" data-xlsx-export="print">${escapeHtml(t("xlsx.print"))}</button>
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none" role="menu">
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="md"><i data-lucide="file-text" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_md"))}</span></button>
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="html"><i data-lucide="code" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_html"))}</span></button>
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="print"><i data-lucide="printer" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.print"))}</span></button>
</div>
</span>
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled><i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>
</span>`);
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
+127 -7
View File
@@ -1,9 +1,17 @@
/* ObsiGate — generic themed context menu for the XLSX grid (#155).
/* ObsiGate — generic themed context menu for the XLSX grid (#155, #179).
*
* Builds a small menu anchored at a screen position, closes it on outside
* click / Escape / window blur. Labels and callbacks come from the caller, so
* no i18n lives here.
* Builds a small menu anchored at a screen position. Labels, icons and
* callbacks come from the caller, so no i18n lives here.
*
* #179 — every menu entry carries a representative Lucide icon, the menu
* closes as soon as the focus leaves it (outside click, focusout, Escape,
* window blur, scroll, resize) and arrow keys walk through the entries.
* trackDismissable() exposes the same dismissal contract to the other
* spreadsheet menus (Structure, Mise en forme, Export), which keep their
* own markup.
*/
import { safeCreateIcons } from '../utils.js';
let _menu = null;
let _openedAt = 0;
let _dismissBound = false;
@@ -33,13 +41,106 @@ function bindDismiss() {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeContextMenu();
});
if (typeof window !== "undefined") window.addEventListener("blur", closeContextMenu);
// #179 — never linger once the focus moves outside the menu (toolbar
// button, editor, another panel…). A null relatedTarget (click on a
// non-focusable cell) is already covered by the click-outside handler.
document.addEventListener("focusout", (e) => {
if (!_menu) return;
const next = e.relatedTarget;
if (next && _menu.contains(next)) return;
closeContextMenu();
});
if (typeof window !== "undefined") {
window.addEventListener("blur", closeContextMenu);
// A scroll or resize detaches the viewport-anchored menu from its cell.
window.addEventListener("scroll", closeContextMenu, true);
window.addEventListener("resize", closeContextMenu);
}
}
/**
* Walk through a menu's buttons with the keyboard (#179).
* @param {HTMLElement} menuEl - menu container
* @param {string} itemSelector - CSS selector of the navigable entries
*/
export function enableArrowNav(menuEl, itemSelector) {
menuEl.addEventListener("keydown", (e) => {
const items = [...menuEl.querySelectorAll(itemSelector)].filter((b) => !b.disabled);
if (!items.length) return;
const idx = items.indexOf(document.activeElement);
let next = -1;
if (e.key === "ArrowDown") next = idx < 0 ? 0 : (idx + 1) % items.length;
else if (e.key === "ArrowUp") next = idx < 0 ? items.length - 1 : (idx - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
else return;
e.preventDefault();
items[next].focus();
});
}
/**
* Dismissal contract shared by the spreadsheet's other menus (#179).
* Closes on outside click (the toggle button owns its own clicks), Escape,
* focus leaving both the menu and its toggle, scroll and resize.
* @param {HTMLElement} menuEl - menu element to watch
* @param {object} opts
* @param {HTMLElement|null} opts.toggle - button opening the menu (exempt)
* @param {Function|null} opts.onClose - called once on dismissal (default: remove the element)
* @returns {Function} dismiss() — idempotent manual close (unbinds + onClose)
*/
export function trackDismissable(menuEl, { toggle = null, onClose = null } = {}) {
let done = false;
const unbind = () => {
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKey);
document.removeEventListener("focusout", onFocusOut);
if (typeof window !== "undefined") {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
}
};
const dismiss = () => {
if (done) return;
done = true;
unbind();
if (onClose) onClose();
else menuEl.remove();
};
const onDocClick = (e) => {
if (done) return;
if (!menuEl.isConnected) { done = true; unbind(); return; }
const t = e.target;
if (menuEl.contains(t)) return;
if (toggle && (toggle === t || toggle.contains(t))) return;
dismiss();
};
const onKey = (e) => {
if (e.key === "Escape" && menuEl.isConnected) dismiss();
};
const onFocusOut = (e) => {
if (done || !menuEl.isConnected) return;
const next = e.relatedTarget;
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
dismiss();
};
const onScroll = () => {
if (menuEl.isConnected) dismiss();
};
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
document.addEventListener("focusout", onFocusOut);
if (typeof window !== "undefined") {
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
}
return dismiss;
}
/**
* @param {number} x - viewport X of the pointer
* @param {number} y - viewport Y of the pointer
* @param {Array<{label?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
* @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
* @returns {HTMLElement} the menu element
*/
export function openContextMenu(x, y, items) {
@@ -52,6 +153,7 @@ export function openContextMenu(x, y, items) {
if (item.separator) {
const sep = document.createElement("div");
sep.className = "xlsx-context-sep";
sep.setAttribute("aria-hidden", "true");
menu.appendChild(sep);
continue;
}
@@ -59,7 +161,17 @@ export function openContextMenu(x, y, items) {
btn.type = "button";
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
btn.setAttribute("role", "menuitem");
btn.textContent = item.label;
if (item.icon) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", item.icon);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
btn.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = item.label;
btn.appendChild(lab);
btn.disabled = Boolean(item.disabled);
btn.addEventListener("click", () => {
closeContextMenu();
@@ -67,6 +179,7 @@ export function openContextMenu(x, y, items) {
});
menu.appendChild(btn);
}
enableArrowNav(menu, ".xlsx-context-item");
document.body.appendChild(menu);
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
@@ -81,5 +194,12 @@ export function openContextMenu(x, y, items) {
_menu = menu;
_openedAt = Date.now();
// Keyboard users land on the first entry right away; mouse users are
// unaffected (hover/click still work). preventScroll avoids yanking the grid.
const first = menu.querySelector(".xlsx-context-item:not(:disabled)");
if (first && typeof first.focus === "function") {
try { first.focus({ preventScroll: true }); } catch (_) { first.focus(); }
}
if (typeof safeCreateIcons === "function") safeCreateIcons();
return menu;
}
+46
View File
@@ -11332,6 +11332,7 @@ body.desktop-mode .editor-container {
top: 100%;
z-index: 1000;
min-width: 200px;
max-width: 86vw;
padding: 4px;
background: var(--bg, #fff);
border: 1px solid var(--border);
@@ -11410,6 +11411,7 @@ body.desktop-mode .editor-container {
flex-direction: column;
gap: 2px;
min-width: 240px;
max-width: min(320px, 92vw);
margin-top: 4px;
padding: 6px;
border: 1px solid var(--border);
@@ -11608,6 +11610,32 @@ body.desktop-mode .editor-container {
background: var(--border);
}
/* #179 — shared menu-entry icon (grid context menu, structure, format, export). */
.xlsx-menu-icon {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.xlsx-menu-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.xlsx-context-item,
.xlsx-structure-item,
.xlsx-export-menu .export-menu-item {
display: flex;
align-items: center;
gap: 8px;
}
.xlsx-context-item:focus:not(:disabled),
.xlsx-structure-item:focus:not(:disabled),
.xlsx-export-menu .export-menu-item:focus {
background: var(--bg-hover);
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
/* #155 — Excel-like range selection + right-click / long-press context menu. */
.xlsx-table tbody td.xlsx-selected {
background: var(--accent-bg);
@@ -11625,12 +11653,30 @@ body.desktop-mode .editor-container {
display: flex;
flex-direction: column;
min-width: 220px;
max-width: min(320px, 86vw);
max-height: 80vh;
overflow-y: auto;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 12px 32px var(--shadow, rgba(0, 0, 0, 0.35));
}
/* #179 — touch-sized entries + viewport-bound menus on coarse pointers. */
@media (pointer: coarse) {
.xlsx-context-item,
.xlsx-structure-item,
.xlsx-export-menu .export-menu-item {
min-height: 44px;
font-size: 0.9rem;
}
.xlsx-context-menu {
min-width: min(260px, 86vw);
}
.xlsx-structure-menu {
max-width: 92vw;
}
}
.xlsx-context-item {
text-align: left;
border: none;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.52.0",
"version": "2.53.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": {
+88
View File
@@ -20,6 +20,10 @@
* - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide,
* with the real A1 coordinates (#153 A9).
*
* Third block (#179) : menus carry one Lucide icon per entry and close on
* focus loss (outside click, Escape); mobile viewport checks the ribbon
* wrapping and 44px touch targets.
*
* The fixture is restored byte-for-byte in `afterAll` so a local run never
* dirties the working copy.
*
@@ -314,3 +318,87 @@ test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)',
expect(await textOf('C10')).toBe(src.c10);
});
});
// ── #179 — icônes des menus, fermeture au focus, polish mobile ──────────────
test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)', () => {
test('chaque entrée du menu contextuel porte une icône', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
const menu = page.locator('.xlsx-context-menu');
await expect(menu).toBeVisible();
// Icône rendue par Lucide (svg) ou à rendre (<i data-lucide>) si le CDN
// est injoignable — dans les deux cas l'entrée est iconisée.
for (const label of ['Couper', 'Copier', 'Coller']) {
const entry = menu.locator('.xlsx-context-item', { hasText: label });
await expect(entry.locator('i[data-lucide], svg')).toHaveCount(1);
}
await expect(menu.locator('.xlsx-context-item i[data-lucide="trash-2"], .xlsx-context-item svg.lucide-trash-2')).not.toHaveCount(0);
});
test('le menu contextuel se ferme au clic extérieur et à Échap', async ({ page }) => {
await login(page);
await openFixture(page);
const cell = page.locator('#content-area td[data-cell="A1"]');
await cell.click({ button: 'right' });
await expect(page.locator('.xlsx-context-menu')).toBeVisible();
await page.locator('#content-area .xlsx-table').click({ position: { x: 5, y: 5 } });
await expect(page.locator('.xlsx-context-menu')).toHaveCount(0);
await cell.click({ button: 'right' });
await expect(page.locator('.xlsx-context-menu')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.xlsx-context-menu')).toHaveCount(0);
});
test('le menu Structure se ferme quand le focus le quitte', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#xlsx-structure-btn').click();
const menu = page.locator('.xlsx-structure-menu');
await expect(menu).toBeVisible();
await expect(menu.locator('.xlsx-structure-item i[data-lucide], .xlsx-structure-item svg').first()).toBeAttached();
// Clic dans la grille = le focus quitte le menu → fermeture.
await page.locator('#content-area td[data-cell="A1"]').click();
await expect(page.locator('.xlsx-structure-menu')).toHaveCount(0);
await expect(page.locator('#xlsx-structure-btn')).toHaveAttribute('aria-expanded', 'false');
});
test('le menu Export se ferme au clic extérieur', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#xlsx-export-btn').click();
const menu = page.locator('#xlsx-export-menu');
await expect(menu).toBeVisible();
await page.locator('#content-area td[data-cell="A1"]').click();
await expect(menu).toBeHidden();
});
test('mobile : ruban sans débordement et cibles tactiles 44px', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 9999) > 768, 'Mobile viewport required');
await login(page);
await page.evaluate(() => window.TabManager.open('TestVault', FIXTURE));
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
// Le ruban s'enroule au lieu de déborder horizontalement.
const overflow = await page.evaluate(() => {
const bar = document.querySelector('#content-area .xlsx-cmdbar');
return bar ? bar.scrollWidth - bar.clientWidth : -1;
});
expect(overflow).toBeLessThanOrEqual(1);
// Menu contextuel (clic droit émulé) : tient dans le viewport, entrées ≥ 44px.
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
const menu = page.locator('.xlsx-context-menu');
await expect(menu).toBeVisible();
const box = await menu.boundingBox();
expect(box.width).toBeLessThanOrEqual(viewport.width - 8);
const itemBox = await menu.locator('.xlsx-context-item').first().boundingBox();
expect(itemBox.height).toBeGreaterThanOrEqual(44);
});
});
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env node
/**
* ObsiGate — JSDOM tests for the spreadsheet menus polish (ROADMAP #179).
*
* Covers frontend/js/xlsx/context-menu.js and frontend/js/xlsx/command-bar.js:
* - every grid-menu entry renders its representative Lucide icon + safe label;
* - the grid menu closes on Escape / outside click / focus loss / scroll /
* resize, and offers arrow-key navigation with autofocus on open;
* - trackDismissable() gives Structure / Mise en forme / Export the same
* dismissal contract (toggle exempt, custom onClose);
* - the ribbon carries the new icons (save, tab "+", export entries).
*
* Usage: node tests/frontend/xlsx-menus.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..");
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
const dom = new JSDOM(
`<!DOCTYPE html><html><body><div id="content-area"></div></body></html>`,
{ url: "http://localhost/", pretendToBeVisual: true }
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.DOMParser = w.DOMParser;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.FocusEvent = w.FocusEvent;
globalThis.KeyboardEvent = w.KeyboardEvent;
globalThis.MouseEvent = w.MouseEvent;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
// ── Module under test ───────────────────────────────────────────────────────
const ctxMenuUrl = pathToFileURL(
path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")
).href;
const cmdBarUrl = pathToFileURL(
path.join(REPO_ROOT, "frontend", "js", "xlsx", "command-bar.js")
).href;
const { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } =
await import(ctxMenuUrl);
const { buildCommandBar } = await import(cmdBarUrl);
// ── Mini runner ─────────────────────────────────────────────────────────────
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
closeContextMenu();
document.body.querySelectorAll(".tmp-menu").forEach((m) => m.remove());
try {
await fn();
passCount++;
console.log(` ✓ ${name}`);
} catch (e) {
console.error(` ✗ ${name}`);
console.error(e);
process.exitCode = 1;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const itemsOf = () => [
{ icon: "scissors", label: "Cut" },
{ icon: "copy", label: "Copy", disabled: true },
{ separator: true },
{ icon: "trash-2", label: "Delete", danger: true, onClick: () => {} },
];
// ── Rendering ───────────────────────────────────────────────────────────────
await test("entries render their icon, label, separator and danger style", () => {
const menu = openContextMenu(10, 10, itemsOf());
const btns = [...menu.querySelectorAll(".xlsx-context-item")];
assert.equal(btns.length, 3);
assert.equal(btns[0].querySelector("i").getAttribute("data-lucide"), "scissors");
assert.equal(btns[0].querySelector(".xlsx-menu-label").textContent, "Cut");
assert.equal(btns[1].disabled, true);
assert.ok(btns[2].classList.contains("xlsx-context-danger"));
assert.ok(menu.querySelector(".xlsx-context-sep"), "separator rendered");
assert.equal(menu.getAttribute("role"), "menu");
});
await test("labels are text, never HTML (XSS-safe)", () => {
const menu = openContextMenu(10, 10, [{ icon: "copy", label: "<img src=x onerror=1>" }]);
assert.equal(menu.querySelector("img"), null);
assert.equal(
menu.querySelector(".xlsx-menu-label").textContent,
"<img src=x onerror=1>"
);
});
await test("opening focuses the first enabled entry", () => {
const menu = openContextMenu(10, 10, itemsOf());
const first = menu.querySelectorAll(".xlsx-context-item")[0];
assert.equal(document.activeElement, first);
});
// ── Keyboard navigation ─────────────────────────────────────────────────────
await test("ArrowDown / ArrowUp / Home / End walk through enabled entries", () => {
const menu = openContextMenu(10, 10, itemsOf());
const btns = [...menu.querySelectorAll(".xlsx-context-item")];
const key = (k) =>
menu.dispatchEvent(new w.KeyboardEvent("keydown", { key: k, bubbles: true }));
key("ArrowDown"); // Cut (0, disabled Copy skipped) -> Delete (2)
assert.equal(document.activeElement, btns[2]);
key("ArrowDown"); // wraps to Cut
assert.equal(document.activeElement, btns[0]);
key("ArrowUp"); // back to Delete
assert.equal(document.activeElement, btns[2]);
key("Home");
assert.equal(document.activeElement, btns[0]);
key("End");
assert.equal(document.activeElement, btns[2]);
});
// ── Dismissal ───────────────────────────────────────────────────────────────
await test("Escape closes the menu", () => {
openContextMenu(10, 10, itemsOf());
assert.ok(isContextMenuOpen());
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
assert.ok(!isContextMenuOpen());
assert.equal(document.querySelector(".xlsx-context-menu"), null);
});
await test("outside click closes, inside click does not", async () => {
const menu = openContextMenu(10, 10, itemsOf());
await sleep(300); // past the long-press click guard
menu.querySelector(".xlsx-context-item").dispatchEvent(
new w.MouseEvent("click", { bubbles: true })
);
assert.ok(!isContextMenuOpen(), "item click closes");
openContextMenu(10, 10, itemsOf());
await sleep(300);
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.ok(!isContextMenuOpen(), "outside click closes");
});
await test("focus leaving the menu closes it, focus inside keeps it", () => {
const menu = openContextMenu(10, 10, itemsOf());
const [first, , last] = menu.querySelectorAll(".xlsx-context-item");
first.dispatchEvent(
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: last })
);
assert.ok(isContextMenuOpen(), "focus moving inside keeps the menu");
const outside = document.createElement("button");
document.body.appendChild(outside);
last.dispatchEvent(
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside })
);
assert.ok(!isContextMenuOpen(), "focus moving outside closes the menu");
outside.remove();
});
await test("scroll and resize close the menu", () => {
openContextMenu(10, 10, itemsOf());
window.dispatchEvent(new w.Event("scroll"));
assert.ok(!isContextMenuOpen(), "scroll closes");
openContextMenu(10, 10, itemsOf());
window.dispatchEvent(new w.Event("resize"));
assert.ok(!isContextMenuOpen(), "resize closes");
});
// ── trackDismissable (Structure / Format / Export) ──────────────────────────
function tmpMenu(toggle) {
const menu = document.createElement("div");
menu.className = "tmp-menu";
const b = document.createElement("button");
b.type = "button";
b.textContent = "Action";
menu.appendChild(b);
(toggle.closest("div") || document.body).appendChild(menu);
return menu;
}
await test("trackDismissable: toggle exempt, outside closes with onClose", async () => {
const host = document.createElement("div");
const toggle = document.createElement("button");
host.appendChild(toggle);
document.body.appendChild(host);
const menu = tmpMenu(toggle);
let closed = 0;
const dismiss = trackDismissable(menu, { toggle, onClose: () => { closed++; menu.remove(); } });
toggle.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(closed, 0, "toggle click does not dismiss");
assert.ok(menu.isConnected);
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(closed, 1, "outside click dismisses once");
assert.ok(!menu.isConnected);
dismiss(); // idempotent manual close
assert.equal(closed, 1);
host.remove();
});
await test("trackDismissable: Escape and focus loss dismiss", () => {
const toggle = document.createElement("button");
document.body.appendChild(toggle);
const menu = tmpMenu(toggle);
let closed = 0;
trackDismissable(menu, { toggle, onClose: () => { closed++; menu.remove(); } });
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
assert.equal(closed, 1, "Escape dismisses");
assert.ok(!menu.isConnected);
const menu2 = tmpMenu(toggle);
let closed2 = 0;
trackDismissable(menu2, { toggle, onClose: () => { closed2++; menu2.remove(); } });
const outside = document.createElement("input");
document.body.appendChild(outside);
menu2.querySelector("button").dispatchEvent(
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside })
);
assert.equal(closed2, 1, "focus loss dismisses");
outside.remove();
toggle.remove();
});
// ── Ribbon icons ────────────────────────────────────────────────────────────
await test("the ribbon carries save, tab-plus and export icons", () => {
const { actionsHtml, tabs } = buildCommandBar({
sheets: [{ name: "F1" }],
isCsv: false,
readOnly: false,
editable: true,
});
const host = document.createElement("div");
host.innerHTML = tabs + actionsHtml;
const saveIcon = host.querySelector("#xlsx-save-btn i[data-lucide]");
assert.ok(saveIcon, "save button has an icon");
assert.equal(saveIcon.getAttribute("data-lucide"), "save");
const plusIcon = host.querySelector("#xlsx-tab-add i[data-lucide]");
assert.ok(plusIcon, "tab + button has an icon");
assert.equal(plusIcon.getAttribute("data-lucide"), "plus");
const exportIcons = [...host.querySelectorAll("#xlsx-export-menu i[data-lucide]")].map((i) =>
i.getAttribute("data-lucide")
);
assert.deepEqual(exportIcons, ["file-text", "code", "printer"]);
});
console.log(`\n${passCount}/${testCount} passed`);
process.exit(process.exitCode || 0);
+89
View File
@@ -1867,6 +1867,95 @@ await test("the find scans every sheet and steps across tabs (#156-A11)", () =>
assert.ok(area.querySelector('.xlsx-panel[data-sheet="1"] mark.xlsx-find-current'), "the current hit sits in sheet 2");
});
// ── #179 — menu icons + dismissal at viewer level ─────────────────────────
await test("the grid context menu shows one icon per entry (#179-A1)", () => {
const area = mount();
const menu = openMenuOn(area, "A1");
assert.ok(menu, "menu opens on right-click");
const items = [...menu.querySelectorAll(".xlsx-context-item")];
assert.ok(items.length > 3, "several entries");
for (const b of items) {
const icon = b.querySelector("i[data-lucide]");
assert.ok(icon, `"${b.textContent}" carries an icon`);
assert.match(icon.getAttribute("data-lucide"), /^[a-z0-9-]+$/);
}
assert.equal(
menu.querySelector(".xlsx-context-item .xlsx-menu-label").textContent,
FR["xlsx.cut"]
);
const danger = items.find(
(b) => b.textContent === FR["xlsx.delete_rows"].replace("{n}", "1")
);
assert.ok(danger && danger.querySelector('i[data-lucide="trash-2"]'), "delete reads as danger");
});
await test("the grid context menu closes when the focus leaves it (#179-A3)", () => {
const area = mount();
openMenuOn(area, "A1");
assert.ok(document.querySelector(".xlsx-context-menu"), "menu open");
const first = document.querySelector(".xlsx-context-item");
const outside = document.createElement("button");
document.body.appendChild(outside);
first.dispatchEvent(new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside }));
assert.equal(document.querySelector(".xlsx-context-menu"), null, "menu closed on focus loss");
outside.remove();
});
await test("the structure menu shows icons and closes on outside click (#179-A2/A3)", () => {
const area = mount();
area.querySelector("#xlsx-structure-btn").click();
const menu = area.querySelector(".xlsx-structure-menu");
assert.ok(menu, "structure menu opens");
const icons = [...menu.querySelectorAll(".xlsx-structure-item i[data-lucide]")].map((i) =>
i.getAttribute("data-lucide")
);
assert.ok(icons.includes("file-plus"), "sheet add icon");
assert.ok(icons.includes("trash-2"), "delete icon");
assert.equal(
area.querySelector("#xlsx-structure-btn").getAttribute("aria-expanded"),
"true"
);
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(area.querySelector(".xlsx-structure-menu"), null, "outside click closes");
assert.equal(
area.querySelector("#xlsx-structure-btn").getAttribute("aria-expanded"),
"false"
);
});
await test("the format menu shows icons and closes on Escape (#179-A2/A3)", () => {
const area = mount();
area.querySelector("#xlsx-format-btn").click();
const menu = area.querySelector(".xlsx-format-menu");
assert.ok(menu, "format menu opens");
const find = (icon) => menu.querySelector(`.xlsx-structure-item i[data-lucide="${icon}"]`);
assert.ok(find("bold"), "bold icon");
assert.ok(find("align-center"), "align icon");
assert.ok(menu.querySelector('.xlsx-format-colour i[data-lucide="palette"]'), "font colour icon");
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
assert.equal(area.querySelector(".xlsx-format-menu"), null, "Escape closes");
});
await test("the export menu closes on outside click (#179-A3)", () => {
const area = mount();
area.querySelector("#xlsx-export-btn").click();
assert.equal(area.querySelector("#xlsx-export-menu").style.display, "block", "menu opens");
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(area.querySelector("#xlsx-export-menu").style.display, "none", "outside click closes");
});
await test("the ribbon save and tab + buttons carry icons (#179-A2)", () => {
const area = mount();
assert.equal(
area.querySelector('#xlsx-save-btn i[data-lucide]').getAttribute("data-lucide"),
"save"
);
assert.equal(
area.querySelector('#xlsx-tab-add i[data-lucide]').getAttribute("data-lucide"),
"plus"
);
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);