Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79202e698 |
+17
-1
@@ -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
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.52.0"
|
||||
version = "2.53.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user