Improve help modal navigation with keyboard and search support
Refactor help navigation to support keyboard arrow keys, content pane focus toggling, and enhanced search filtering that hides non-matching sections. Improve mobile toggle behavior and accessibility with proper focus management.
This commit is contained in:
+1
-1
@@ -774,7 +774,7 @@
|
||||
<div class="help-nav-header">
|
||||
<div class="help-nav-title">Sommaire</div>
|
||||
<button class="help-nav-toggle" id="help-nav-toggle" title="Afficher/Masquer le sommaire" aria-label="Menu sommaire">
|
||||
<i data-lucide="menu" style="width:16px;height:16px"></i>
|
||||
<i data-lucide="chevron-down" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="help-nav-search-wrap">
|
||||
|
||||
+167
-55
@@ -189,11 +189,21 @@ function initHelpModal() {
|
||||
const modal = document.getElementById("help-modal");
|
||||
if (!openBtn || !closeBtn || !modal) return;
|
||||
|
||||
let helpNavInitialized = false;
|
||||
|
||||
openBtn.addEventListener("click", () => {
|
||||
modal.classList.add("active");
|
||||
closeHeaderMenu();
|
||||
safeCreateIcons();
|
||||
initHelpNavigation();
|
||||
if (!helpNavInitialized) {
|
||||
initHelpNavigation();
|
||||
helpNavInitialized = true;
|
||||
}
|
||||
// Focus search on open
|
||||
const searchInput = document.getElementById("help-nav-search");
|
||||
if (searchInput) {
|
||||
setTimeout(() => searchInput.focus(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", closeHelpModal);
|
||||
@@ -216,24 +226,57 @@ function initHelpNavigation() {
|
||||
const nav = document.getElementById("help-nav");
|
||||
const searchInput = document.getElementById("help-nav-search");
|
||||
const toggleBtn = document.getElementById("help-nav-toggle");
|
||||
const helpContent = document.querySelector(".help-content");
|
||||
|
||||
if (!helpBody || !navList || !nav) return;
|
||||
|
||||
const allLinks = [...navList.querySelectorAll(".help-nav-link")];
|
||||
const allSections = [...document.querySelectorAll(".help-section")];
|
||||
const sectionMap = {};
|
||||
allSections.forEach(s => { sectionMap[s.id] = s; });
|
||||
|
||||
// ── Scroll to section and focus it ──
|
||||
// Build text index for content search (section title + text content)
|
||||
const sectionTextCache = {};
|
||||
allSections.forEach(s => {
|
||||
sectionTextCache[s.id] = (s.textContent || '').toLowerCase();
|
||||
});
|
||||
|
||||
// ── Scroll to section ──
|
||||
function scrollToSection(targetId) {
|
||||
const section = document.getElementById(targetId);
|
||||
const section = sectionMap[targetId];
|
||||
if (!section) return;
|
||||
section.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
section.setAttribute("tabindex", "-1");
|
||||
section.focus({ preventScroll: true });
|
||||
// On mobile, collapse nav after selection
|
||||
if (window.innerWidth <= 768 && nav) {
|
||||
nav.classList.add("collapsed");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Focus the content pane (first heading in active section) ──
|
||||
function focusContent() {
|
||||
// Find the active section and focus its first heading
|
||||
const activeLink = navList.querySelector(".help-nav-link.active:not(.hidden)");
|
||||
if (activeLink) {
|
||||
const targetId = activeLink.getAttribute("href").substring(1);
|
||||
const section = sectionMap[targetId];
|
||||
if (section) {
|
||||
const h2 = section.querySelector("h2, h3");
|
||||
if (h2) { h2.setAttribute("tabindex", "-1"); h2.focus({ preventScroll: true }); return; }
|
||||
}
|
||||
}
|
||||
// Fallback: focus the content area itself
|
||||
if (helpContent) { helpContent.setAttribute("tabindex", "-1"); helpContent.focus({ preventScroll: true }); }
|
||||
}
|
||||
|
||||
// ── Focus TOC active link ──
|
||||
function focusTOC() {
|
||||
const activeLink = navList.querySelector(".help-nav-link.active:not(.hidden)");
|
||||
if (activeLink) { activeLink.focus(); return; }
|
||||
const firstLink = navList.querySelector(".help-nav-link:not(.hidden)");
|
||||
if (firstLink) firstLink.focus();
|
||||
}
|
||||
|
||||
// ── Nav link click handler ──
|
||||
allLinks.forEach((link) => {
|
||||
link.addEventListener("click", (e) => {
|
||||
@@ -249,11 +292,20 @@ function initHelpNavigation() {
|
||||
if (!items.length) return;
|
||||
const currentIdx = items.indexOf(document.activeElement);
|
||||
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowRight") {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const next = (currentIdx + 1) % items.length;
|
||||
items[next].focus();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const prev = (currentIdx - 1 + items.length) % items.length;
|
||||
items[prev].focus();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
// Right arrow: go to content pane
|
||||
e.preventDefault();
|
||||
focusContent();
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
// Left arrow in TOC: stay (prev item)
|
||||
e.preventDefault();
|
||||
const prev = (currentIdx - 1 + items.length) % items.length;
|
||||
items[prev].focus();
|
||||
@@ -267,45 +319,114 @@ function initHelpNavigation() {
|
||||
e.preventDefault();
|
||||
const targetId = items[currentIdx].getAttribute("href").substring(1);
|
||||
scrollToSection(targetId);
|
||||
// After scrolling, move focus to content
|
||||
setTimeout(() => focusContent(), 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Make nav items focusable
|
||||
navList.querySelectorAll("li").forEach((li) => li.setAttribute("role", "none"));
|
||||
|
||||
// ── Search filter ──
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener("input", () => {
|
||||
const q = searchInput.value.toLowerCase().trim();
|
||||
allLinks.forEach((link) => {
|
||||
const text = link.textContent.toLowerCase();
|
||||
if (!q || text.includes(q)) {
|
||||
link.classList.remove("hidden");
|
||||
} else {
|
||||
link.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
// Update active state for visible links
|
||||
updateActiveLink();
|
||||
});
|
||||
// Clear search on Escape
|
||||
searchInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
searchInput.value = "";
|
||||
searchInput.dispatchEvent(new Event("input"));
|
||||
// ── Keyboard navigation in content pane ──
|
||||
if (helpContent) {
|
||||
helpContent.setAttribute("tabindex", "-1");
|
||||
helpContent.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
// Left arrow in content: go back to TOC
|
||||
e.preventDefault();
|
||||
focusTOC();
|
||||
} else if (e.key === "ArrowUp" && document.activeElement === helpContent) {
|
||||
e.preventDefault();
|
||||
focusTOC();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Keyboard navigation in search input ──
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const firstLink = navList.querySelector(".help-nav-link:not(.hidden)");
|
||||
if (firstLink) firstLink.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Search: filter TOC + hide non-matching content sections ──
|
||||
let currentQuery = "";
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener("input", () => {
|
||||
currentQuery = searchInput.value.toLowerCase().trim();
|
||||
applySearchFilter();
|
||||
});
|
||||
searchInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && currentQuery) {
|
||||
searchInput.value = "";
|
||||
currentQuery = "";
|
||||
applySearchFilter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applySearchFilter() {
|
||||
const q = currentQuery;
|
||||
// Filter TOC links
|
||||
allLinks.forEach((link) => {
|
||||
const text = link.textContent.toLowerCase();
|
||||
if (!q || text.includes(q)) {
|
||||
link.classList.remove("hidden");
|
||||
} else {
|
||||
link.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
// Filter content sections
|
||||
allSections.forEach((section) => {
|
||||
if (!q) {
|
||||
section.classList.remove("help-hidden");
|
||||
return;
|
||||
}
|
||||
const sectionText = sectionTextCache[section.id] || '';
|
||||
const sectionTitle = (section.querySelector("h2, h3")?.textContent || '').toLowerCase();
|
||||
if (sectionTitle.includes(q) || sectionText.includes(q)) {
|
||||
section.classList.remove("help-hidden");
|
||||
} else {
|
||||
section.classList.add("help-hidden");
|
||||
}
|
||||
});
|
||||
updateActiveLink();
|
||||
// If no visible results, show a message
|
||||
ensureNoResultsMessage(q);
|
||||
}
|
||||
|
||||
function ensureNoResultsMessage(q) {
|
||||
const existing = document.getElementById("help-no-results");
|
||||
const visibleSections = allSections.filter(s => !s.classList.contains("help-hidden"));
|
||||
if (q && visibleSections.length === 0) {
|
||||
if (!existing) {
|
||||
const msg = document.createElement("div");
|
||||
msg.id = "help-no-results";
|
||||
msg.style.cssText = "padding:40px;text-align:center;color:var(--text-muted);font-size:0.9rem";
|
||||
msg.innerHTML = `<div style="font-size:2rem;margin-bottom:8px">🔍</div><p>Aucun résultat pour <strong>"${q}"</strong></p><p style="font-size:0.75rem">Essayez un autre terme</p>`;
|
||||
if (helpContent) helpContent.appendChild(msg);
|
||||
}
|
||||
} else if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mobile toggle ──
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
const wasCollapsed = nav.classList.contains("collapsed");
|
||||
nav.classList.toggle("collapsed");
|
||||
// If expanding, focus search
|
||||
if (wasCollapsed && searchInput) {
|
||||
setTimeout(() => searchInput.focus(), 150);
|
||||
}
|
||||
});
|
||||
// Start collapsed on mobile
|
||||
if (window.innerWidth <= 768) {
|
||||
nav.classList.add("collapsed");
|
||||
}
|
||||
// Start expanded on mobile so user sees the TOC immediately
|
||||
// (we use collapsed only after user taps a section or explicitly toggles)
|
||||
window.addEventListener("resize", () => {
|
||||
if (window.innerWidth > 768) {
|
||||
nav.classList.remove("collapsed");
|
||||
@@ -314,31 +435,25 @@ function initHelpNavigation() {
|
||||
}
|
||||
|
||||
// ── Scroll spy (IntersectionObserver) ──
|
||||
let observer;
|
||||
function updateActiveLink() {
|
||||
// Find the first visible section and highlight its link
|
||||
const sections = [...document.querySelectorAll(".help-section")];
|
||||
const visibleLinks = allLinks.filter(l => !l.classList.contains("hidden"));
|
||||
const visibleSections = allSections.filter(s => !s.classList.contains("help-hidden"));
|
||||
let activeId = null;
|
||||
for (const section of sections) {
|
||||
for (const section of visibleSections) {
|
||||
const rect = section.getBoundingClientRect();
|
||||
const helpContent = document.querySelector(".help-content");
|
||||
const contentRect = helpContent ? helpContent.getBoundingClientRect() : null;
|
||||
// Section top is in the upper half of the viewport (or within content)
|
||||
if (rect.top < (contentRect ? contentRect.top + 120 : 200)) {
|
||||
activeId = section.id;
|
||||
}
|
||||
}
|
||||
if (activeId) {
|
||||
allLinks.forEach((link) => {
|
||||
const isMatch = link.getAttribute("href") === `#${activeId}`;
|
||||
link.classList.toggle("active", isMatch);
|
||||
});
|
||||
}
|
||||
visibleLinks.forEach((link) => {
|
||||
const isMatch = link.getAttribute("href") === `#${activeId}`;
|
||||
link.classList.toggle("active", isMatch);
|
||||
});
|
||||
}
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// Find the entry most visible near the top
|
||||
let bestEntry = null;
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
@@ -362,27 +477,24 @@ function initHelpNavigation() {
|
||||
}
|
||||
);
|
||||
|
||||
document.querySelectorAll(".help-section").forEach((section) => {
|
||||
observer.observe(section);
|
||||
});
|
||||
allSections.forEach((section) => observer.observe(section));
|
||||
|
||||
// ── Keyboard: Ctrl+↑/↓ inside help body to jump between sections ──
|
||||
// ── Keyboard: Ctrl+↑/↓ to jump between sections ──
|
||||
helpBody.addEventListener("keydown", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||
e.preventDefault();
|
||||
const sections = [...document.querySelectorAll(".help-section")];
|
||||
const currentSection = sections.find((s) => {
|
||||
const visibleSections = allSections.filter(s => !s.classList.contains("help-hidden"));
|
||||
const currentSection = visibleSections.find((s) => {
|
||||
const rect = s.getBoundingClientRect();
|
||||
const helpContent = document.querySelector(".help-content");
|
||||
const contentRect = helpContent ? helpContent.getBoundingClientRect() : null;
|
||||
return rect.top >= (contentRect ? contentRect.top : 0) && rect.top < (contentRect ? contentRect.top + 120 : 120);
|
||||
});
|
||||
if (!currentSection) return;
|
||||
const currentIdx = sections.indexOf(currentSection);
|
||||
if (e.key === "ArrowDown" && currentIdx < sections.length - 1) {
|
||||
sections[currentIdx + 1].scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
const currentIdx = visibleSections.indexOf(currentSection);
|
||||
if (e.key === "ArrowDown" && currentIdx < visibleSections.length - 1) {
|
||||
visibleSections[currentIdx + 1].scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
} else if (e.key === "ArrowUp" && currentIdx > 0) {
|
||||
sections[currentIdx - 1].scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
visibleSections[currentIdx - 1].scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+29
-6
@@ -2287,6 +2287,9 @@ select {
|
||||
.help-nav-link.hidden {
|
||||
display: none;
|
||||
}
|
||||
.help-section.help-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
flex: 1;
|
||||
@@ -6410,29 +6413,49 @@ body.popup-mode .content-area {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: max-height 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.help-nav-header {
|
||||
padding: 12px 16px 8px;
|
||||
padding: 10px 16px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.help-nav-toggle {
|
||||
display: inline-flex; /* visible on mobile */
|
||||
display: inline-flex;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.help-nav:not(.collapsed) .help-nav-toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.help-nav.collapsed .help-nav-search-wrap,
|
||||
.help-nav.collapsed .help-nav-list {
|
||||
display: none;
|
||||
}
|
||||
.help-nav-search-wrap {
|
||||
padding: 0 12px 6px;
|
||||
}
|
||||
.help-nav-list {
|
||||
max-height: 40vh;
|
||||
max-height: 45vh;
|
||||
flex: none;
|
||||
padding-bottom: 12px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.help-content {
|
||||
padding: 16px 12px 40px;
|
||||
padding: 14px 12px 40px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.help-content:focus {
|
||||
outline: none;
|
||||
}
|
||||
.help-hero {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.help-hero-visual { display: none; }
|
||||
.help-section { padding: 24px 0 20px; }
|
||||
.help-section { padding: 20px 0 16px; }
|
||||
.help-nav-link {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user