|
45 | 45 | padding-top: 1rem; |
46 | 46 | } |
47 | 47 |
|
| 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 | +
|
48 | 89 | .sidebar .nav-link { |
49 | 90 | font-size: 0.9rem; |
50 | 91 | color: #495057; |
|
145 | 186 | <nav class="col-md-3 col-lg-2 d-md-block sidebar collapse"> |
146 | 187 | {{>partials/Navigation.hbs}} |
147 | 188 | </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> |
149 | 199 | {{{PageHtml}}} |
150 | 200 | </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> |
153 | 204 | <nav class="nav flex-column"></nav> |
154 | 205 | </nav> |
155 | 206 | </div> |
|
196 | 247 | }); |
197 | 248 | } |
198 | 249 |
|
199 | | - // "On this page" TOC script |
| 250 | + // "On this page" TOC script - single responsive TOC |
200 | 251 | 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 | + |
201 | 260 | if (toc) { |
202 | 261 | const mainContent = document.querySelector('.content'); |
| 262 | + if (!mainContent) return; |
| 263 | + |
203 | 264 | const headings = mainContent.querySelectorAll('h2, h3'); |
204 | 265 | const tocNav = toc.querySelector('.nav'); |
205 | 266 | |
| 267 | + if (!tocNav) return; |
| 268 | + |
206 | 269 | if (headings.length > 0) { |
| 270 | + let isScrolling = false; |
| 271 | + |
| 272 | + // Create TOC links |
207 | 273 | headings.forEach((heading, index) => { |
208 | 274 | const id = 'heading-' + index; |
209 | | - heading.setAttribute('id', id); |
| 275 | + if (!heading.getAttribute('id')) { |
| 276 | + heading.setAttribute('id', id); |
| 277 | + } |
210 | 278 |
|
211 | 279 | const link = document.createElement('a'); |
212 | 280 | link.classList.add('nav-link'); |
|
221 | 289 | link.addEventListener('click', (e) => { |
222 | 290 | e.preventDefault(); |
223 | 291 | |
224 | | - // Temporarily disable our custom scroll tracking during navigation |
| 292 | + // Temporarily disable scroll tracking during navigation |
225 | 293 | isScrolling = true; |
226 | 294 | |
227 | 295 | // Remove active class from all TOC links |
|
230 | 298 | // Add active class to clicked link |
231 | 299 | link.classList.add('active'); |
232 | 300 | |
| 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 | + |
233 | 309 | // Navigate to the hash |
234 | 310 | window.location.hash = href; |
235 | 311 | |
|
239 | 315 | clearTimeout(scrollTimeout); |
240 | 316 | scrollTimeout = setTimeout(() => { |
241 | 317 | window.removeEventListener('scroll', handleScroll); |
242 | | - isScrolling = false; // Re-enable scroll tracking |
243 | | - }, 200); |
| 318 | + isScrolling = false; |
| 319 | + }, SCROLL_NAVIGATION_TIMEOUT_MS); |
244 | 320 | }; |
245 | 321 | |
246 | 322 | window.addEventListener('scroll', handleScroll); |
247 | 323 | |
248 | | - // Fallback: re-enable after 1 second regardless |
| 324 | + // Fallback: re-enable after 1 second |
249 | 325 | setTimeout(() => { |
250 | 326 | window.removeEventListener('scroll', handleScroll); |
251 | 327 | isScrolling = false; |
|
255 | 331 | tocNav.appendChild(link); |
256 | 332 | }); |
257 | 333 |
|
| 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 | +
|
258 | 349 | // Set initial active state based on current scroll position or hash |
259 | 350 | const setInitialActiveState = () => { |
260 | 351 | const hash = window.location.hash; |
261 | | - if (hash) { |
262 | | - // If there's a hash, activate the corresponding link |
| 352 | + if (hash && tocNav) { |
263 | 353 | const targetLink = tocNav.querySelector(`a[href="${hash}"]`); |
264 | 354 | if (targetLink) { |
265 | 355 | tocNav.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active')); |
|
268 | 358 | } |
269 | 359 | } |
270 | 360 | |
271 | | - // Otherwise, find the currently visible heading |
| 361 | + // Find the currently visible heading |
272 | 362 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; |
273 | | - const offset = 100; // Offset from top of viewport |
274 | 363 | let activeHeading = null; |
275 | 364 | |
276 | | - // Find the heading that is currently in view or just passed |
277 | 365 | for (let i = 0; i < headings.length; i++) { |
278 | 366 | const heading = headings[i]; |
279 | 367 | const rect = heading.getBoundingClientRect(); |
280 | 368 | const headingTop = rect.top + scrollTop; |
281 | 369 | |
282 | | - // Check if this heading is above the scroll position + offset |
283 | | - if (headingTop <= scrollTop + offset) { |
| 370 | + if (headingTop <= scrollTop + SCROLL_OFFSET) { |
284 | 371 | activeHeading = heading; |
285 | 372 | } else { |
286 | | - // If we've found a heading that's below our threshold, stop looking |
287 | 373 | break; |
288 | 374 | } |
289 | 375 | } |
290 | 376 | |
291 | | - if (activeHeading) { |
| 377 | + if (activeHeading && tocNav) { |
292 | 378 | const activeId = activeHeading.getAttribute('id'); |
293 | 379 | const activeLink = tocNav.querySelector(`a[href="#${activeId}"]`); |
294 | 380 | if (activeLink) { |
295 | 381 | tocNav.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active')); |
296 | 382 | activeLink.classList.add('active'); |
297 | 383 | } |
298 | | - } else { |
| 384 | + } else if (tocNav) { |
299 | 385 | // If no heading is visible, activate the first one |
300 | 386 | const firstLink = tocNav.querySelector('.nav-link'); |
301 | 387 | if (firstLink) { |
|
307 | 393 | // Set initial active state |
308 | 394 | setInitialActiveState(); |
309 | 395 |
|
310 | | - // Custom scroll tracking instead of Bootstrap ScrollSpy |
311 | | - let isScrolling = false; |
| 396 | + // Optimized scroll tracking |
312 | 397 | const updateActiveOnScroll = () => { |
313 | | - if (isScrolling) return; |
| 398 | + if (isScrolling || !tocNav) return; |
314 | 399 | |
315 | 400 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; |
316 | | - const offset = 100; // Offset from top of viewport |
317 | 401 | let activeHeading = null; |
318 | 402 | |
319 | | - // Find the heading that is currently in view or just passed |
320 | 403 | for (let i = 0; i < headings.length; i++) { |
321 | 404 | const heading = headings[i]; |
322 | 405 | const rect = heading.getBoundingClientRect(); |
323 | 406 | const headingTop = rect.top + scrollTop; |
324 | 407 | |
325 | | - // Check if this heading is above the scroll position + offset |
326 | | - if (headingTop <= scrollTop + offset) { |
| 408 | + if (headingTop <= scrollTop + SCROLL_OFFSET) { |
327 | 409 | activeHeading = heading; |
328 | 410 | } else { |
329 | | - // If we've found a heading that's below our threshold, stop looking |
330 | 411 | break; |
331 | 412 | } |
332 | 413 | } |
333 | 414 | |
334 | | - // Update active link |
335 | 415 | const currentActiveLink = tocNav.querySelector('.nav-link.active'); |
336 | 416 | let newActiveLink = null; |
337 | 417 | |
338 | 418 | if (activeHeading) { |
339 | 419 | const activeId = activeHeading.getAttribute('id'); |
340 | 420 | newActiveLink = tocNav.querySelector(`a[href="#${activeId}"]`); |
341 | 421 | } else { |
342 | | - // If no heading is in view, activate the first one |
343 | 422 | newActiveLink = tocNav.querySelector('.nav-link'); |
344 | 423 | } |
345 | 424 | |
346 | | - // Only update if the active link has changed |
347 | 425 | if (newActiveLink && newActiveLink !== currentActiveLink) { |
348 | 426 | tocNav.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active')); |
349 | 427 | newActiveLink.classList.add('active'); |
|
354 | 432 | let scrollTimeout; |
355 | 433 | window.addEventListener('scroll', () => { |
356 | 434 | clearTimeout(scrollTimeout); |
357 | | - scrollTimeout = setTimeout(updateActiveOnScroll, 10); |
| 435 | + scrollTimeout = setTimeout(updateActiveOnScroll, SCROLL_THROTTLE_MS); |
358 | 436 | }); |
| 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 | + } |
359 | 451 | } else { |
| 452 | + // Hide TOC if no headings |
360 | 453 | toc.style.display = 'none'; |
| 454 | + if (tocToggle) tocToggle.style.display = 'none'; |
361 | 455 | } |
362 | 456 | } |
363 | 457 |
|
|
0 commit comments