Skip to content

Commit 17471b4

Browse files
pekkahclaude[bot]
andauthored
fix: make TOC mobile-friendly with toggle and responsive positioning (#231)
* fix: make TOC mobile-friendly with toggle and responsive positioning - Add mobile TOC toggle button at top of content - TOC collapses/expands with visual feedback (list/chevron icons) - Auto-hides after clicking links on mobile for better UX - Dual TOC system keeps desktop sidebar and mobile versions synchronized - Use responsive breakpoint at 992px (Bootstrap lg) - Position TOC at top of content on mobile as requested Fixes #230 Co-authored-by: Pekka Heikura <pekkah@users.noreply.github.com> * refactor: eliminate duplicate TOC and improve performance - Replace dual TOC system with single responsive TOC structure - Use CSS-only positioning for mobile vs desktop layout - Add proper null checks and error handling throughout - Optimize scroll performance (25ms throttling vs 10ms) - Extract constants for magic numbers (offsets, breakpoints) - Remove ~200 lines of duplicate code and DOM elements - Maintain all existing UX functionality (toggle, auto-collapse, etc.) Co-authored-by: Pekka Heikura <pekkah@users.noreply.github.com> --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Pekka Heikura <pekkah@users.noreply.github.com>
1 parent 8b43cc2 commit 17471b4

File tree

1 file changed

+125
-31
lines changed

1 file changed

+125
-31
lines changed

ui-bundle/article.hbs

Lines changed: 125 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,47 @@
4545
padding-top: 1rem;
4646
}
4747
48+
/* Mobile TOC styling - single responsive TOC */
49+
@media (max-width: 991.98px) {
50+
.toc-sidebar {
51+
position: static !important;
52+
height: auto !important;
53+
max-height: 300px;
54+
overflow-y: auto;
55+
background-color: #f8f9fa;
56+
border: 1px solid #dee2e6;
57+
border-radius: 0.375rem;
58+
margin-bottom: 1rem;
59+
padding: 1rem;
60+
/* Move TOC to mobile position - above content */
61+
order: -1;
62+
display: block !important; /* Override Bootstrap d-none d-lg-block */
63+
}
64+
65+
.toc-sidebar.collapsed {
66+
display: none !important;
67+
}
68+
69+
.toc-sidebar .toc-title {
70+
display: none; /* Hide desktop title on mobile */
71+
}
72+
73+
/* Adjust main content layout for mobile */
74+
.content {
75+
order: 0;
76+
}
77+
}
78+
79+
.toc-toggle {
80+
display: none;
81+
}
82+
83+
@media (max-width: 991.98px) {
84+
.toc-toggle {
85+
display: inline-block;
86+
}
87+
}
88+
4889
.sidebar .nav-link {
4990
font-size: 0.9rem;
5091
color: #495057;
@@ -145,11 +186,21 @@
145186
<nav class="col-md-3 col-lg-2 d-md-block sidebar collapse">
146187
{{>partials/Navigation.hbs}}
147188
</nav>
148-
<main class="col-md-6 ms-sm-auto col-lg-7 px-md-4 py-3 content">
189+
<main class="col-md-6 ms-sm-auto col-lg-7 px-md-4 py-3 content d-flex flex-column">
190+
<!-- Mobile TOC toggle button -->
191+
<div class="d-lg-none mb-3">
192+
<button id="toc-toggle" class="btn btn-outline-secondary btn-sm toc-toggle mb-2" type="button">
193+
<svg width="16" height="16" fill="currentColor" class="bi bi-list" viewBox="0 0 16 16">
194+
<path fill-rule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z"/>
195+
</svg>
196+
<span class="ms-1">On this page</span>
197+
</button>
198+
</div>
149199
{{{PageHtml}}}
150200
</main>
151-
<nav id="toc" class="col-md-3 col-lg-3 d-none d-lg-block toc-sidebar">
152-
<strong class="d-block h6 my-2 pb-2 border-bottom">On this page</strong>
201+
<!-- Single responsive TOC -->
202+
<nav id="toc" class="col-md-3 col-lg-3 d-none d-lg-block toc-sidebar collapsed">
203+
<strong class="d-block h6 my-2 pb-2 border-bottom toc-title">On this page</strong>
153204
<nav class="nav flex-column"></nav>
154205
</nav>
155206
</div>
@@ -196,17 +247,34 @@
196247
});
197248
}
198249
199-
// "On this page" TOC script
250+
// "On this page" TOC script - single responsive TOC
200251
const toc = document.getElementById('toc');
252+
const tocToggle = document.getElementById('toc-toggle');
253+
254+
// Constants
255+
const SCROLL_OFFSET = 100;
256+
const SCROLL_THROTTLE_MS = 25; // Optimized from 10ms
257+
const SCROLL_NAVIGATION_TIMEOUT_MS = 200;
258+
const MOBILE_BREAKPOINT = 992;
259+
201260
if (toc) {
202261
const mainContent = document.querySelector('.content');
262+
if (!mainContent) return;
263+
203264
const headings = mainContent.querySelectorAll('h2, h3');
204265
const tocNav = toc.querySelector('.nav');
205266
267+
if (!tocNav) return;
268+
206269
if (headings.length > 0) {
270+
let isScrolling = false;
271+
272+
// Create TOC links
207273
headings.forEach((heading, index) => {
208274
const id = 'heading-' + index;
209-
heading.setAttribute('id', id);
275+
if (!heading.getAttribute('id')) {
276+
heading.setAttribute('id', id);
277+
}
210278
211279
const link = document.createElement('a');
212280
link.classList.add('nav-link');
@@ -221,7 +289,7 @@
221289
link.addEventListener('click', (e) => {
222290
e.preventDefault();
223291
224-
// Temporarily disable our custom scroll tracking during navigation
292+
// Temporarily disable scroll tracking during navigation
225293
isScrolling = true;
226294
227295
// Remove active class from all TOC links
@@ -230,6 +298,14 @@
230298
// Add active class to clicked link
231299
link.classList.add('active');
232300
301+
// On mobile, collapse the TOC after clicking a link
302+
if (window.innerWidth < MOBILE_BREAKPOINT && toc) {
303+
toc.classList.add('collapsed');
304+
if (tocToggle) {
305+
updateToggleButton(true);
306+
}
307+
}
308+
233309
// Navigate to the hash
234310
window.location.hash = href;
235311
@@ -239,13 +315,13 @@
239315
clearTimeout(scrollTimeout);
240316
scrollTimeout = setTimeout(() => {
241317
window.removeEventListener('scroll', handleScroll);
242-
isScrolling = false; // Re-enable scroll tracking
243-
}, 200);
318+
isScrolling = false;
319+
}, SCROLL_NAVIGATION_TIMEOUT_MS);
244320
};
245321
246322
window.addEventListener('scroll', handleScroll);
247323
248-
// Fallback: re-enable after 1 second regardless
324+
// Fallback: re-enable after 1 second
249325
setTimeout(() => {
250326
window.removeEventListener('scroll', handleScroll);
251327
isScrolling = false;
@@ -255,11 +331,25 @@
255331
tocNav.appendChild(link);
256332
});
257333
334+
// Update toggle button appearance
335+
const updateToggleButton = (isCollapsed) => {
336+
if (!tocToggle) return;
337+
338+
if (isCollapsed) {
339+
tocToggle.innerHTML = `<svg width="16" height="16" fill="currentColor" class="bi bi-list" viewBox="0 0 16 16">
340+
<path fill-rule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z"/>
341+
</svg><span class="ms-1">On this page</span>`;
342+
} else {
343+
tocToggle.innerHTML = `<svg width="16" height="16" fill="currentColor" class="bi bi-chevron-up" viewBox="0 0 16 16">
344+
<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708l6-6z"/>
345+
</svg><span class="ms-1">Hide</span>`;
346+
}
347+
};
348+
258349
// Set initial active state based on current scroll position or hash
259350
const setInitialActiveState = () => {
260351
const hash = window.location.hash;
261-
if (hash) {
262-
// If there's a hash, activate the corresponding link
352+
if (hash && tocNav) {
263353
const targetLink = tocNav.querySelector(`a[href="${hash}"]`);
264354
if (targetLink) {
265355
tocNav.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
@@ -268,34 +358,30 @@
268358
}
269359
}
270360
271-
// Otherwise, find the currently visible heading
361+
// Find the currently visible heading
272362
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
273-
const offset = 100; // Offset from top of viewport
274363
let activeHeading = null;
275364
276-
// Find the heading that is currently in view or just passed
277365
for (let i = 0; i < headings.length; i++) {
278366
const heading = headings[i];
279367
const rect = heading.getBoundingClientRect();
280368
const headingTop = rect.top + scrollTop;
281369
282-
// Check if this heading is above the scroll position + offset
283-
if (headingTop <= scrollTop + offset) {
370+
if (headingTop <= scrollTop + SCROLL_OFFSET) {
284371
activeHeading = heading;
285372
} else {
286-
// If we've found a heading that's below our threshold, stop looking
287373
break;
288374
}
289375
}
290376
291-
if (activeHeading) {
377+
if (activeHeading && tocNav) {
292378
const activeId = activeHeading.getAttribute('id');
293379
const activeLink = tocNav.querySelector(`a[href="#${activeId}"]`);
294380
if (activeLink) {
295381
tocNav.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
296382
activeLink.classList.add('active');
297383
}
298-
} else {
384+
} else if (tocNav) {
299385
// If no heading is visible, activate the first one
300386
const firstLink = tocNav.querySelector('.nav-link');
301387
if (firstLink) {
@@ -307,43 +393,35 @@
307393
// Set initial active state
308394
setInitialActiveState();
309395
310-
// Custom scroll tracking instead of Bootstrap ScrollSpy
311-
let isScrolling = false;
396+
// Optimized scroll tracking
312397
const updateActiveOnScroll = () => {
313-
if (isScrolling) return;
398+
if (isScrolling || !tocNav) return;
314399
315400
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
316-
const offset = 100; // Offset from top of viewport
317401
let activeHeading = null;
318402
319-
// Find the heading that is currently in view or just passed
320403
for (let i = 0; i < headings.length; i++) {
321404
const heading = headings[i];
322405
const rect = heading.getBoundingClientRect();
323406
const headingTop = rect.top + scrollTop;
324407
325-
// Check if this heading is above the scroll position + offset
326-
if (headingTop <= scrollTop + offset) {
408+
if (headingTop <= scrollTop + SCROLL_OFFSET) {
327409
activeHeading = heading;
328410
} else {
329-
// If we've found a heading that's below our threshold, stop looking
330411
break;
331412
}
332413
}
333414
334-
// Update active link
335415
const currentActiveLink = tocNav.querySelector('.nav-link.active');
336416
let newActiveLink = null;
337417
338418
if (activeHeading) {
339419
const activeId = activeHeading.getAttribute('id');
340420
newActiveLink = tocNav.querySelector(`a[href="#${activeId}"]`);
341421
} else {
342-
// If no heading is in view, activate the first one
343422
newActiveLink = tocNav.querySelector('.nav-link');
344423
}
345424
346-
// Only update if the active link has changed
347425
if (newActiveLink && newActiveLink !== currentActiveLink) {
348426
tocNav.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
349427
newActiveLink.classList.add('active');
@@ -354,10 +432,26 @@
354432
let scrollTimeout;
355433
window.addEventListener('scroll', () => {
356434
clearTimeout(scrollTimeout);
357-
scrollTimeout = setTimeout(updateActiveOnScroll, 10);
435+
scrollTimeout = setTimeout(updateActiveOnScroll, SCROLL_THROTTLE_MS);
358436
});
437+
438+
// Mobile TOC toggle functionality
439+
if (tocToggle) {
440+
tocToggle.addEventListener('click', () => {
441+
const isCollapsed = toc.classList.contains('collapsed');
442+
if (isCollapsed) {
443+
toc.classList.remove('collapsed');
444+
updateToggleButton(false);
445+
} else {
446+
toc.classList.add('collapsed');
447+
updateToggleButton(true);
448+
}
449+
});
450+
}
359451
} else {
452+
// Hide TOC if no headings
360453
toc.style.display = 'none';
454+
if (tocToggle) tocToggle.style.display = 'none';
361455
}
362456
}
363457

0 commit comments

Comments
 (0)