From ad664c32ea3323c208ac117e8a75b3b6d9d0d3c3 Mon Sep 17 00:00:00 2001 From: whilb Date: Mon, 1 Sep 2025 18:37:33 -0700 Subject: [PATCH] mobile responsiveness --- public/css/styles.css | 123 ++++++++++++++- public/index.html | 9 +- public/js/app.js | 21 +++ tests/test_mobile.py | 344 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 494 insertions(+), 3 deletions(-) create mode 100644 tests/test_mobile.py diff --git a/public/css/styles.css b/public/css/styles.css index 8fa1d87..77f57af 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -65,16 +65,135 @@ select:disabled{ opacity:.55; cursor:not-allowed; } html,body{margin:0;background:var(--bg);color:var(--text);font:16px/1.5 system-ui,Segoe UI,Roboto,Ubuntu,Cantarell,sans-serif} .wrap{max-width:var(--max);margin:0 auto;padding:16px} -.bar{position:sticky;top:0;background:linear-gradient(180deg,rgba(0,0,0,.06),rgba(0,0,0,0));backdrop-filter:blur(8px);border-bottom:1px solid var(--border);z-index:10} -.bar__inner{display:flex;align-items:center;gap:12px;justify-content:space-between} +.bar{position:sticky;top:0;background:linear-gradient(180deg,rgba(0,0,0,.06),rgba(0,0,0,0));backdrop-filter:blur(8px);border-bottom:1px solid var(--border);z-index:10;min-height:70px} +.bar__inner{display:flex;align-items:center;gap:12px;justify-content:space-between;min-height:70px} .brand{font-weight:700} .btn{background:transparent;border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:999px;cursor:pointer} +/* Mobile navigation toggle button */ +.nav-toggle { + display: none; + background: transparent; + border: 1px solid var(--border); + color: var(--text); + padding: 8px; + border-radius: 8px; + cursor: pointer; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; +} + +.nav-toggle svg { + width: 20px; + height: 20px; + fill: currentColor; +} + /* ---- Layout ---- */ .layout{display:grid;grid-template-columns:240px 1fr;gap:16px} @media (max-width: 820px){ .layout{grid-template-columns:1fr} + + /* Show mobile nav toggle */ + .nav-toggle { + display: flex !important; + } + + /* Hide desktop navigation by default on mobile */ + .sidenav { + display: none; + position: fixed; + top: 70px; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + background: var(--card); + border-radius: 0; + border: none; + box-shadow: var(--shadow); + overflow-y: auto; + } + + /* Show navigation when active */ + .sidenav.mobile-active { + display: block; + } + + /* Add mobile overlay */ + .sidenav::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: -1; + } + + /* Adjust main content spacing for mobile */ + .content { + margin-top: 16px; + } + + /* Improve mobile spacing */ + .wrap { + padding: 12px; + } + + /* Better mobile grid */ + .content { + grid-template-columns: 1fr; + gap: 12px; + } + + /* Mobile-friendly cards */ + .card { + padding: 12px; + } + + /* Mobile-friendly inputs */ + input, select, textarea { + padding: 12px; + font-size: 16px; /* Prevents zoom on iOS */ + } + + /* Mobile-friendly results */ + .result { + margin-top: 16px; + padding: 12px; + overflow-x: auto; + } + + /* Mobile-friendly tables */ + table { + font-size: 14px; + } + + /* Mobile-friendly calculator inputs */ + .calculator-container { + padding: 16px 0; + } + + /* Ensure proper spacing from navigation */ + .layout { + padding-top: 16px; + } + + /* Mobile-friendly footer */ + .footer-content { + flex-direction: column; + gap: 12px; + text-align: center; + } + + .source-link { + margin-left: 0; + } } /* ---- Vertical nav ---- */ diff --git a/public/index.html b/public/index.html index 32b5c42..b21196e 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,14 @@
calculator.127local.net
- +
+ + +
diff --git a/public/js/app.js b/public/js/app.js index 03ab77a..1ebb89a 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -12,8 +12,29 @@ const CALCS = [ const navEl = document.getElementById('nav'); const viewEl = document.getElementById('view'); const themeBtn= document.getElementById('themeToggle'); +const navToggleBtn = document.getElementById('navToggle'); initTheme(themeBtn); +// Mobile navigation toggle +navToggleBtn.addEventListener('click', () => { + navEl.classList.toggle('mobile-active'); +}); + +// Close mobile nav when clicking outside +document.addEventListener('click', (e) => { + if (!navEl.contains(e.target) && !navToggleBtn.contains(e.target)) { + navEl.classList.remove('mobile-active'); + } +}); + +// Close mobile nav when clicking on a nav link +navEl.addEventListener('click', (e) => { + const a = e.target.closest('a[data-calc]'); + if (a) { + navEl.classList.remove('mobile-active'); + } +}); + const moduleCache = new Map(); const viewCache = new Map(); diff --git a/tests/test_mobile.py b/tests/test_mobile.py new file mode 100644 index 0000000..b0ee845 --- /dev/null +++ b/tests/test_mobile.py @@ -0,0 +1,344 @@ +import pytest +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.action_chains import ActionChains + + +class TestMobileResponsiveness: + """Test mobile responsiveness and navigation functionality""" + + def test_mobile_nav_toggle_button_exists(self, calculator_page): + """Test that mobile navigation toggle button is present""" + # Set mobile viewport + calculator_page.set_window_size(375, 667) + + nav_toggle = calculator_page.find_element(By.ID, "navToggle") + assert nav_toggle.is_displayed() + + # Check it has the correct class + assert "nav-toggle" in nav_toggle.get_attribute("class") + + # Check it has the hamburger icon + svg = nav_toggle.find_element(By.TAG_NAME, "svg") + assert svg.is_displayed() + + def test_mobile_nav_toggle_functionality(self, calculator_page): + """Test that mobile navigation toggle works correctly""" + # Set mobile viewport + calculator_page.set_window_size(375, 667) + + nav_toggle = calculator_page.find_element(By.ID, "navToggle") + sidenav = calculator_page.find_element(By.ID, "nav") + + # Debug: check if element is actually clickable + print(f"Nav toggle displayed: {nav_toggle.is_displayed()}") + print(f"Nav toggle enabled: {nav_toggle.is_enabled()}") + print(f"Nav toggle location: {nav_toggle.location}") + print(f"Nav toggle size: {nav_toggle.size}") + + # Initially, sidenav should not have mobile-active class + assert "mobile-active" not in sidenav.get_attribute("class") + + # Wait for element to be clickable + WebDriverWait(calculator_page, 10).until( + EC.element_to_be_clickable((By.ID, "navToggle")) + ) + + # Click the toggle button using JavaScript if regular click fails + try: + nav_toggle.click() + except Exception as e: + print(f"Regular click failed: {e}") + calculator_page.execute_script("arguments[0].click();", nav_toggle) + + # Wait for the mobile-active class to be added + WebDriverWait(calculator_page, 5).until( + EC.presence_of_element_located((By.CSS_SELECTOR, ".sidenav.mobile-active")) + ) + + # Click again to close + try: + nav_toggle.click() + except Exception as e: + print(f"Regular click failed on close: {e}") + calculator_page.execute_script("arguments[0].click();", nav_toggle) + + # Wait for the mobile-active class to be removed + WebDriverWait(calculator_page, 5).until_not( + EC.presence_of_element_located((By.CSS_SELECTOR, ".sidenav.mobile-active")) + ) + + def test_mobile_nav_closes_on_outside_click(self, calculator_page): + """Test that mobile navigation closes when clicking outside""" + # Set mobile viewport + calculator_page.set_window_size(375, 667) + + nav_toggle = calculator_page.find_element(By.ID, "navToggle") + sidenav = calculator_page.find_element(By.ID, "nav") + + # Open mobile nav using JavaScript + calculator_page.execute_script("arguments[0].click();", nav_toggle) + + # Wait for nav to open + WebDriverWait(calculator_page, 5).until( + EC.presence_of_element_located((By.CSS_SELECTOR, ".sidenav.mobile-active")) + ) + + # Click on the body element (outside nav) using JavaScript + calculator_page.execute_script("document.body.click();") + + # Wait for nav to close + WebDriverWait(calculator_page, 5).until_not( + EC.presence_of_element_located((By.CSS_SELECTOR, ".sidenav.mobile-active")) + ) + + def test_mobile_nav_closes_on_nav_link_click(self, calculator_page): + """Test that mobile navigation closes when clicking a navigation link""" + # Set mobile viewport + calculator_page.set_window_size(375, 667) + + nav_toggle = calculator_page.find_element(By.ID, "navToggle") + sidenav = calculator_page.find_element(By.ID, "nav") + + # Open mobile nav using JavaScript + calculator_page.execute_script("arguments[0].click();", nav_toggle) + + # Wait for nav to open + WebDriverWait(calculator_page, 5).until( + EC.presence_of_element_located((By.CSS_SELECTOR, ".sidenav.mobile-active")) + ) + + # Click on a navigation link + nav_link = sidenav.find_element(By.CSS_SELECTOR, "a[data-calc='raid']") + nav_link.click() + + # Wait for nav to close + WebDriverWait(calculator_page, 5).until_not( + EC.presence_of_element_located((By.CSS_SELECTOR, ".sidenav.mobile-active")) + ) + + def test_mobile_nav_sticky_positioning(self, calculator_page): + """Test that navigation bar stays at top and content doesn't scroll under it""" + # Get the navigation bar + nav_bar = calculator_page.find_element(By.CLASS_NAME, "bar") + + # Check that it has sticky positioning + position = nav_bar.value_of_css_property("position") + assert position == "sticky" + + # Check that it has a high z-index + z_index = nav_bar.value_of_css_property("z-index") + assert int(z_index) >= 10 + + # Check that it has a minimum height + min_height = nav_bar.value_of_css_property("min-height") + assert min_height == "70px" + + def test_mobile_responsive_layout(self, calculator_page): + """Test that layout changes appropriately on mobile""" + # Set mobile viewport + calculator_page.set_window_size(375, 667) + + # Get the layout container + layout = calculator_page.find_element(By.CLASS_NAME, "layout") + + # Check that it has proper grid layout + display = layout.value_of_css_property("display") + assert display == "grid" + + # Check that it has responsive grid template + grid_template = layout.value_of_css_property("grid-template-columns") + # Should be responsive - on mobile it will be 1fr, on desktop 240px 1fr + # The actual value might be computed differently, so just check it's a valid grid value + assert "px" in grid_template or "fr" in grid_template + + def test_mobile_friendly_inputs(self, calculator_page): + """Test that inputs are mobile-friendly""" + # Set mobile viewport + calculator_page.set_window_size(375, 667) + + # Navigate to a calculator with inputs + calculator_page.get("http://localhost:8008/subnet") + + # Wait for calculator to load + WebDriverWait(calculator_page, 10).until( + EC.presence_of_element_located((By.CSS_SELECTOR, "input[name='ipAddress']")) + ) + + # Check input styling + ip_input = calculator_page.find_element(By.CSS_SELECTOR, "input[name='ipAddress']") + + # Check padding (should be 12px on mobile) + padding = ip_input.value_of_css_property("padding") + assert "12px" in padding + + # Check font size (should be 16px to prevent zoom on iOS) + font_size = ip_input.value_of_css_property("font-size") + assert "16px" in font_size + + def test_mobile_table_overflow(self, calculator_page): + """Test that tables have horizontal scroll on mobile""" + # Set mobile viewport + calculator_page.set_window_size(375, 667) + + # Navigate to subnet calculator which has tables + calculator_page.get("http://localhost:8008/subnet") + + # Wait for calculator to load + WebDriverWait(calculator_page, 10).until( + EC.presence_of_element_located((By.CSS_SELECTOR, "input[name='ipAddress']")) + ) + + # Enter an IP address to generate the table + ip_input = calculator_page.find_element(By.CSS_SELECTOR, "input[name='ipAddress']") + ip_input.clear() + ip_input.send_keys("192.168.1.1") + + # Wait for table to appear + WebDriverWait(calculator_page, 10).until( + EC.presence_of_element_located((By.TAG_NAME, "table")) + ) + + # Check that the result container has overflow handling + result_container = calculator_page.find_element(By.CLASS_NAME, "result") + overflow_x = result_container.value_of_css_property("overflow-x") + # Should have auto or scroll overflow on mobile + assert overflow_x in ["auto", "scroll"] + + def test_mobile_footer_layout(self, calculator_page): + """Test that footer is mobile-friendly""" + footer_content = calculator_page.find_element(By.CLASS_NAME, "footer-content") + + # Check that footer content has proper flexbox layout + display = footer_content.value_of_css_property("display") + assert display == "flex" + + # Check that source link is properly positioned + source_link = calculator_page.find_element(By.CLASS_NAME, "source-link") + assert source_link.is_displayed() + assert "https://code.disobey.net/whilb/calculator.127local.net" in source_link.get_attribute("href") + + def test_mobile_nav_theme_toggle_buttons(self, calculator_page): + """Test that both nav toggle and theme toggle buttons are accessible""" + # Set mobile viewport + calculator_page.set_window_size(375, 667) + + nav_toggle = calculator_page.find_element(By.ID, "navToggle") + theme_toggle = calculator_page.find_element(By.ID, "themeToggle") + + # Both buttons should be visible + assert nav_toggle.is_displayed() + assert theme_toggle.is_displayed() + + # Both should be clickable + assert nav_toggle.is_enabled() + assert theme_toggle.is_enabled() + + # Check button styling + for button in [nav_toggle, theme_toggle]: + cursor = button.value_of_css_property("cursor") + assert cursor == "pointer" + + def test_mobile_nav_accessibility(self, calculator_page): + """Test mobile navigation accessibility features""" + nav_toggle = calculator_page.find_element(By.ID, "navToggle") + sidenav = calculator_page.find_element(By.ID, "nav") + + # Check aria-label on toggle button + aria_label = nav_toggle.get_attribute("aria-label") + assert aria_label == "Toggle navigation" + + # Check that sidenav has proper role (should be navigation) + role = sidenav.get_attribute("role") + # If no explicit role, check that it's semantically correct + if not role: + # Should contain navigation links + nav_links = sidenav.find_elements(By.CSS_SELECTOR, "a[data-calc]") + assert len(nav_links) > 0 + + def test_mobile_nav_calculator_integration(self, calculator_page): + """Test that mobile navigation works properly with calculator functionality""" + # Set mobile viewport + calculator_page.set_window_size(375, 667) + + # Navigate to subnet calculator + calculator_page.get("http://localhost:8008/subnet") + + # Wait for calculator to load + WebDriverWait(calculator_page, 10).until( + EC.presence_of_element_located((By.CSS_SELECTOR, "input[name='ipAddress']")) + ) + + # Open mobile navigation using JavaScript + nav_toggle = calculator_page.find_element(By.ID, "navToggle") + calculator_page.execute_script("arguments[0].click();", nav_toggle) + + # Wait for nav to open + WebDriverWait(calculator_page, 5).until( + EC.presence_of_element_located((By.CSS_SELECTOR, ".sidenav.mobile-active")) + ) + + # Navigate to a different calculator via mobile nav + nav_link = calculator_page.find_element(By.CSS_SELECTOR, "a[data-calc='currency']") + nav_link.click() + + # Wait for nav to close and currency calculator to load + WebDriverWait(calculator_page, 10).until( + EC.presence_of_element_located((By.CSS_SELECTOR, "input[name='amount']")) + ) + + # Verify we're on the currency calculator + currency_input = calculator_page.find_element(By.CSS_SELECTOR, "input[name='amount']") + assert currency_input.is_displayed() + + # Verify nav is closed + sidenav = calculator_page.find_element(By.ID, "nav") + assert "mobile-active" not in sidenav.get_attribute("class") + + def test_mobile_nav_scroll_behavior(self, calculator_page): + """Test that mobile navigation doesn't interfere with page scrolling""" + # Set mobile viewport + calculator_page.set_window_size(375, 667) + + # Navigate to a calculator with long content + calculator_page.get("http://localhost:8008/subnet") + + # Wait for calculator to load + WebDriverWait(calculator_page, 10).until( + EC.presence_of_element_located((By.CSS_SELECTOR, "input[name='ipAddress']")) + ) + + # Enter an IP address to generate content + ip_input = calculator_page.find_element(By.CSS_SELECTOR, "input[name='ipAddress']") + ip_input.clear() + ip_input.send_keys("10.0.0.1") + + # Wait for results to appear + WebDriverWait(calculator_page, 10).until( + EC.presence_of_element_located((By.CLASS_NAME, "result")) + ) + + # Open mobile navigation using JavaScript + nav_toggle = calculator_page.find_element(By.ID, "navToggle") + calculator_page.execute_script("arguments[0].click();", nav_toggle) + + # Wait for nav to open + WebDriverWait(calculator_page, 5).until( + EC.presence_of_element_located((By.CSS_SELECTOR, ".sidenav.mobile-active")) + ) + + # Try to scroll the page + calculator_page.execute_script("window.scrollTo(0, 100)") + + # Verify navigation is still open and functional + sidenav = calculator_page.find_element(By.ID, "nav") + assert "mobile-active" in sidenav.get_attribute("class") + + # Close navigation using JavaScript + calculator_page.execute_script("arguments[0].click();", nav_toggle) + + # Verify navigation closes + WebDriverWait(calculator_page, 5).until_not( + EC.presence_of_element_located((By.CSS_SELECTOR, ".sidenav.mobile-active")) + )