patx/projectpay

improve search ux

Commit 384fe6d · patx · 2026-07-06T23:41:05-04:00

Changeset
384fe6da6fe3211da65656e1aaccb4cff6870240
Parents
a6f2a067cceb5d8538581590e75f2d879412b4fe

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/templates/base.html b/templates/base.html
index 7258cbd..473d2a1 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -468,6 +468,7 @@
       .amount { text-align: right; }
       @media (max-width: 760px) {
         .topbar-inner, .header-row, .nav, .searchbar { align-items: stretch; flex-direction: column; }
+        input, textarea { font-size: 16px; }
         .nav .inline, .nav .inline button, .nav .button { width: 100%; }
         .grid-2, .stats, .line-item, .project-card-main { grid-template-columns: 1fr; }
         .line-item-text { grid-column: auto; }
@@ -478,6 +479,18 @@
         .project-card-amount { text-align: left; }
         .project-card-footer { justify-content: flex-start; }
         .sticky-pay button { width: 100%; }
+        .has-sticky-index.index-search-active {
+          padding-top: calc(92px + env(safe-area-inset-top, 0px));
+        }
+        .has-sticky-index.index-search-active .sticky-index-search {
+          top: 0;
+          bottom: auto;
+          padding-top: max(12px, env(safe-area-inset-top, 0px));
+          box-shadow: 0 8px 18px var(--shadow-color);
+        }
+        .has-sticky-index.index-search-active .floating-new-project {
+          display: none;
+        }
       }
         @media print {
           .sticky-pay {
diff --git a/templates/index.html b/templates/index.html
index 95d6c3c..b01bf09 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -24,13 +24,31 @@
 
   <script>
     const list = document.getElementById("project-list");
+    const page = document.querySelector(".has-sticky-index");
     const sentinel = document.getElementById("scroll-sentinel");
     const searchInput = document.getElementById("project-search");
     const clearSearch = document.getElementById("clear-search");
+    const mobileSearchQuery = window.matchMedia("(max-width: 760px)");
     let nextPage = {{ page + 1 if has_more else 0 }};
     let loading = false;
     let searchTimer = null;
     let activeRequest = 0;
+    let blurTimer = null;
+
+    function keepSearchResultsVisible() {
+      if (!mobileSearchQuery.matches) return;
+      requestAnimationFrame(() => {
+        window.scrollTo({ top: 0, left: 0, behavior: "auto" });
+      });
+      setTimeout(() => window.scrollTo(0, 0), 80);
+    }
+
+    function setSearchActive(active) {
+      clearTimeout(blurTimer);
+      if (!page) return;
+      page.classList.toggle("index-search-active", active && mobileSearchQuery.matches);
+      if (active) keepSearchResultsVisible();
+    }
 
     function applyRows(html, append) {
       const wrapper = document.createElement("div");
@@ -44,6 +62,7 @@
       });
       nextPage = marker && marker.dataset.hasMore === "1" ? Number(marker.dataset.nextPage) : 0;
       sentinel.textContent = nextPage ? "Loading more projects as you scroll" : "";
+      if (!append) keepSearchResultsVisible();
     }
 
     async function fetchProjects(page, append) {
@@ -80,12 +99,30 @@
     }
 
     searchInput.addEventListener("input", queueSearch);
+    searchInput.addEventListener("focus", () => setSearchActive(true));
+    searchInput.addEventListener("blur", () => {
+      blurTimer = setTimeout(() => setSearchActive(false), 120);
+    });
     clearSearch.addEventListener("click", () => {
       searchInput.value = "";
       searchInput.focus();
       queueSearch();
     });
     updateClearButton();
+    function syncSearchActiveState() {
+      setSearchActive(document.activeElement === searchInput);
+    }
+
+    if (mobileSearchQuery.addEventListener) {
+      mobileSearchQuery.addEventListener("change", syncSearchActiveState);
+    } else if (mobileSearchQuery.addListener) {
+      mobileSearchQuery.addListener(syncSearchActiveState);
+    }
+    if (window.visualViewport) {
+      window.visualViewport.addEventListener("resize", () => {
+        if (document.activeElement === searchInput) keepSearchResultsVisible();
+      });
+    }
 
     if ("IntersectionObserver" in window) {
       const observer = new IntersectionObserver((entries) => {