feat: menus tableur a icones, fermeture au focus et polish mobile #179
This commit is contained in:
+147
-61
@@ -19,7 +19,7 @@ import { openAssistant } from './ai-fab.js';
|
||||
import { parseRef, columnName, findTd, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
|
||||
import { buildCommandBar } from './xlsx/command-bar.js';
|
||||
import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.js';
|
||||
import { openContextMenu, closeContextMenu } from './xlsx/context-menu.js';
|
||||
import { openContextMenu, closeContextMenu, trackDismissable, enableArrowNav } from './xlsx/context-menu.js';
|
||||
|
||||
// ── Multi-format export ────────────────────────────────────────────────────
|
||||
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
|
||||
@@ -2284,25 +2284,42 @@ export function renderXlsxViewer(area, data) {
|
||||
// Structure menu: built on demand, positioned under the button.
|
||||
// (.csv / read-only workbooks have no such button — the menu block above
|
||||
// is skipped for them.)
|
||||
// #179 — tracked dismissal of the structure menu (outside, focus loss…).
|
||||
let dismissStructureMenu = null;
|
||||
const structureBtn = area.querySelector("#xlsx-structure-btn");
|
||||
if (structureBtn) structureBtn.addEventListener("click", (e) => {
|
||||
const old = area.querySelector(".xlsx-structure-menu");
|
||||
if (old) { old.remove(); return; }
|
||||
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; return; }
|
||||
const idx = visibleSheetIndex();
|
||||
const sheetName = sheets[idx]?.name || "";
|
||||
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
|
||||
const parsed = parseRef(activeRef) || { row: 1, col: 1 };
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "xlsx-structure-menu";
|
||||
const item = (label, fn) => {
|
||||
menu.setAttribute("role", "menu");
|
||||
// #179 — representative icon per entry; destructive ones read as danger.
|
||||
const item = (iconName, label, fn, danger = false) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "btn-action xlsx-structure-item";
|
||||
b.textContent = label;
|
||||
b.addEventListener("click", () => { menu.remove(); fn(); });
|
||||
b.className = `btn-action xlsx-structure-item${danger ? " xlsx-context-danger" : ""}`;
|
||||
b.setAttribute("role", "menuitem");
|
||||
if (iconName) {
|
||||
const ic = document.createElement("i");
|
||||
ic.setAttribute("data-lucide", iconName);
|
||||
ic.className = "xlsx-menu-icon";
|
||||
ic.setAttribute("aria-hidden", "true");
|
||||
b.appendChild(ic);
|
||||
}
|
||||
const lab = document.createElement("span");
|
||||
lab.className = "xlsx-menu-label";
|
||||
lab.textContent = label;
|
||||
b.appendChild(lab);
|
||||
b.addEventListener("click", () => {
|
||||
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; }
|
||||
fn();
|
||||
});
|
||||
menu.appendChild(b);
|
||||
};
|
||||
item(t("xlsx.sheet_add"), async () => {
|
||||
item("file-plus", t("xlsx.sheet_add"), async () => {
|
||||
const name = await showPrompt({
|
||||
title: t("xlsx.sheet_add"),
|
||||
message: t("xlsx.structure_prompt_add"),
|
||||
@@ -2311,7 +2328,7 @@ export function renderXlsxViewer(area, data) {
|
||||
const actions = [{ op: "sheet_add", name }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_rename"), async () => {
|
||||
item("pencil", t("xlsx.sheet_rename"), async () => {
|
||||
const to = await showPrompt({
|
||||
title: t("xlsx.sheet_rename"),
|
||||
message: t("xlsx.structure_prompt_rename"),
|
||||
@@ -2321,7 +2338,7 @@ export function renderXlsxViewer(area, data) {
|
||||
const actions = [{ op: "sheet_rename", from: sheetName, to }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_duplicate"), async () => {
|
||||
item("copy", t("xlsx.sheet_duplicate"), async () => {
|
||||
const as = await showPrompt({
|
||||
title: t("xlsx.sheet_duplicate"),
|
||||
message: t("xlsx.structure_prompt_add"),
|
||||
@@ -2331,7 +2348,7 @@ export function renderXlsxViewer(area, data) {
|
||||
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_delete"), async () => {
|
||||
item("trash-2", t("xlsx.sheet_delete"), async () => {
|
||||
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
|
||||
const okDelete = await showConfirm({
|
||||
title: t("xlsx.sheet_delete"),
|
||||
@@ -2342,14 +2359,14 @@ export function renderXlsxViewer(area, data) {
|
||||
if (!okDelete) return;
|
||||
const actions = [{ op: "sheet_delete", name: sheetName }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
}, true);
|
||||
if (parsed) {
|
||||
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
|
||||
item(t("xlsx.row_insert"), async () => {
|
||||
item("rows", t("xlsx.row_insert"), async () => {
|
||||
const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.row_delete"), async () => {
|
||||
item("trash-2", t("xlsx.row_delete"), async () => {
|
||||
const okRow = await showConfirm({
|
||||
title: t("xlsx.row_delete"),
|
||||
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
|
||||
@@ -2359,12 +2376,12 @@ export function renderXlsxViewer(area, data) {
|
||||
if (!okRow) return;
|
||||
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.col_insert"), async () => {
|
||||
}, true);
|
||||
item("columns", t("xlsx.col_insert"), async () => {
|
||||
const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.col_delete"), async () => {
|
||||
item("trash-2", t("xlsx.col_delete"), async () => {
|
||||
const okCol = await showConfirm({
|
||||
title: t("xlsx.col_delete"),
|
||||
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
|
||||
@@ -2374,9 +2391,21 @@ export function renderXlsxViewer(area, data) {
|
||||
if (!okCol) return;
|
||||
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
enableArrowNav(menu, ".xlsx-structure-item");
|
||||
e.target.closest(".xlsx-toolbar").appendChild(menu);
|
||||
safeCreateIcons();
|
||||
structureBtn.setAttribute("aria-expanded", "true");
|
||||
// #179 — close on outside click, focus loss, Escape, scroll, resize.
|
||||
dismissStructureMenu = trackDismissable(menu, {
|
||||
toggle: structureBtn,
|
||||
onClose: () => {
|
||||
dismissStructureMenu = null;
|
||||
structureBtn.setAttribute("aria-expanded", "false");
|
||||
menu.remove();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── #156-A11 — sortie : CSV / Markdown / HTML / impression ────────────
|
||||
@@ -2491,14 +2520,34 @@ export function renderXlsxViewer(area, data) {
|
||||
}
|
||||
};
|
||||
|
||||
// #179 — the export menu used to stay open until its button was clicked
|
||||
// again; it now closes on outside click, focus loss, Escape, scroll, resize.
|
||||
let dismissExportMenu = null;
|
||||
const exportMenu = area.querySelector("#xlsx-export-menu");
|
||||
if (exportMenu) {
|
||||
area.querySelector("#xlsx-export-btn").addEventListener("click", () => {
|
||||
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
|
||||
const exportBtn = area.querySelector("#xlsx-export-btn");
|
||||
if (exportMenu && exportBtn) {
|
||||
const hideExportMenu = () => {
|
||||
if (dismissExportMenu) { dismissExportMenu(); dismissExportMenu = null; }
|
||||
exportMenu.style.display = "none";
|
||||
exportBtn.setAttribute("aria-expanded", "false");
|
||||
};
|
||||
exportBtn.addEventListener("click", () => {
|
||||
if (exportMenu.style.display === "block") { hideExportMenu(); return; }
|
||||
exportMenu.style.display = "block";
|
||||
exportBtn.setAttribute("aria-expanded", "true");
|
||||
dismissExportMenu = trackDismissable(exportMenu, {
|
||||
toggle: exportBtn,
|
||||
onClose: () => {
|
||||
dismissExportMenu = null;
|
||||
exportMenu.style.display = "none";
|
||||
exportBtn.setAttribute("aria-expanded", "false");
|
||||
},
|
||||
});
|
||||
});
|
||||
enableArrowNav(exportMenu, "[data-xlsx-export]");
|
||||
exportMenu.querySelectorAll("[data-xlsx-export]").forEach((item) => {
|
||||
item.addEventListener("click", () => {
|
||||
exportMenu.style.display = "none";
|
||||
hideExportMenu();
|
||||
if (item.dataset.xlsxExport === "print") printSheet();
|
||||
else exportDocument(item.dataset.xlsxExport);
|
||||
});
|
||||
@@ -2575,19 +2624,36 @@ export function renderXlsxViewer(area, data) {
|
||||
return Boolean(td && td.style.textDecoration === "underline");
|
||||
};
|
||||
|
||||
// #179 — tracked dismissal of the format menu (outside, focus loss…).
|
||||
let dismissFormatMenu = null;
|
||||
const formatBtn = area.querySelector("#xlsx-format-btn");
|
||||
if (formatBtn) formatBtn.addEventListener("click", () => {
|
||||
const old = area.querySelector(".xlsx-format-menu");
|
||||
if (old) { old.remove(); return; }
|
||||
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; return; }
|
||||
const sheetName = sheets[visibleSheetIndex()]?.name || "";
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "xlsx-structure-menu xlsx-format-menu";
|
||||
const item = (label, fn) => {
|
||||
menu.setAttribute("role", "menu");
|
||||
// #179 — representative icon per entry.
|
||||
const item = (iconName, label, fn) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "btn-action xlsx-structure-item";
|
||||
b.textContent = label;
|
||||
b.addEventListener("click", () => { menu.remove(); fn(); });
|
||||
b.setAttribute("role", "menuitem");
|
||||
if (iconName) {
|
||||
const ic = document.createElement("i");
|
||||
ic.setAttribute("data-lucide", iconName);
|
||||
ic.className = "xlsx-menu-icon";
|
||||
ic.setAttribute("aria-hidden", "true");
|
||||
b.appendChild(ic);
|
||||
}
|
||||
const lab = document.createElement("span");
|
||||
lab.className = "xlsx-menu-label";
|
||||
lab.textContent = label;
|
||||
b.appendChild(lab);
|
||||
b.addEventListener("click", () => {
|
||||
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; }
|
||||
fn();
|
||||
});
|
||||
menu.appendChild(b);
|
||||
};
|
||||
const sep = () => menu.appendChild(
|
||||
@@ -2598,22 +2664,29 @@ export function renderXlsxViewer(area, data) {
|
||||
]);
|
||||
const numFormat = (fmt) => cellStyle({ number_format: fmt });
|
||||
|
||||
item(t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
|
||||
item(t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
|
||||
item(t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
|
||||
item(t("xlsx.format_clear"), cellStyle({
|
||||
item("bold", t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
|
||||
item("italic", t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
|
||||
item("underline", t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
|
||||
item("remove-formatting", t("xlsx.format_clear"), cellStyle({
|
||||
bold: false, italic: false, underline: false,
|
||||
font_color: "", fill_color: "", align: "left", number_format: "General",
|
||||
}));
|
||||
sep();
|
||||
item(t("xlsx.format_align_left"), cellStyle({ align: "left" }));
|
||||
item(t("xlsx.format_align_center"), cellStyle({ align: "center" }));
|
||||
item(t("xlsx.format_align_right"), cellStyle({ align: "right" }));
|
||||
item("align-left", t("xlsx.format_align_left"), cellStyle({ align: "left" }));
|
||||
item("align-center", t("xlsx.format_align_center"), cellStyle({ align: "center" }));
|
||||
item("align-right", t("xlsx.format_align_right"), cellStyle({ align: "right" }));
|
||||
sep();
|
||||
// Colours come from the native picker; applied once when it closes.
|
||||
const colour = (label, apply) => {
|
||||
const colour = (iconName, label, apply) => {
|
||||
const row = document.createElement("label");
|
||||
row.className = "xlsx-format-colour";
|
||||
if (iconName) {
|
||||
const ic = document.createElement("i");
|
||||
ic.setAttribute("data-lucide", iconName);
|
||||
ic.className = "xlsx-menu-icon";
|
||||
ic.setAttribute("aria-hidden", "true");
|
||||
row.appendChild(ic);
|
||||
}
|
||||
const span = document.createElement("span");
|
||||
span.textContent = label;
|
||||
const input = document.createElement("input");
|
||||
@@ -2623,21 +2696,21 @@ export function renderXlsxViewer(area, data) {
|
||||
row.appendChild(input);
|
||||
menu.appendChild(row);
|
||||
};
|
||||
colour(t("xlsx.format_font_color"), (e) => runStyle([
|
||||
colour("palette", t("xlsx.format_font_color"), (e) => runStyle([
|
||||
{ op: "cell", sheet: sheetName, range: selectionRange(), style: { font_color: e.target.value } },
|
||||
]));
|
||||
colour(t("xlsx.format_fill_color"), (e) => runStyle([
|
||||
colour("paint-bucket", t("xlsx.format_fill_color"), (e) => runStyle([
|
||||
{ op: "cell", sheet: sheetName, range: selectionRange(), style: { fill_color: e.target.value } },
|
||||
]));
|
||||
sep();
|
||||
item(t("xlsx.format_num_general"), numFormat("General"));
|
||||
item(t("xlsx.format_num_number"), numFormat("0.00"));
|
||||
item(t("xlsx.format_num_percent"), numFormat("0.0%"));
|
||||
item(t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
|
||||
item(t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
|
||||
item(t("xlsx.format_num_text"), numFormat("@"));
|
||||
item("type", t("xlsx.format_num_general"), numFormat("General"));
|
||||
item("hash", t("xlsx.format_num_number"), numFormat("0.00"));
|
||||
item("percent", t("xlsx.format_num_percent"), numFormat("0.0%"));
|
||||
item("euro", t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
|
||||
item("calendar", t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
|
||||
item("pilcrow", t("xlsx.format_num_text"), numFormat("@"));
|
||||
sep();
|
||||
item(t("xlsx.format_merge"), () => {
|
||||
item("combine", t("xlsx.format_merge"), () => {
|
||||
const b = selectionBounds();
|
||||
if (!b || !selection || selection.panel !== visiblePanel() || (b.r1 === b.r2 && b.c1 === b.c2)) {
|
||||
showToast(t("xlsx.format_merge_needs_range"), "info");
|
||||
@@ -2645,16 +2718,16 @@ export function renderXlsxViewer(area, data) {
|
||||
}
|
||||
runStyle([{ op: "merge", sheet: sheetName, range: rangeLabel() }]);
|
||||
});
|
||||
item(t("xlsx.format_unmerge"), () => {
|
||||
item("ungroup", t("xlsx.format_unmerge"), () => {
|
||||
runStyle([{ op: "unmerge", sheet: sheetName, range: selectionRange() }]);
|
||||
});
|
||||
sep();
|
||||
item(t("xlsx.format_freeze"), () => {
|
||||
item("snowflake", t("xlsx.format_freeze"), () => {
|
||||
runStyle([{ op: "freeze", sheet: sheetName, cell: selectionRange().split(":")[0] }]);
|
||||
});
|
||||
item(t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }]));
|
||||
item("sun", t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }]));
|
||||
sep();
|
||||
item(t("xlsx.format_col_width"), async () => {
|
||||
item("move-horizontal", t("xlsx.format_col_width"), async () => {
|
||||
const b = selectionBounds();
|
||||
const cols = [];
|
||||
if (b && selection && selection.panel === visiblePanel()) {
|
||||
@@ -2672,7 +2745,7 @@ export function renderXlsxViewer(area, data) {
|
||||
if (!answer || !Number.isFinite(width) || width < 0 || width > 255) return;
|
||||
runStyle(cols.map((col) => ({ op: "col_width", sheet: sheetName, col, width })));
|
||||
});
|
||||
item(t("xlsx.format_row_height"), async () => {
|
||||
item("move-vertical", t("xlsx.format_row_height"), async () => {
|
||||
const b = selectionBounds();
|
||||
const rows = [];
|
||||
if (b && selection && selection.panel === visiblePanel()) {
|
||||
@@ -2690,7 +2763,19 @@ export function renderXlsxViewer(area, data) {
|
||||
if (!answer || !Number.isFinite(height) || height < 0 || height > 409) return;
|
||||
runStyle(rows.map((row) => ({ op: "row_height", sheet: sheetName, row, height })));
|
||||
});
|
||||
enableArrowNav(menu, ".xlsx-structure-item");
|
||||
formatBtn.closest(".xlsx-toolbar").appendChild(menu);
|
||||
safeCreateIcons();
|
||||
formatBtn.setAttribute("aria-expanded", "true");
|
||||
// #179 — close on outside click, focus loss, Escape, scroll, resize.
|
||||
dismissFormatMenu = trackDismissable(menu, {
|
||||
toggle: formatBtn,
|
||||
onClose: () => {
|
||||
dismissFormatMenu = null;
|
||||
formatBtn.setAttribute("aria-expanded", "false");
|
||||
menu.remove();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ══ #155 — Excel-like selection & context menu ═════════════════════════
|
||||
@@ -3228,27 +3313,28 @@ export function renderXlsxViewer(area, data) {
|
||||
const colCount = b.c2 - b.c1 + 1;
|
||||
const items = [];
|
||||
// #156-A5 — clipboard entries first, in the spreadsheet order.
|
||||
items.push({ label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
|
||||
items.push({ label: t("xlsx.copy"), onClick: () => copySelection() });
|
||||
items.push({ label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
|
||||
// #179 — every entry carries a representative Lucide icon.
|
||||
items.push({ icon: "scissors", label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
|
||||
items.push({ icon: "copy", label: t("xlsx.copy"), onClick: () => copySelection() });
|
||||
items.push({ icon: "clipboard-paste", label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
|
||||
items.push({ separator: true });
|
||||
if (editable) {
|
||||
items.push({ label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
|
||||
items.push({ label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
|
||||
items.push({ label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
|
||||
items.push({ label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
|
||||
items.push({ icon: "rows", label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
|
||||
items.push({ icon: "rows", label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
|
||||
items.push({ icon: "columns", label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
|
||||
items.push({ icon: "columns", label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
|
||||
items.push({ separator: true });
|
||||
items.push({ label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
|
||||
items.push({ label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
|
||||
items.push({ icon: "trash-2", label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
|
||||
items.push({ icon: "trash-2", label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
|
||||
items.push({ separator: true });
|
||||
}
|
||||
if (!isCsv) {
|
||||
items.push({ label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
|
||||
items.push({ label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
|
||||
items.push({ icon: "arrow-up", label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
|
||||
items.push({ icon: "arrow-down", label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
|
||||
}
|
||||
if (editable) {
|
||||
items.push({ separator: true });
|
||||
items.push({ label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
|
||||
items.push({ icon: "eraser", label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
|
||||
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
|
||||
).join("")}${editable
|
||||
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}">+</button>`
|
||||
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}"><i data-lucide="plus" style="width:14px;height:14px" aria-hidden="true"></i></button>`
|
||||
: ""}</div>`;
|
||||
|
||||
// Status pills make the viewer's limits visible up front.
|
||||
@@ -55,10 +55,10 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
}
|
||||
if (editable) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
|
||||
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}">
|
||||
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
<i data-lucide="paintbrush" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
|
||||
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
@@ -72,16 +72,16 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||
</button>
|
||||
<span class="export-dropdown xlsx-export-dropdown">
|
||||
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}">
|
||||
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
|
||||
</button>
|
||||
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none">
|
||||
<button class="export-menu-item" type="button" data-xlsx-export="md">${escapeHtml(t("xlsx.export_md"))}</button>
|
||||
<button class="export-menu-item" type="button" data-xlsx-export="html">${escapeHtml(t("xlsx.export_html"))}</button>
|
||||
<button class="export-menu-item" type="button" data-xlsx-export="print">${escapeHtml(t("xlsx.print"))}</button>
|
||||
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none" role="menu">
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="md"><i data-lucide="file-text" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_md"))}</span></button>
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="html"><i data-lucide="code" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_html"))}</span></button>
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="print"><i data-lucide="printer" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.print"))}</span></button>
|
||||
</div>
|
||||
</span>
|
||||
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
||||
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled><i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>
|
||||
</span>`);
|
||||
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
/* ObsiGate — generic themed context menu for the XLSX grid (#155).
|
||||
/* ObsiGate — generic themed context menu for the XLSX grid (#155, #179).
|
||||
*
|
||||
* Builds a small menu anchored at a screen position, closes it on outside
|
||||
* click / Escape / window blur. Labels and callbacks come from the caller, so
|
||||
* no i18n lives here.
|
||||
* Builds a small menu anchored at a screen position. Labels, icons and
|
||||
* callbacks come from the caller, so no i18n lives here.
|
||||
*
|
||||
* #179 — every menu entry carries a representative Lucide icon, the menu
|
||||
* closes as soon as the focus leaves it (outside click, focusout, Escape,
|
||||
* window blur, scroll, resize) and arrow keys walk through the entries.
|
||||
* trackDismissable() exposes the same dismissal contract to the other
|
||||
* spreadsheet menus (Structure, Mise en forme, Export), which keep their
|
||||
* own markup.
|
||||
*/
|
||||
import { safeCreateIcons } from '../utils.js';
|
||||
|
||||
let _menu = null;
|
||||
let _openedAt = 0;
|
||||
let _dismissBound = false;
|
||||
@@ -33,13 +41,106 @@ function bindDismiss() {
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeContextMenu();
|
||||
});
|
||||
if (typeof window !== "undefined") window.addEventListener("blur", closeContextMenu);
|
||||
// #179 — never linger once the focus moves outside the menu (toolbar
|
||||
// button, editor, another panel…). A null relatedTarget (click on a
|
||||
// non-focusable cell) is already covered by the click-outside handler.
|
||||
document.addEventListener("focusout", (e) => {
|
||||
if (!_menu) return;
|
||||
const next = e.relatedTarget;
|
||||
if (next && _menu.contains(next)) return;
|
||||
closeContextMenu();
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("blur", closeContextMenu);
|
||||
// A scroll or resize detaches the viewport-anchored menu from its cell.
|
||||
window.addEventListener("scroll", closeContextMenu, true);
|
||||
window.addEventListener("resize", closeContextMenu);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk through a menu's buttons with the keyboard (#179).
|
||||
* @param {HTMLElement} menuEl - menu container
|
||||
* @param {string} itemSelector - CSS selector of the navigable entries
|
||||
*/
|
||||
export function enableArrowNav(menuEl, itemSelector) {
|
||||
menuEl.addEventListener("keydown", (e) => {
|
||||
const items = [...menuEl.querySelectorAll(itemSelector)].filter((b) => !b.disabled);
|
||||
if (!items.length) return;
|
||||
const idx = items.indexOf(document.activeElement);
|
||||
let next = -1;
|
||||
if (e.key === "ArrowDown") next = idx < 0 ? 0 : (idx + 1) % items.length;
|
||||
else if (e.key === "ArrowUp") next = idx < 0 ? items.length - 1 : (idx - 1 + items.length) % items.length;
|
||||
else if (e.key === "Home") next = 0;
|
||||
else if (e.key === "End") next = items.length - 1;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
items[next].focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismissal contract shared by the spreadsheet's other menus (#179).
|
||||
* Closes on outside click (the toggle button owns its own clicks), Escape,
|
||||
* focus leaving both the menu and its toggle, scroll and resize.
|
||||
* @param {HTMLElement} menuEl - menu element to watch
|
||||
* @param {object} opts
|
||||
* @param {HTMLElement|null} opts.toggle - button opening the menu (exempt)
|
||||
* @param {Function|null} opts.onClose - called once on dismissal (default: remove the element)
|
||||
* @returns {Function} dismiss() — idempotent manual close (unbinds + onClose)
|
||||
*/
|
||||
export function trackDismissable(menuEl, { toggle = null, onClose = null } = {}) {
|
||||
let done = false;
|
||||
const unbind = () => {
|
||||
document.removeEventListener("click", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("focusout", onFocusOut);
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
}
|
||||
};
|
||||
const dismiss = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
unbind();
|
||||
if (onClose) onClose();
|
||||
else menuEl.remove();
|
||||
};
|
||||
const onDocClick = (e) => {
|
||||
if (done) return;
|
||||
if (!menuEl.isConnected) { done = true; unbind(); return; }
|
||||
const t = e.target;
|
||||
if (menuEl.contains(t)) return;
|
||||
if (toggle && (toggle === t || toggle.contains(t))) return;
|
||||
dismiss();
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape" && menuEl.isConnected) dismiss();
|
||||
};
|
||||
const onFocusOut = (e) => {
|
||||
if (done || !menuEl.isConnected) return;
|
||||
const next = e.relatedTarget;
|
||||
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
|
||||
dismiss();
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (menuEl.isConnected) dismiss();
|
||||
};
|
||||
document.addEventListener("click", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.addEventListener("focusout", onFocusOut);
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onScroll);
|
||||
}
|
||||
return dismiss;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x - viewport X of the pointer
|
||||
* @param {number} y - viewport Y of the pointer
|
||||
* @param {Array<{label?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
|
||||
* @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
|
||||
* @returns {HTMLElement} the menu element
|
||||
*/
|
||||
export function openContextMenu(x, y, items) {
|
||||
@@ -52,6 +153,7 @@ export function openContextMenu(x, y, items) {
|
||||
if (item.separator) {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "xlsx-context-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
menu.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
@@ -59,7 +161,17 @@ export function openContextMenu(x, y, items) {
|
||||
btn.type = "button";
|
||||
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
|
||||
btn.setAttribute("role", "menuitem");
|
||||
btn.textContent = item.label;
|
||||
if (item.icon) {
|
||||
const ic = document.createElement("i");
|
||||
ic.setAttribute("data-lucide", item.icon);
|
||||
ic.className = "xlsx-menu-icon";
|
||||
ic.setAttribute("aria-hidden", "true");
|
||||
btn.appendChild(ic);
|
||||
}
|
||||
const lab = document.createElement("span");
|
||||
lab.className = "xlsx-menu-label";
|
||||
lab.textContent = item.label;
|
||||
btn.appendChild(lab);
|
||||
btn.disabled = Boolean(item.disabled);
|
||||
btn.addEventListener("click", () => {
|
||||
closeContextMenu();
|
||||
@@ -67,6 +179,7 @@ export function openContextMenu(x, y, items) {
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
}
|
||||
enableArrowNav(menu, ".xlsx-context-item");
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
|
||||
@@ -81,5 +194,12 @@ export function openContextMenu(x, y, items) {
|
||||
|
||||
_menu = menu;
|
||||
_openedAt = Date.now();
|
||||
// Keyboard users land on the first entry right away; mouse users are
|
||||
// unaffected (hover/click still work). preventScroll avoids yanking the grid.
|
||||
const first = menu.querySelector(".xlsx-context-item:not(:disabled)");
|
||||
if (first && typeof first.focus === "function") {
|
||||
try { first.focus({ preventScroll: true }); } catch (_) { first.focus(); }
|
||||
}
|
||||
if (typeof safeCreateIcons === "function") safeCreateIcons();
|
||||
return menu;
|
||||
}
|
||||
|
||||
@@ -11332,6 +11332,7 @@ body.desktop-mode .editor-container {
|
||||
top: 100%;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
max-width: 86vw;
|
||||
padding: 4px;
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border);
|
||||
@@ -11410,6 +11411,7 @@ body.desktop-mode .editor-container {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 240px;
|
||||
max-width: min(320px, 92vw);
|
||||
margin-top: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -11608,6 +11610,32 @@ body.desktop-mode .editor-container {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* #179 — shared menu-entry icon (grid context menu, structure, format, export). */
|
||||
.xlsx-menu-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.xlsx-menu-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.xlsx-context-item,
|
||||
.xlsx-structure-item,
|
||||
.xlsx-export-menu .export-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.xlsx-context-item:focus:not(:disabled),
|
||||
.xlsx-structure-item:focus:not(:disabled),
|
||||
.xlsx-export-menu .export-menu-item:focus {
|
||||
background: var(--bg-hover);
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* #155 — Excel-like range selection + right-click / long-press context menu. */
|
||||
.xlsx-table tbody td.xlsx-selected {
|
||||
background: var(--accent-bg);
|
||||
@@ -11625,12 +11653,30 @@ body.desktop-mode .editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
max-width: min(320px, 86vw);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 32px var(--shadow, rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
/* #179 — touch-sized entries + viewport-bound menus on coarse pointers. */
|
||||
@media (pointer: coarse) {
|
||||
.xlsx-context-item,
|
||||
.xlsx-structure-item,
|
||||
.xlsx-export-menu .export-menu-item {
|
||||
min-height: 44px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.xlsx-context-menu {
|
||||
min-width: min(260px, 86vw);
|
||||
}
|
||||
.xlsx-structure-menu {
|
||||
max-width: 92vw;
|
||||
}
|
||||
}
|
||||
.xlsx-context-item {
|
||||
text-align: left;
|
||||
border: none;
|
||||
|
||||
Reference in New Issue
Block a user