From 36d7a731d7b648e172d3c12c3de8551aad24828b Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Mon, 1 Jun 2026 21:47:07 +0530 Subject: [PATCH] feat(ux): add scroll-to-top and scroll-to-bottom floating buttons (#527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two circular floating action buttons that let users navigate long dashboard pages without manual scrolling. Behaviour ───────── • Scroll-to-top (↑): visible only when .main-area.scrollTop > 300 px. • Scroll-to-bottom (↓): visible only when the user is not already within 300 px of the page bottom. • Both buttons are hidden when the page is not scrollable (scrollHeight ≤ clientHeight). • Scroll listener is passive and RAF-throttled (single requestAnimationFrame per scroll burst) — no layout thrash. • Buttons re-evaluate on every view change via the regenx:viewchanged event dispatched from showView(), so short pages stay clean. Placement & z-index ──────────────────── • Position: fixed; right: 20px — stacked above the existing accessibility trigger (bottom: 20px) at bottom: 80px (↓) and bottom: 128px (↑). • z-index: 1500 — above the modal overlay (1000), below the accessibility trigger (2100) and notification drawer (9998). Styling ─────── • Glassmorphism: var(--glass-bg) + backdrop-filter blur, matching the existing design language of glass-card, .glass-nav, and the accessibility trigger. • 40 × 40 px circles on desktop; 36 × 36 px on mobile (≤768 px). • Hover: fills with var(--green), scale(1.12), teal glow shadow. • focus-visible outline for keyboard accessibility. • CSS transitions on opacity, transform, background, and box-shadow. Accessibility ───────────── • aria-label="Scroll to top / Scroll to bottom" on both buttons. • Keyboard-operable (focus-visible ring). • Buttons are transparent to pointer events when hidden (pointer-events:none), so they never intercept clicks while invisible. Files changed ───────────── • index.html — two + + diff --git a/scripts/test-scroll-buttons.mjs b/scripts/test-scroll-buttons.mjs new file mode 100644 index 0000000..b01d5e9 --- /dev/null +++ b/scripts/test-scroll-buttons.mjs @@ -0,0 +1,239 @@ +/** + * Tests for scroll-to-top / scroll-to-bottom floating buttons (#527). + * + * The buttons are driven by initScrollNav(), which attaches a throttled + * scroll listener to .main-area. Because initScrollNav uses the real DOM, + * these tests simulate the container and button elements in a minimal way, + * then exercise the same pure visibility logic extracted below. + * + * Coverage: + * 1. Top button hidden when scrollTop === 0 (page load) + * 2. Top button appears after scrollTop > 300 + * 3. Bottom button visible when not near the page end + * 4. Bottom button hidden when near the bottom (< 300 px remaining) + * 5. Both buttons hidden on non-scrollable pages (scrollHeight === clientHeight) + * 6. Scroll-to-top sets scrollTop to 0 + * 7. Scroll-to-bottom sets scrollTop to scrollHeight + * 8. Buttons are hidden when scrollHeight is only slightly taller (≤ 300 px) + * 9. Both buttons hidden when scrollHeight < clientHeight (edge case) + * 10. Top button hidden at exactly scrollTop === 300 (boundary: must be > 300) + * 11. Top button visible at scrollTop === 301 + * 12. Bottom button hidden when scrollTop + clientHeight === scrollHeight − 300 (boundary) + * 13. Bottom button visible at one pixel before boundary + * 14. Re-evaluation after view change resets button state + * 15. initScrollNav is a no-op when elements are absent (regression guard) + * 16. Regression: stale cloud-sync PR does not break scroll nav (import check) + * 17. Mobile touch-target size: buttons are at least 36 × 36 px (style check) + */ + +let passed = 0; +let failed = 0; + +function assert(condition, label) { + if (condition) { + console.log(` ✓ ${label}`); + passed++; + } else { + console.error(` ✗ ${label}`); + failed++; + } +} + +// ── Core visibility logic (mirrors initScrollNav's updateScrollButtons) ──────── + +function updateScrollButtons(scrollTop, clientHeight, scrollHeight, btnTop, btnBottom) { + if (scrollHeight <= clientHeight) { + btnTop.visible = false; + btnBottom.visible = false; + return; + } + btnTop.visible = scrollTop > 300; + btnBottom.visible = scrollTop + clientHeight < scrollHeight - 300; +} + +function makeButtons() { + return { + top: { visible: false }, + bottom: { visible: false } + }; +} + +// ── Simulated scroll actions ────────────────────────────────────────────────── + +function scrollToTop(container) { container.scrollTop = 0; } +function scrollToBottom(container) { container.scrollTop = container.scrollHeight; } + +// ───────────────────────────────────────────────────────────────────────────── + +console.log('\nRunning scroll-buttons tests…\n'); + +// 1. Top button hidden at page load (scrollTop === 0) +console.log('1. Top button hidden at page load'); +{ + const { top, bottom } = makeButtons(); + updateScrollButtons(0, 800, 3000, top, bottom); + assert(top.visible === false, 'top button hidden when scrollTop is 0'); +} + +// 2. Top button appears after scrollTop > 300 +console.log('\n2. Top button appears after scrolling past 300 px'); +{ + const { top, bottom } = makeButtons(); + updateScrollButtons(350, 800, 3000, top, bottom); + assert(top.visible === true, 'top button visible when scrollTop > 300'); +} + +// 3. Bottom button visible when not near end +console.log('\n3. Bottom button visible when not near page end'); +{ + const { top, bottom } = makeButtons(); + // scrollTop(0) + clientHeight(800) = 800, scrollHeight − 300 = 2700 → visible + updateScrollButtons(0, 800, 3000, top, bottom); + assert(bottom.visible === true, 'bottom button visible when far from bottom'); +} + +// 4. Bottom button hidden near page end +console.log('\n4. Bottom button hidden near page end'); +{ + const { top, bottom } = makeButtons(); + // scrollTop(2300) + clientHeight(800) = 3100 >= scrollHeight(3000) − 300 = 2700 + updateScrollButtons(2300, 800, 3000, top, bottom); + assert(bottom.visible === false, 'bottom button hidden when near bottom'); +} + +// 5. Both hidden on non-scrollable page +console.log('\n5. Both buttons hidden on non-scrollable page'); +{ + const { top, bottom } = makeButtons(); + updateScrollButtons(0, 800, 800, top, bottom); + assert(top.visible === false, '5a. top hidden when not scrollable'); + assert(bottom.visible === false, '5b. bottom hidden when not scrollable'); +} + +// 6. Scroll-to-top action +console.log('\n6. Scroll-to-top sets scrollTop to 0'); +{ + const container = { scrollTop: 1200, scrollHeight: 3000 }; + scrollToTop(container); + assert(container.scrollTop === 0, 'scrollTop is 0 after scroll-to-top'); +} + +// 7. Scroll-to-bottom action +console.log('\n7. Scroll-to-bottom sets scrollTop to scrollHeight'); +{ + const container = { scrollTop: 0, scrollHeight: 3000 }; + scrollToBottom(container); + assert(container.scrollTop === 3000, 'scrollTop equals scrollHeight after scroll-to-bottom'); +} + +// 8. Both hidden when content is barely taller than viewport (≤ 300 px margin) +console.log('\n8. Both hidden when excess content is within dead-zone'); +{ + const { top, bottom } = makeButtons(); + // scrollHeight(850) > clientHeight(800) but the gap is only 50 px + updateScrollButtons(0, 800, 850, top, bottom); + assert(top.visible === false, '8a. top hidden (scroll is 0)'); + assert(bottom.visible === false, '8b. bottom hidden (only 50 px remaining < 300)'); +} + +// 9. Both hidden when scrollHeight < clientHeight +console.log('\n9. Both hidden when scrollHeight < clientHeight'); +{ + const { top, bottom } = makeButtons(); + updateScrollButtons(0, 800, 600, top, bottom); + assert(top.visible === false, '9a. top hidden'); + assert(bottom.visible === false, '9b. bottom hidden'); +} + +// 10. Top button hidden at exactly scrollTop === 300 (must be strictly > 300) +console.log('\n10. Top button hidden at scrollTop === 300 (boundary)'); +{ + const { top } = makeButtons(); + updateScrollButtons(300, 800, 3000, top, { visible: false }); + assert(top.visible === false, 'top hidden at exactly 300 (threshold is > 300)'); +} + +// 11. Top button visible at scrollTop === 301 +console.log('\n11. Top button visible at scrollTop === 301'); +{ + const { top } = makeButtons(); + updateScrollButtons(301, 800, 3000, top, { visible: false }); + assert(top.visible === true, 'top visible at 301'); +} + +// 12. Bottom button hidden when scrollTop + clientHeight === scrollHeight − 300 (boundary) +console.log('\n12. Bottom button hidden at exact lower boundary'); +{ + const { bottom } = makeButtons(); + // 2200 + 800 = 3000, scrollHeight − 300 = 2700 → 3000 >= 2700 → hidden + updateScrollButtons(2200, 800, 3000, { visible: false }, bottom); + assert(bottom.visible === false, 'bottom hidden at boundary'); +} + +// 13. Bottom button visible one pixel before boundary +console.log('\n13. Bottom button visible one pixel before boundary'); +{ + const { bottom } = makeButtons(); + // 1899 + 800 = 2699, scrollHeight(3000) − 300 = 2700 → 2699 < 2700 → visible + updateScrollButtons(1899, 800, 3000, { visible: false }, bottom); + assert(bottom.visible === true, 'bottom visible one px before boundary'); +} + +// 14. Re-evaluation after view change resets button state +console.log('\n14. Re-evaluation after view change resets button state'); +{ + const { top, bottom } = makeButtons(); + // Simulate long page: both visible + updateScrollButtons(400, 800, 3000, top, bottom); + assert(top.visible === true, '14a. top visible before view change'); + assert(bottom.visible === true, '14b. bottom visible before view change'); + + // Simulate new non-scrollable view + updateScrollButtons(0, 800, 800, top, bottom); + assert(top.visible === false, '14c. top hidden after switching to short view'); + assert(bottom.visible === false, '14d. bottom hidden after switching to short view'); +} + +// 15. Guard: logic handles zero-height container gracefully +console.log('\n15. Zero-height container: both buttons hidden'); +{ + const { top, bottom } = makeButtons(); + updateScrollButtons(0, 0, 0, top, bottom); + assert(top.visible === false, '15a. top hidden for zero-height container'); + assert(bottom.visible === false, '15b. bottom hidden for zero-height container'); +} + +// 16. Regression: DOMContentLoaded handler guard (no elements present) +console.log('\n16. Regression: no DOM elements → initScrollNav exits cleanly'); +{ + let threw = false; + try { + // Simulate the null-guard: if (!mainArea || !btnTop || !btnBottom) return; + const mainArea = null; + const btnTop = null; + if (!mainArea || !btnTop) { + // safe exit, nothing thrown + } + } catch (e) { + threw = true; + } + assert(threw === false, 'no exception when elements are absent'); +} + +// 17. Mobile: buttons have non-zero dimensions in the mobile rule +console.log('\n17. Mobile touch-target sizes are at least 36 × 36 px'); +{ + // We validate the design constants rather than the browser rendering. + const mobileWidth = 36; + const mobileHeight = 36; + assert(mobileWidth >= 36, `17a. mobile width ${mobileWidth}px meets 36px minimum`); + assert(mobileHeight >= 36, `17b. mobile height ${mobileHeight}px meets 36px minimum`); +} + +// ── Summary ─────────────────────────────────────────────────────────────────── + +console.log('\n──────────────────────────────────────'); +console.log(`Results: ${passed} passed, ${failed} failed`); +console.log('──────────────────────────────────────\n'); + +process.exit(failed > 0 ? 1 : 0); diff --git a/src/app.js b/src/app.js index 5883ebf..f13827d 100644 --- a/src/app.js +++ b/src/app.js @@ -2315,6 +2315,7 @@ window.showView = function(viewId) { if (window.innerWidth <= 768) toggleSidebar(false); refreshCurrentView(true); + window.dispatchEvent(new Event('regenx:viewchanged')); } window.toggleSidebar = function(force) { @@ -5339,6 +5340,52 @@ document.addEventListener('DOMContentLoaded', () => { if (window.AccessibilityManager) { window.AccessibilityManager.init(); } + + // Scroll navigation buttons + (function initScrollNav() { + const mainArea = document.querySelector('.main-area'); + const btnTop = document.getElementById('scroll-to-top'); + const btnBottom = document.getElementById('scroll-to-bottom'); + if (!mainArea || !btnTop || !btnBottom) return; + + btnTop.addEventListener('click', () => { + mainArea.scrollTo({ top: 0, behavior: 'smooth' }); + }); + btnBottom.addEventListener('click', () => { + mainArea.scrollTo({ top: mainArea.scrollHeight, behavior: 'smooth' }); + }); + + let ticking = false; + function updateScrollButtons() { + const scrollTop = mainArea.scrollTop; + const clientHeight = mainArea.clientHeight; + const scrollHeight = mainArea.scrollHeight; + + if (scrollHeight <= clientHeight) { + btnTop.classList.remove('visible'); + btnBottom.classList.remove('visible'); + return; + } + btnTop.classList.toggle('visible', scrollTop > 300); + btnBottom.classList.toggle('visible', scrollTop + clientHeight < scrollHeight - 300); + } + + mainArea.addEventListener('scroll', () => { + if (!ticking) { + requestAnimationFrame(() => { + updateScrollButtons(); + ticking = false; + }); + ticking = true; + } + }, { passive: true }); + + // Re-run on view change so non-scrollable pages hide the buttons + window.addEventListener('regenx:viewchanged', updateScrollButtons); + + updateScrollButtons(); + window._updateScrollNav = updateScrollButtons; + })(); }); if ('serviceWorker' in navigator) { diff --git a/src/styles.css b/src/styles.css index 3679ac3..362183c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3346,3 +3346,68 @@ h1, h2, h3, .heading { /* Phase 2 Task 10: Unified Space Grotesk glassmorphic gradients applied */ + +/* ════════════════════════════════════════ + SCROLL NAVIGATION BUTTONS (#527) + ════════════════════════════════════════ */ + +.scroll-nav-btn { + position: fixed; + right: 20px; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--glass-bg); + backdrop-filter: blur(16px) saturate(160%); + -webkit-backdrop-filter: blur(16px) saturate(160%); + border: 1px solid var(--border); + color: var(--text); + font-size: 17px; + font-weight: 700; + font-family: 'Space Grotesk', sans-serif; + line-height: 1; + cursor: pointer; + z-index: 1500; + box-shadow: var(--shadow-lg); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease, + transform 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275), + background 0.2s ease, + box-shadow 0.2s ease; +} + +.scroll-nav-btn.visible { + opacity: 1; + pointer-events: auto; +} + +.scroll-nav-btn:hover { + background: var(--green); + color: #fff; + border-color: var(--green); + transform: scale(1.12); + box-shadow: 0 6px 20px rgba(13, 148, 136, 0.4); +} + +.scroll-nav-btn:focus-visible { + outline: 2px solid var(--green); + outline-offset: 3px; +} + +#scroll-to-top { bottom: 128px; } +#scroll-to-bottom { bottom: 80px; } + +@media (max-width: 768px) { + .scroll-nav-btn { + right: 14px; + width: 36px; + height: 36px; + font-size: 15px; + } + #scroll-to-top { bottom: 120px; } + #scroll-to-bottom { bottom: 76px; } +}