From d79202e6987da68d0aa959bda41906e11a55bb4e Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 4 Oct 2026 19:27:46 -0400 Subject: [PATCH] feat: menus tableur a icones, fermeture au focus et polish mobile #179 --- CHANGELOG.md | 18 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md | 3 +- docs/ROADMAP.md | 26 ++- docs/features/xlsx-menus-179.md | 72 +++++++ frontend/js/viewer.js | 208 +++++++++++++------ frontend/js/xlsx/command-bar.js | 18 +- frontend/js/xlsx/context-menu.js | 134 ++++++++++++- frontend/style.css | 46 +++++ package.json | 2 +- tests/e2e/xlsx-viewer.spec.js | 88 +++++++++ tests/frontend/xlsx-menus.test.mjs | 252 ++++++++++++++++++++++++ tests/frontend/xlsx-viewer.test.mjs | 89 +++++++++ 18 files changed, 885 insertions(+), 91 deletions(-) create mode 100644 docs/features/xlsx-menus-179.md create mode 100644 tests/frontend/xlsx-menus.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ccd9e..00e8e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.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é diff --git a/README.fr.md b/README.fr.md index c87de05..f31ea9b 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.52.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.53.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.52.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.53.0). --- -*Projet : ObsiGate | Version : 2.52.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.53.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 49dbdc8..a626b28 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.52.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.53.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.52.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.53.0). --- -*Project: ObsiGate | Version: 2.52.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.53.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index cfa53dc..261d955 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.52.0 +2.53.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 8f40a3c..d3a3ea5 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.52.0" +version = "2.53.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 904a042..ed56680 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -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" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index b555347..9cfdb78 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.52.0", + "version": "2.53.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md index ad9d30b..f254f50 100644 --- a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md +++ b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b3e6c1c..e0a3963 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/features/xlsx-menus-179.md b/docs/features/xlsx-menus-179.md new file mode 100644 index 0000000..d237832 --- /dev/null +++ b/docs/features/xlsx-menus-179.md @@ -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` +(`` + ``, 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). diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index a66081a..09341df 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -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; }; diff --git a/frontend/js/xlsx/command-bar.js b/frontend/js/xlsx/command-bar.js index c650eca..9989f6e 100644 --- a/frontend/js/xlsx/command-bar.js +++ b/frontend/js/xlsx/command-bar.js @@ -23,7 +23,7 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) { : `
${sheets.map((s, i) => `` ).join("")}${editable - ? `` + ? `` : ""}
`; // 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(` - - `); @@ -72,16 +72,16 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) { CSV - -