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
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