search: list recent contacts when the dialog opens blank
The dialog opened on an empty box with nothing there to pick;
it fetches recent contacts now each time it opens, quick.
The page and dialog both show the list, so `recent:` had no say;
clearing the box brings back the list instead of a blank display.

Assisted-by: Claude Opus 5.5 via Claude Code
change xrwkuropkrupyzprktmkmzxxtvtvvvkt
commit 59fb69cfa251d1ad69c7dd017b3ce3d077575fab
author Alpha Chen <alpha@kejadlen.dev>
date
parent rlulnuyt
diff --git a/docs/plans/2026-09-30-live-search.md b/docs/plans/2026-09-30-live-search.md
index f01d0cf..887b8e9 100644
--- a/docs/plans/2026-09-30-live-search.md
+++ b/docs/plans/2026-09-30-live-search.md
@@ -54,8 +54,10 @@ submitted query without script, which the dialog cannot. The header
 renders both the dialog's button and the page's link, and CSS shows
 one per width. Before a word is typed the page lists the recently
 updated contacts, the dashboard's own rows, rather than standing
-empty; the dialog opens over a screen with its own content and
-stays empty until typed into.
+empty. The dialog first stayed empty until typed into, since it
+opens over a screen with its own content; it lists them too now,
+fetched as it opens, since most screens it opens over are not the
+dashboard and a blank box offered nothing to pick.
 
 ## Why the results page went
 
diff --git a/lib/pro_tacts/admin/search_dialog.rb b/lib/pro_tacts/admin/search_dialog.rb
index f9dfad6..8144526 100644
--- a/lib/pro_tacts/admin/search_dialog.rb
+++ b/lib/pro_tacts/admin/search_dialog.rb
@@ -14,7 +14,9 @@ module ProTacts
     # a click outside — and `autofocus` lands in the input each time
     # it opens. `/` opens it from anywhere a key is not already typing
     # into something (docs/DESIGN.md, "The core idea"). What was typed
-    # stays when it closes, so reopening lands on the same answer.
+    # stays when it closes, so reopening lands on the same answer;
+    # opened blank, it lists the recently updated contacts, the search
+    # page's own list before a word is typed.
     class SearchDialog < Phlex::HTML
       ID = "search" #: String
 
diff --git a/lib/pro_tacts/admin/search_field.rb b/lib/pro_tacts/admin/search_field.rb
index d8e93e4..531b9a7 100644
--- a/lib/pro_tacts/admin/search_field.rb
+++ b/lib/pro_tacts/admin/search_field.rb
@@ -17,8 +17,8 @@ module ProTacts
     # cancelled by the next one, so an answer to an old query never
     # lands over a newer; and the keys, ↑/↓ moving a highlight through
     # the result links and Enter following it, the first by default —
-    # while there is a query, the recently updated list the page shows
-    # before one being no answer for Enter to pick.
+    # while there is a query, the recently updated list shown before
+    # one being no answer for Enter to pick.
     # Enter with no result to follow submits the form the field sits
     # in, where there is one — the page's, which answers without
     # script too.
@@ -27,26 +27,32 @@ module ProTacts
     # page's own ?q= and its server-rendered answer. `history` keeps
     # the page's address on the query as it changes, so going back to
     # it from a result lands on the same answer; the dialog has no
-    # address of its own to keep. `recent` has a blank query fetch
-    # the recently updated list too, as the page shows before a word
-    # is typed; the dialog opens over a screen showing its own, and
-    # clears to nothing.
+    # address of its own to keep.
+    #
+    # A blank query fetches the recently updated list, so the field
+    # never stands empty: the page renders it in, and the dialog
+    # fetches it each time it opens blank, the book having maybe
+    # changed since the screen under it loaded.
     class SearchField < Phlex::HTML
       # @rbs @query: String
       # @rbs @results: Phlex::HTML?
       # @rbs @history: bool
-      # @rbs @recent: bool
 
