admin: render the header search on every page
Assisted-by: GLM-5.3 via pi
change vkvzlxoxlrpqnqlrrwutpyzuvqqmnuzk
commit fbe6daaccd7f0ab7cc6693151c791456c71f9758
author Alpha Chen <alpha@kejadlen.dev>
date
parent qvnnvptr
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 8618fac..51eed25 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -62,7 +62,11 @@ thousand, so no screen exists to scroll the whole set.
 
 Every collection screen is the same shape:
 
-1. A search input in the page header, focused and ready.
+1. A search input in the page header, focused and ready. The input
+   renders on every screen — a header that changes height between
+   pages makes the chrome jump, and a search should never require
+   going home first — but focuses only on collection screens, where
+   the search is the page.
 2. Results while there's a query.
 3. **Recently updated** when there isn't — the ten or so records touched last,
    sorted by update time.
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index bdfc002..7bad30a 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -38,7 +38,8 @@ module ProTacts
       end
 
       def view_template
-        render Layout.new(title: "Contacts", wide: true, search: @query, notice: @notice) do
+        render Layout.new(title: "Contacts", wide: true, query: @query,
+                          autofocus: @query.empty?, notice: @notice) do
           div(class: "dashboard") do
             section do
               div(class: "section-head") do
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 546e123..f5be6ee 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -6,18 +6,24 @@ module ProTacts
     # vendored Gloss stylesheets (see public/vendor/gloss and
     # docs/DESIGN.md), and a one-line header naming the app in type —
     # "no brand mark" is one of the rules that document inherits from
-    # Gloss. A screen that passes `search` renders the collection
-    # search in that header, beside the name (docs/DESIGN.md's
-    # search-first rule). `wide` opts a screen out of the reading
-    # width a single column wants (see admin.css) — the dashboard
-    # root is the one screen that asks. No JavaScript: nothing served
-    # here yet needs any.
+    # Gloss — with the collection search beside the name on every
+    # screen: a header that grew and shrank with the search made the
+    # chrome jump between pages, and search-first (docs/DESIGN.md)
+    # wants finding a contact possible from anywhere. `query` carries
+    # the dashboard's current search into the input's value;
+    # `autofocus` is a screen saying its entry point is the search —
+    # the dashboard with an empty query, the one place the design
+    # doc wants "focused and ready". `wide` opts a screen out of the
+    # reading width a single column wants (see admin.css) — the
+    # dashboard root is the one screen that asks. No JavaScript:
+    # nothing served here yet needs any.
     class Layout < Phlex::HTML
-      #: (title: String, ?wide: bool, ?search: String?, ?notice: String?) -> void
-      def initialize(title:, wide: false, search: nil, notice: nil)
+      #: (title: String, ?wide: bool, ?query: String?, ?autofocus: bool, ?notice: String?) -> void
+      def initialize(title:, wide: false, query: nil, autofocus: false, notice: nil)
         @title = title
         @wide = wide
-        @search = search
+        @query = query
+        @autofocus = autofocus
         @notice = notice
       end
 
@@ -38,15 +44,13 @@ module ProTacts
           body do
             header(class: "admin-header") do
               a(href: "/") { "pro-tacts" }
-              # The dashboard's query follows the search into the
-              # header: the input keeps its value across a search, and
-              # focuses only when the query is empty — a results page
-              # has somewhere to be besides the input.
-              if @search
-                form(action: "/", method: "get", class: "search-form") do
-                  input(type: "search", name: "q", value: @search,
-                        placeholder: "Search contacts", autofocus: @search.to_s.empty?)
-                end
+              # Every screen's chrome, not the dashboard's alone: the
+              # input keeps the query it carries — a results page has
+              # somewhere to be besides the input — and focuses only
+              # where the screen asked for it.
+              form(action: "/", method: "get", class: "search-form") do
+                input(type: "search", name: "q", value: @query,
+                      placeholder: "Search contacts", autofocus: @autofocus)
               end
             end
             main(class: "admin-main", **(@wide ? {data: {wide: true}} : {})) { yield }
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 198b69f..7876d61 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -132,9 +132,11 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   # The search lives in the page header (docs/DESIGN.md), rendered by
-  # the layout when a screen asks for it. A screen that forgets to ask
-  # loses the input with no route failing — search still works through
-  # the URL — so this pins the wiring, not just the behavior.
+  # the layout on every screen now — not just the dashboard's — so
+  # the header's height never changes between pages and finding a
+  # contact never requires going home first. This pins the wiring,
+  # not just the behavior: a screen that forgets `autofocus` loses
+  # the focused-and-ready entry point with no route failing.
   def test_the_search_lives_in_the_header
     with_contacts({"ada" => ADA}) do
       get "/"
@@ -150,6 +152,23 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The same chrome on a record's own page: the search rides the
+  # header there too, empty and unfocused — the record is that
+  # page's content, not a query.
+  def test_the_search_renders_on_detail_pages_too
+    with_contacts({"ada" => ADA}) do
+      get "/contacts/ada"
+
+      header = last_response.body.split("<main").first
+      assert_includes header, "search-form"
+      # No value attribute at all — the record's page carries no
+      # query, and Phlex omits the attribute for nil rather than
+      # rendering it empty.
+      assert_includes header, '<input type="search" name="q" placeholder="Search contacts">'
+      refute_includes header, "autofocus"
+    end
+  end
+
   # Searching narrows the contacts column; the ambient column stays
   # where it is, still answering its own question.
   def test_search_leaves_the_birthdays_column_standing