UNPKG

starlight-scroll-to-top

Version:

Add a scroll to top button to your documentation website.

444 lines (397 loc) 15.2 kB
/** * Creates and manages the scroll-to-top button * @param {Object} config - Configuration options * @param {string} config.position - Button position relative to the bottom corner of the page ('left' or 'right') * @param {string} config.tooltipText - Text to show in the tooltip * @param {boolean} config.smoothScroll - Whether to use smooth scrolling * @param {number} config.threshold - Height after page scroll to be visible (percentage) * @param {string} config.svgPath - The SVG icon path d attribute * @param {number} config.borderRadius - The radius of the button corners, 50 for circle. * @param {boolean} config.showTooltip - Whether to show the tooltip on hover * @param {boolean} config.svgStrokeWidth - The SVG icon stroke width */ function initScrollToTop(config = {}) { const { position = "right", tooltipText = "Scroll to top", smoothScroll = true, threshold = 30, // Default: show when scrolled 30% down svgPath = "M18 15l-6-6-6 6", svgStrokeWidth = "2", borderRadius = "15", showTooltip = false, showProgressRing = false, progressRingColor = "yellow", } = config; // Store cleanup function globally to handle view transitions. let cleanup = null; // Check if current page is homepage using DOM content detection. const isHomepage = () => { // Check for common homepage/hero elements in Starlight. return document.querySelector('.hero') || document.querySelector('.sl-hero') || document.querySelector('[data-page="index"]') || document.querySelector('.landing-page') || document.querySelector('.homepage') || document.querySelector('[data-starlight-homepage]') || document.querySelector('.site-hero') || // Check if body has homepage-related classes. document.body.classList.contains('homepage') || document.body.classList.contains('landing') || // Check for Starlight's main content wrapper with hero content. (document.querySelector('main.sl-main') && document.querySelector('main.sl-main .hero, main.sl-main .sl-hero')); }; const initButton = () => { // Clean up existing button if it exists. if (cleanup) { cleanup(); } // Skip button creation if this is the homepage. if (isHomepage()) { return; } // Create the button element. const scrollToTopButton = document.createElement("button"); scrollToTopButton.id = "scroll-to-top-button"; scrollToTopButton.ariaLabel = tooltipText; scrollToTopButton.setAttribute('aria-describedby', showTooltip ? 'scroll-to-top-tooltip' : ''); scrollToTopButton.setAttribute('role', 'button'); scrollToTopButton.setAttribute('tabindex', '0'); let isKeyboard = false; // Add button with configurable SVG icon and optional progress ring. scrollToTopButton.innerHTML = ` ${showProgressRing ? ` <svg class="scroll-progress-ring" width="47" height="47" viewBox="0 0 47 47" style="position: absolute; top: 0; left: 0;"> <!-- Background circle --> <circle cx="23.5" cy="23.5" r="22" fill="none" stroke="${progressRingColor}" stroke-width="3" opacity="0.2" /> <!-- Progress circle --> <circle cx="23.5" cy="23.5" r="22" fill="none" stroke="${progressRingColor}" stroke-width="3" stroke-linecap="round" class="scroll-progress-circle" style="transform: rotate(-90deg); transform-origin: center;" /> </svg> ` : ''} <svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${svgStrokeWidth}" stroke-linecap="round" stroke-linejoin="round" style="position: relative; z-index: 1;"> <path d="${svgPath}"/> </svg> `; // Create tooltip element. const tooltip = document.createElement("div"); tooltip.id = "scroll-to-top-tooltip"; tooltip.textContent = tooltipText; // Create the arrow element. const arrow = document.createElement("div"); arrow.style.cssText = ` position: absolute; top: 100%; /* Position below the tooltip */ left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid var(--sl-color-gray-5); `; // Create the custom style element. const customStyle = document.createElement("style"); customStyle.id = "scroll-to-top-styles"; customStyle.textContent = ` .scroll-to-top-button { position: fixed; bottom: 40px; width: 47px; height: 47px; ${ position === "left" ? "left: 40px;" : position === "right" ? "right: 35px;" : "left: 50%; transform: translateX(-50%);" } border-radius: ${borderRadius}%; background-color: var(--sl-color-accent); color: white; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease, background-color 0.3s ease, transform 0.3s ease; z-index: 100; border: 1px solid var(--sl-color-accent); transform-origin: center; -webkit-tap-highlight-color: transparent; /* Disable mobile tap highlight */ touch-action: manipulation; /* Prevent double-tap zoom */ box-shadow: 0 0 0 1px rgba(0,0,0,0.04),0 4px 8px 0 rgba(0,0,0,0.2); } .scroll-to-top-button:active { background-color: var(--sl-color-accent-dark); color: var(--sl-text-white); transition: background-color 0.1s ease, transform 0.1s ease; } .scroll-to-top-button.visible { opacity: 1; visibility: visible; } .scroll-to-top-button:hover { background-color: var(--sl-color-gray-5); box-shadow: 0 0 0 1px rgba(0,0,0,0.04),0 4px 8px 0 rgba(0,0,0,0.2); color: var(--sl-color-accent); border-color: var(--sl-color-accent); } .scroll-to-top-button.keyboard-focus { outline: 2px solid var(--sl-color-text); outline-offset: 2px; } .scroll-to-top-btn-tooltip { position: absolute; ${position === "left" ? "left: -25px;" : "right: -22px;"} top: -47px; background-color: var(--sl-color-gray-6); color: var(--sl-color-text); padding: 5px 10px; border-radius: 4px; font-weight: 400; font-size: 14px; white-space: nowrap; opacity: 0; visibility: hidden; transition: opacity 0.2s, visibility 0.3s; pointer-events: none; } .scroll-to-top-btn-tooltip.visible { opacity: 1; visibility: visible; } /* Progress ring styles */ .scroll-progress-ring { pointer-events: none; } .scroll-progress-circle { stroke-dasharray: 138.23; /* 2 * π * r = 2 * π * 22 ≈ 138.23 */ stroke-dashoffset: 138.23; transition: stroke-dashoffset 0.1s ease; } `; document.head.appendChild(customStyle); scrollToTopButton.classList.add("scroll-to-top-button"); // Add the button to the body. document.body.appendChild(scrollToTopButton); // Add tooltip to the button's container. if (showTooltip) { tooltip.classList.add("scroll-to-top-btn-tooltip"); tooltip.appendChild(arrow); scrollToTopButton.appendChild(tooltip); } const hideTooltip = () => { tooltip.classList.remove("visible"); }; const openTooltip = () => { if (showTooltip) { tooltip.classList.add("visible"); } }; // Add tooltip display on hover. scrollToTopButton.addEventListener("mouseenter", () => { openTooltip(); }); scrollToTopButton.addEventListener("mouseleave", () => { hideTooltip(); }); const doScrollToTop = () => { hideTooltip(); window.scrollTo({ top: 0, behavior: smoothScroll ? "smooth" : "auto", }); // Explicitly reset styles after scroll. scrollToTopButton.classList.remove("active"); }; // Detect keyboard input globally (e.g., Tab key). //This ensures that the isKeyboard flag is set as soon as the Tab key is pressed, before the focus event is triggered on the button. document.addEventListener("keydown", (event) => { if (event.key === "Tab") { isKeyboard = true; } }); // Detect mouse input. scrollToTopButton.addEventListener("mousedown", () => { isKeyboard = false; }); // Detect keyboard input (e.g., Tab key). scrollToTopButton.addEventListener("keydown", (event) => { if (event.key === "Enter") { doScrollToTop(); // Hide focus style. scrollToTopButton.classList.remove("keyboard-focus"); } }); // Handle focus event for buttons. scrollToTopButton.addEventListener("focus", () => { if (isKeyboard) { // We only need to outline the button when it focused using the keyboard. openTooltip(); scrollToTopButton.classList.add("keyboard-focus"); } }); scrollToTopButton.addEventListener("blur", () => { hideTooltip(); scrollToTopButton.classList.remove("keyboard-focus"); }); // Handle mobile taps. scrollToTopButton.addEventListener("touchstart", (e) => { e.preventDefault(); // Prevent default touch behavior. scrollToTopButton.classList.add("active"); }); scrollToTopButton.addEventListener("touchend", (e) => { e.preventDefault(); // Prevent default touch behavior. doScrollToTop(); scrollToTopButton.classList.remove("active"); }); // Add click event to scroll to top with smooth scrolling option. // Handle desktop clicks. scrollToTopButton.addEventListener("click", (e) => { e.preventDefault(); // Prevent default click behavior. doScrollToTop(); }); // Throttle function for performance optimization. function throttle(func, limit) { let inThrottle; return function() { const args = arguments; const context = this; if (!inThrottle) { func.apply(context, args); inThrottle = true; setTimeout(() => inThrottle = false, limit); } } } // Show/hide the button based on scroll position. const toggleScrollToTopButton = () => { const scrollPosition = window.scrollY; const viewportHeight = window.innerHeight; const pageHeight = document.documentElement.scrollHeight; // Calculate how far down the page the user has scrolled. const scrollPercentage = scrollPosition / (pageHeight - viewportHeight); // Update progress ring if enabled if (showProgressRing) { const progressCircle = scrollToTopButton.querySelector('.scroll-progress-circle'); if (progressCircle) { // Calculate progress as percentage (0-100) let progress = scrollPercentage * 100; if (progress >= 99.5) progress = 100; if (progress < 0) progress = 0; // Calculate stroke-dashoffset (full circumference - progress) const circumference = 138.23; // 2 * π * 22 const offset = circumference - (progress / 100) * circumference; progressCircle.style.strokeDashoffset = offset.toString(); } } // Ensure threshold is between 10 and 99. const thresholdValue = threshold >= 10 && threshold <= 99 ? threshold : 30; if (scrollPercentage > thresholdValue / 100) { // Show when scrolled past configured threshold. scrollToTopButton.classList.add("visible"); } else { scrollToTopButton.classList.remove("visible"); } }; // Add throttled scroll event listener (16ms ≈ 60fps). const throttledScrollHandler = throttle(toggleScrollToTopButton, 16); window.addEventListener("scroll", throttledScrollHandler); // Initial check on page load. toggleScrollToTopButton(); // Handle theme changes by applying appropriate styles. const updateThemeStyles = () => { const isDarkTheme = document.documentElement.classList.contains("theme-dark"); if (isDarkTheme) { tooltip.style.backgroundColor = "var(--sl-color-gray-6)"; } else { tooltip.style.backgroundColor = "var(--sl-color-gray-5)"; } }; // Initial theme check. updateThemeStyles(); // Monitor theme changes. const observer = new MutationObserver(updateThemeStyles); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], }); // Function to check zoom level and hide the button accordingly. function checkZoomLevel() { // Calculate actual browser zoom level. const zoomLevel = Math.round((window.outerWidth / window.innerWidth) * 100) / 100; // If zoom level is above 300%, hide the button. if (zoomLevel > 3) { scrollToTopButton.style.display = "none"; } else { scrollToTopButton.style.display = "flex"; } } // Run the check whenever the window is resized or zoomed. window.addEventListener("resize", checkZoomLevel); // Also run it on initial load to account for the page's zoom state. checkZoomLevel(); // Cleanup function to remove event listeners when navigating between pages. cleanup = () => { window.removeEventListener("scroll", throttledScrollHandler); window.removeEventListener("resize", checkZoomLevel); observer.disconnect(); if (scrollToTopButton && scrollToTopButton.parentNode) { scrollToTopButton.parentNode.removeChild(scrollToTopButton); } // Remove the style element if it exists. const existingStyle = document.getElementById("scroll-to-top-styles"); if (existingStyle) { existingStyle.remove(); } }; return cleanup; }; // Initialize on page load (works for both initial load and view transitions). const handlePageLoad = () => { // Small delay to ensure DOM is ready. setTimeout(initButton, 10); }; // Handle initial page load and Astro view transitions. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', handlePageLoad); } else { handlePageLoad(); } // Handle Astro view transitions. document.addEventListener('astro:page-load', handlePageLoad); // Cleanup before navigation. document.addEventListener('astro:before-preparation', () => { if (cleanup) { cleanup(); } }); } export default initScrollToTop;