feat: menus tableur a icones, fermeture au focus et polish mobile #179
This commit is contained in:
+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).
|
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
|
> **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
|
## [2.52.0] — 2026-10-04
|
||||||
|
|
||||||
### Ajouté
|
### 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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.52.0"
|
version = "2.53.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.52.0"
|
version = "2.53.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.52.0",
|
"version": "2.53.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"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
|
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,
|
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
|
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
|
**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
|
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent et
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **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
|
## ✅ Terminé — Navigation
|
||||||
|
|
||||||
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
|
### 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 { parseRef, columnName, findTd, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
|
||||||
import { buildCommandBar } from './xlsx/command-bar.js';
|
import { buildCommandBar } from './xlsx/command-bar.js';
|
||||||
import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.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 ────────────────────────────────────────────────────
|
// ── Multi-format export ────────────────────────────────────────────────────
|
||||||
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
|
// 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.
|
// Structure menu: built on demand, positioned under the button.
|
||||||
// (.csv / read-only workbooks have no such button — the menu block above
|
// (.csv / read-only workbooks have no such button — the menu block above
|
||||||
// is skipped for them.)
|
// is skipped for them.)
|
||||||
|
// #179 — tracked dismissal of the structure menu (outside, focus loss…).
|
||||||
|
let dismissStructureMenu = null;
|
||||||
const structureBtn = area.querySelector("#xlsx-structure-btn");
|
const structureBtn = area.querySelector("#xlsx-structure-btn");
|
||||||
if (structureBtn) structureBtn.addEventListener("click", (e) => {
|
if (structureBtn) structureBtn.addEventListener("click", (e) => {
|
||||||
const old = area.querySelector(".xlsx-structure-menu");
|
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; return; }
|
||||||
if (old) { old.remove(); return; }
|
|
||||||
const idx = visibleSheetIndex();
|
const idx = visibleSheetIndex();
|
||||||
const sheetName = sheets[idx]?.name || "";
|
const sheetName = sheets[idx]?.name || "";
|
||||||
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
|
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
|
||||||
const parsed = parseRef(activeRef) || { row: 1, col: 1 };
|
const parsed = parseRef(activeRef) || { row: 1, col: 1 };
|
||||||
const menu = document.createElement("div");
|
const menu = document.createElement("div");
|
||||||
menu.className = "xlsx-structure-menu";
|
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");
|
const b = document.createElement("button");
|
||||||
b.type = "button";
|
b.type = "button";
|
||||||
b.className = "btn-action xlsx-structure-item";
|
b.className = `btn-action xlsx-structure-item${danger ? " xlsx-context-danger" : ""}`;
|
||||||
b.textContent = label;
|
b.setAttribute("role", "menuitem");
|
||||||
b.addEventListener("click", () => { menu.remove(); fn(); });
|
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);
|
menu.appendChild(b);
|
||||||
};
|
};
|
||||||
item(t("xlsx.sheet_add"), async () => {
|
item("file-plus", t("xlsx.sheet_add"), async () => {
|
||||||
const name = await showPrompt({
|
const name = await showPrompt({
|
||||||
title: t("xlsx.sheet_add"),
|
title: t("xlsx.sheet_add"),
|
||||||
message: t("xlsx.structure_prompt_add"),
|
message: t("xlsx.structure_prompt_add"),
|
||||||
@@ -2311,7 +2328,7 @@ export function renderXlsxViewer(area, data) {
|
|||||||
const actions = [{ op: "sheet_add", name }];
|
const actions = [{ op: "sheet_add", name }];
|
||||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
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({
|
const to = await showPrompt({
|
||||||
title: t("xlsx.sheet_rename"),
|
title: t("xlsx.sheet_rename"),
|
||||||
message: t("xlsx.structure_prompt_rename"),
|
message: t("xlsx.structure_prompt_rename"),
|
||||||
@@ -2321,7 +2338,7 @@ export function renderXlsxViewer(area, data) {
|
|||||||
const actions = [{ op: "sheet_rename", from: sheetName, to }];
|
const actions = [{ op: "sheet_rename", from: sheetName, to }];
|
||||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
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({
|
const as = await showPrompt({
|
||||||
title: t("xlsx.sheet_duplicate"),
|
title: t("xlsx.sheet_duplicate"),
|
||||||
message: t("xlsx.structure_prompt_add"),
|
message: t("xlsx.structure_prompt_add"),
|
||||||
@@ -2331,7 +2348,7 @@ export function renderXlsxViewer(area, data) {
|
|||||||
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
|
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
|
||||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
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; }
|
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
|
||||||
const okDelete = await showConfirm({
|
const okDelete = await showConfirm({
|
||||||
title: t("xlsx.sheet_delete"),
|
title: t("xlsx.sheet_delete"),
|
||||||
@@ -2342,14 +2359,14 @@ export function renderXlsxViewer(area, data) {
|
|||||||
if (!okDelete) return;
|
if (!okDelete) return;
|
||||||
const actions = [{ op: "sheet_delete", name: sheetName }];
|
const actions = [{ op: "sheet_delete", name: sheetName }];
|
||||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||||
});
|
}, true);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
|
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 }];
|
const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }];
|
||||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
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({
|
const okRow = await showConfirm({
|
||||||
title: t("xlsx.row_delete"),
|
title: t("xlsx.row_delete"),
|
||||||
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
|
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
|
||||||
@@ -2359,12 +2376,12 @@ export function renderXlsxViewer(area, data) {
|
|||||||
if (!okRow) return;
|
if (!okRow) return;
|
||||||
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
|
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
|
||||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||||
});
|
}, true);
|
||||||
item(t("xlsx.col_insert"), async () => {
|
item("columns", t("xlsx.col_insert"), async () => {
|
||||||
const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
|
const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
|
||||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
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({
|
const okCol = await showConfirm({
|
||||||
title: t("xlsx.col_delete"),
|
title: t("xlsx.col_delete"),
|
||||||
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
|
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
|
||||||
@@ -2374,9 +2391,21 @@ export function renderXlsxViewer(area, data) {
|
|||||||
if (!okCol) return;
|
if (!okCol) return;
|
||||||
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
|
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
|
||||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||||
});
|
}, true);
|
||||||
}
|
}
|
||||||
|
enableArrowNav(menu, ".xlsx-structure-item");
|
||||||
e.target.closest(".xlsx-toolbar").appendChild(menu);
|
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 ────────────
|
// ── #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");
|
const exportMenu = area.querySelector("#xlsx-export-menu");
|
||||||
if (exportMenu) {
|
const exportBtn = area.querySelector("#xlsx-export-btn");
|
||||||
area.querySelector("#xlsx-export-btn").addEventListener("click", () => {
|
if (exportMenu && exportBtn) {
|
||||||
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
|
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) => {
|
exportMenu.querySelectorAll("[data-xlsx-export]").forEach((item) => {
|
||||||
item.addEventListener("click", () => {
|
item.addEventListener("click", () => {
|
||||||
exportMenu.style.display = "none";
|
hideExportMenu();
|
||||||
if (item.dataset.xlsxExport === "print") printSheet();
|
if (item.dataset.xlsxExport === "print") printSheet();
|
||||||
else exportDocument(item.dataset.xlsxExport);
|
else exportDocument(item.dataset.xlsxExport);
|
||||||
});
|
});
|
||||||
@@ -2575,19 +2624,36 @@ export function renderXlsxViewer(area, data) {
|
|||||||
return Boolean(td && td.style.textDecoration === "underline");
|
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");
|
const formatBtn = area.querySelector("#xlsx-format-btn");
|
||||||
if (formatBtn) formatBtn.addEventListener("click", () => {
|
if (formatBtn) formatBtn.addEventListener("click", () => {
|
||||||
const old = area.querySelector(".xlsx-format-menu");
|
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; return; }
|
||||||
if (old) { old.remove(); return; }
|
|
||||||
const sheetName = sheets[visibleSheetIndex()]?.name || "";
|
const sheetName = sheets[visibleSheetIndex()]?.name || "";
|
||||||
const menu = document.createElement("div");
|
const menu = document.createElement("div");
|
||||||
menu.className = "xlsx-structure-menu xlsx-format-menu";
|
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");
|
const b = document.createElement("button");
|
||||||
b.type = "button";
|
b.type = "button";
|
||||||
b.className = "btn-action xlsx-structure-item";
|
b.className = "btn-action xlsx-structure-item";
|
||||||
b.textContent = label;
|
b.setAttribute("role", "menuitem");
|
||||||
b.addEventListener("click", () => { menu.remove(); fn(); });
|
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);
|
menu.appendChild(b);
|
||||||
};
|
};
|
||||||
const sep = () => menu.appendChild(
|
const sep = () => menu.appendChild(
|
||||||
@@ -2598,22 +2664,29 @@ export function renderXlsxViewer(area, data) {
|
|||||||
]);
|
]);
|
||||||
const numFormat = (fmt) => cellStyle({ number_format: fmt });
|
const numFormat = (fmt) => cellStyle({ number_format: fmt });
|
||||||
|
|
||||||
item(t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
|
item("bold", t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
|
||||||
item(t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
|
item("italic", t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
|
||||||
item(t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
|
item("underline", t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
|
||||||
item(t("xlsx.format_clear"), cellStyle({
|
item("remove-formatting", t("xlsx.format_clear"), cellStyle({
|
||||||
bold: false, italic: false, underline: false,
|
bold: false, italic: false, underline: false,
|
||||||
font_color: "", fill_color: "", align: "left", number_format: "General",
|
font_color: "", fill_color: "", align: "left", number_format: "General",
|
||||||
}));
|
}));
|
||||||
sep();
|
sep();
|
||||||
item(t("xlsx.format_align_left"), cellStyle({ align: "left" }));
|
item("align-left", t("xlsx.format_align_left"), cellStyle({ align: "left" }));
|
||||||
item(t("xlsx.format_align_center"), cellStyle({ align: "center" }));
|
item("align-center", t("xlsx.format_align_center"), cellStyle({ align: "center" }));
|
||||||
item(t("xlsx.format_align_right"), cellStyle({ align: "right" }));
|
item("align-right", t("xlsx.format_align_right"), cellStyle({ align: "right" }));
|
||||||
sep();
|
sep();
|
||||||
// Colours come from the native picker; applied once when it closes.
|
// 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");
|
const row = document.createElement("label");
|
||||||
row.className = "xlsx-format-colour";
|
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");
|
const span = document.createElement("span");
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
@@ -2623,21 +2696,21 @@ export function renderXlsxViewer(area, data) {
|
|||||||
row.appendChild(input);
|
row.appendChild(input);
|
||||||
menu.appendChild(row);
|
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 } },
|
{ 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 } },
|
{ op: "cell", sheet: sheetName, range: selectionRange(), style: { fill_color: e.target.value } },
|
||||||
]));
|
]));
|
||||||
sep();
|
sep();
|
||||||
item(t("xlsx.format_num_general"), numFormat("General"));
|
item("type", t("xlsx.format_num_general"), numFormat("General"));
|
||||||
item(t("xlsx.format_num_number"), numFormat("0.00"));
|
item("hash", t("xlsx.format_num_number"), numFormat("0.00"));
|
||||||
item(t("xlsx.format_num_percent"), numFormat("0.0%"));
|
item("percent", t("xlsx.format_num_percent"), numFormat("0.0%"));
|
||||||
item(t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
|
item("euro", t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
|
||||||
item(t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
|
item("calendar", t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
|
||||||
item(t("xlsx.format_num_text"), numFormat("@"));
|
item("pilcrow", t("xlsx.format_num_text"), numFormat("@"));
|
||||||
sep();
|
sep();
|
||||||
item(t("xlsx.format_merge"), () => {
|
item("combine", t("xlsx.format_merge"), () => {
|
||||||
const b = selectionBounds();
|
const b = selectionBounds();
|
||||||
if (!b || !selection || selection.panel !== visiblePanel() || (b.r1 === b.r2 && b.c1 === b.c2)) {
|
if (!b || !selection || selection.panel !== visiblePanel() || (b.r1 === b.r2 && b.c1 === b.c2)) {
|
||||||
showToast(t("xlsx.format_merge_needs_range"), "info");
|
showToast(t("xlsx.format_merge_needs_range"), "info");
|
||||||
@@ -2645,16 +2718,16 @@ export function renderXlsxViewer(area, data) {
|
|||||||
}
|
}
|
||||||
runStyle([{ op: "merge", sheet: sheetName, range: rangeLabel() }]);
|
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() }]);
|
runStyle([{ op: "unmerge", sheet: sheetName, range: selectionRange() }]);
|
||||||
});
|
});
|
||||||
sep();
|
sep();
|
||||||
item(t("xlsx.format_freeze"), () => {
|
item("snowflake", t("xlsx.format_freeze"), () => {
|
||||||
runStyle([{ op: "freeze", sheet: sheetName, cell: selectionRange().split(":")[0] }]);
|
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();
|
sep();
|
||||||
item(t("xlsx.format_col_width"), async () => {
|
item("move-horizontal", t("xlsx.format_col_width"), async () => {
|
||||||
const b = selectionBounds();
|
const b = selectionBounds();
|
||||||
const cols = [];
|
const cols = [];
|
||||||
if (b && selection && selection.panel === visiblePanel()) {
|
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;
|
if (!answer || !Number.isFinite(width) || width < 0 || width > 255) return;
|
||||||
runStyle(cols.map((col) => ({ op: "col_width", sheet: sheetName, col, width })));
|
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 b = selectionBounds();
|
||||||
const rows = [];
|
const rows = [];
|
||||||
if (b && selection && selection.panel === visiblePanel()) {
|
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;
|
if (!answer || !Number.isFinite(height) || height < 0 || height > 409) return;
|
||||||
runStyle(rows.map((row) => ({ op: "row_height", sheet: sheetName, row, height })));
|
runStyle(rows.map((row) => ({ op: "row_height", sheet: sheetName, row, height })));
|
||||||
});
|
});
|
||||||
|
enableArrowNav(menu, ".xlsx-structure-item");
|
||||||
formatBtn.closest(".xlsx-toolbar").appendChild(menu);
|
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 ═════════════════════════
|
// ══ #155 — Excel-like selection & context menu ═════════════════════════
|
||||||
@@ -3228,27 +3313,28 @@ export function renderXlsxViewer(area, data) {
|
|||||||
const colCount = b.c2 - b.c1 + 1;
|
const colCount = b.c2 - b.c1 + 1;
|
||||||
const items = [];
|
const items = [];
|
||||||
// #156-A5 — clipboard entries first, in the spreadsheet order.
|
// #156-A5 — clipboard entries first, in the spreadsheet order.
|
||||||
items.push({ label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
|
// #179 — every entry carries a representative Lucide icon.
|
||||||
items.push({ label: t("xlsx.copy"), onClick: () => copySelection() });
|
items.push({ icon: "scissors", label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
|
||||||
items.push({ label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
|
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 });
|
items.push({ separator: true });
|
||||||
if (editable) {
|
if (editable) {
|
||||||
items.push({ 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_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({ icon: "rows", 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({ icon: "columns", 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: "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({ 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({ 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({ 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_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 });
|
items.push({ separator: true });
|
||||||
}
|
}
|
||||||
if (!isCsv) {
|
if (!isCsv) {
|
||||||
items.push({ label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
|
items.push({ icon: "arrow-up", 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-down", label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
|
||||||
}
|
}
|
||||||
if (editable) {
|
if (editable) {
|
||||||
items.push({ separator: true });
|
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;
|
return items;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
|||||||
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
|
: `<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>`
|
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
|
||||||
).join("")}${editable
|
).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>`;
|
: ""}</div>`;
|
||||||
|
|
||||||
// Status pills make the viewer's limits visible up front.
|
// Status pills make the viewer's limits visible up front.
|
||||||
@@ -55,10 +55,10 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
|||||||
}
|
}
|
||||||
if (editable) {
|
if (editable) {
|
||||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
|
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>
|
<i data-lucide="paintbrush" style="width:14px;height:14px"></i>
|
||||||
</button>
|
</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>
|
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||||||
</button>
|
</button>
|
||||||
</span>`);
|
</span>`);
|
||||||
@@ -72,16 +72,16 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
|||||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||||
</button>
|
</button>
|
||||||
<span class="export-dropdown xlsx-export-dropdown">
|
<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"))}
|
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
|
||||||
</button>
|
</button>
|
||||||
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none">
|
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none" role="menu">
|
||||||
<button class="export-menu-item" type="button" data-xlsx-export="md">${escapeHtml(t("xlsx.export_md"))}</button>
|
<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" data-xlsx-export="html">${escapeHtml(t("xlsx.export_html"))}</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" data-xlsx-export="print">${escapeHtml(t("xlsx.print"))}</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>
|
</div>
|
||||||
</span>
|
</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>`);
|
</span>`);
|
||||||
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></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
|
* Builds a small menu anchored at a screen position. Labels, icons and
|
||||||
* click / Escape / window blur. Labels and callbacks come from the caller, so
|
* callbacks come from the caller, so no i18n lives here.
|
||||||
* 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 _menu = null;
|
||||||
let _openedAt = 0;
|
let _openedAt = 0;
|
||||||
let _dismissBound = false;
|
let _dismissBound = false;
|
||||||
@@ -33,13 +41,106 @@ function bindDismiss() {
|
|||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape") closeContextMenu();
|
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} x - viewport X of the pointer
|
||||||
* @param {number} y - viewport Y 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
|
* @returns {HTMLElement} the menu element
|
||||||
*/
|
*/
|
||||||
export function openContextMenu(x, y, items) {
|
export function openContextMenu(x, y, items) {
|
||||||
@@ -52,6 +153,7 @@ export function openContextMenu(x, y, items) {
|
|||||||
if (item.separator) {
|
if (item.separator) {
|
||||||
const sep = document.createElement("div");
|
const sep = document.createElement("div");
|
||||||
sep.className = "xlsx-context-sep";
|
sep.className = "xlsx-context-sep";
|
||||||
|
sep.setAttribute("aria-hidden", "true");
|
||||||
menu.appendChild(sep);
|
menu.appendChild(sep);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -59,7 +161,17 @@ export function openContextMenu(x, y, items) {
|
|||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
|
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
|
||||||
btn.setAttribute("role", "menuitem");
|
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.disabled = Boolean(item.disabled);
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
@@ -67,6 +179,7 @@ export function openContextMenu(x, y, items) {
|
|||||||
});
|
});
|
||||||
menu.appendChild(btn);
|
menu.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
enableArrowNav(menu, ".xlsx-context-item");
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
|
|
||||||
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
|
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
|
||||||
@@ -81,5 +194,12 @@ export function openContextMenu(x, y, items) {
|
|||||||
|
|
||||||
_menu = menu;
|
_menu = menu;
|
||||||
_openedAt = Date.now();
|
_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;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11332,6 +11332,7 @@ body.desktop-mode .editor-container {
|
|||||||
top: 100%;
|
top: 100%;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
max-width: 86vw;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background: var(--bg, #fff);
|
background: var(--bg, #fff);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -11410,6 +11411,7 @@ body.desktop-mode .editor-container {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
|
max-width: min(320px, 92vw);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -11608,6 +11610,32 @@ body.desktop-mode .editor-container {
|
|||||||
background: var(--border);
|
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. */
|
/* #155 — Excel-like range selection + right-click / long-press context menu. */
|
||||||
.xlsx-table tbody td.xlsx-selected {
|
.xlsx-table tbody td.xlsx-selected {
|
||||||
background: var(--accent-bg);
|
background: var(--accent-bg);
|
||||||
@@ -11625,12 +11653,30 @@ body.desktop-mode .editor-container {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
|
max-width: min(320px, 86vw);
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: 0 12px 32px var(--shadow, rgba(0, 0, 0, 0.35));
|
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 {
|
.xlsx-context-item {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
* - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide,
|
* - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide,
|
||||||
* with the real A1 coordinates (#153 A9).
|
* 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
|
* The fixture is restored byte-for-byte in `afterAll` so a local run never
|
||||||
* dirties the working copy.
|
* 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);
|
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");
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Report ──────────────────────────────────────────────────────────────────
|
||||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||||
process.exit(passCount === testCount ? 0 : 1);
|
process.exit(passCount === testCount ? 0 : 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user