-      #: (?query: String, ?results: Phlex::HTML?, ?history: bool, ?recent: bool) -> void
-      def initialize(query: "", results: nil, history: false, recent: false)
+      # The popover the field sits in, where it sits in one, fetching
+      # as it opens.
+      OPEN = "mark(); $el.closest('[popover]')?.addEventListener('toggle', " \
+             "event => { if (event.newState === 'open') opened() })" #: String
+      private_constant :OPEN
+
+      #: (?query: String, ?results: Phlex::HTML?, ?history: bool) -> void
+      def initialize(query: "", results: nil, history: false)
         @query = query
         @results = results
         @history = history
-        @recent = recent
       end
 
       def view_template
-        div(class: "search-field", x_data: state, x_init: "mark()") do
+        div(class: "search-field", x_data: state, x_init: OPEN) do
           div(class: "filter") do
             input(type: "search", name: "q", value: @query, placeholder: "Search contacts",
                   aria_label: "Search contacts", autofocus: true, x_model: "q", x_ref: "input",
@@ -71,10 +77,9 @@ module ProTacts
       # fail.
       #: () -> String
       def state
-        "{ q: #{JSON.generate(@query)}, history: #{@history}, recent: #{@recent}, active: 0, request: null, " \
+        "{ q: #{JSON.generate(@query)}, history: #{@history}, active: 0, request: null, " \
           "async search() { this.request?.abort(); this.active = 0; const q = this.q.trim(); " \
           "if (this.history) history.replaceState(null, '', q === '' ? '/search' : '/search?q=' + encodeURIComponent(q)); " \
-          "if (q === '' && !this.recent) { this.request = null; this.$refs.results.innerHTML = ''; return } " \
           "const request = this.request = new AbortController(); let html; " \
           "try { const response = await fetch('/search/results?q=' + encodeURIComponent(q), { signal: request.signal }); " \
           "if (!response.ok) throw new Error(`The search failed: the server answered ${response.status}.`); " \
@@ -88,6 +93,7 @@ module ProTacts
           "mark() { const on = this.q.trim() !== ''; " \
           "this.links.forEach((link, i) => link.toggleAttribute('data-active', on && i === this.active)); " \
           "this.links[this.active]?.scrollIntoView({ block: 'nearest' }) }, " \
+          "opened() { if (this.q.trim() === '') this.search() }, " \
           "follow() { const link = this.q.trim() === '' ? null : this.links[this.active]; " \
           "if (link) link.click(); else this.$el.closest('form')?.requestSubmit() } }"
       end
diff --git a/lib/pro_tacts/admin/search_page.rb b/lib/pro_tacts/admin/search_page.rb
index 1cab0bd..78769a6 100644
--- a/lib/pro_tacts/admin/search_page.rb
+++ b/lib/pro_tacts/admin/search_page.rb
@@ -26,7 +26,7 @@ module ProTacts
       def view_template
         render Layout.new(title: "Search", login: @login, searching: true) do
           form(action: "/search", method: "get", class: "search-page") do
-            render SearchField.new(query: @query, results: @results, history: true, recent: true)
+            render SearchField.new(query: @query, results: @results, history: true)
           end
         end
       end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 0f417e8..77e0007 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -357,6 +357,19 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The dialog fetches the recently updated list as it opens with
+  # nothing typed, the list GET /search/results answers a blank query
+  # (Admin::SearchField), so the box is never empty.
+  def test_the_search_dialog_fetches_as_it_opens
+    with_contacts({"ada" => ADA}) do
+      get "/"
+      dialog = last_response.body[/<dialog id="search".*?<\/dialog>/m]
+
+      assert_includes dialog, "addEventListener('toggle'"
+      assert_includes dialog, "opened() { if (this.q.trim() === '') this.search() }"
+    end
+  end
+
   # The dashboard answers no query: the results page it used to be is
   # gone, and a stale ?q= link lands on recency like any other visit.
   def test_the_dashboard_ignores_a_query