search: live results in a dialog, or a page on a phone
Search used to submit and reload the dashboard to find a name;
now it narrows as you type, and Search on the server decides the same.
Wide screens get a dialog, a phone gets a page of its own,
and the dashboard's /?q= results page is gone.
Assisted-by: Claude Opus 5.5 via Claude Code
Issue: pnrqpyvk
diff --git a/Steepfile b/Steepfile
index 65dc443..9c7c3a8 100644
--- a/Steepfile
+++ b/Steepfile
@@ -23,7 +23,7 @@ target :lib do
ignore(*%w[
avatar contact_card contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
group_dialog group_filter group_label groups_edit groups_index groups_members groups_show icons import_groups import_original import_saved import_sidebar
- import_target import_upload layout list_item phlex record_card upcoming_birthdays
+ import_target import_upload layout list_item phlex recent_row record_card search_dialog search_field search_page search_results upcoming_birthdays
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 0fb51a7..c63454d 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -85,14 +85,15 @@ left.
Every collection screen is the same shape:
-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.
+1. A search one key away. Its button sits in the page header on every
+ screen — a header that changes height between pages makes the
+ chrome jump, and a search should never require going home first —
+ and it, or `/` from anywhere, opens a dialog with the input
+ focused and results narrowing as it is typed. On a phone it goes
+ to a search page instead, the same input with the screen to itself.
+2. **Recently updated** on the screen itself — the ten or so records
+ touched last, sorted by update time. The screen answers no query;
+ the dialog does, over whatever screen it was opened on.
Recency is the answer to "what was I doing?", which is the actual question
someone opening this app has. Match generously: a contact should be findable
@@ -102,11 +103,9 @@ The root screen is a dashboard of two columns. The primary column is the
shape above. The ambient column beside it is a standing answer to "what's
coming?": the next ten or so birthdays in arrival order, each row opening
the contact's card. It is a list that browses, like the whole-set listing,
-because its question is not "where is X?", and it stands whether or not
-there is a query. The
-columns are equal and fluid, collapsing to one — birthdays under contacts —
-when the viewport cannot hold two side by side. Every other screen keeps a
-single reading column.
+because its question is not "where is X?". The columns are equal and fluid,
+collapsing to one — birthdays under contacts — when the viewport cannot hold
+two side by side. Every other screen keeps a single reading column.
Under every screen sits the footer, the header's counterpart, holding what no
search can turn up because none of it is a record: the version answering and
diff --git a/docs/plans/2026-09-30-live-search.md b/docs/plans/2026-09-30-live-search.md
new file mode 100644
index 0000000..f01d0cf
--- /dev/null
+++ b/docs/plans/2026-09-30-live-search.md
@@ -0,0 +1,81 @@
+# Live search
+
+The header's search was a GET form: type, submit, and the dashboard
+reloaded as a results page for `/?q=`. It now narrows as it is typed:
+in a dialog the header's button opens over any screen, and on a phone
+on a search page of its own. The dashboard's results page is gone.
+Task pnrqpyvk.
+
+## What was weighed
+
+The task named three ways to make it live:
+
+- **Filter in the page with Alpine**, over every contact embedded in
+ it. No server work, but it ships the whole book to the browser and
+ writes the match a second time in JavaScript — and since
+ `Admin::Search` (task ynwtzotq) the match is words across fields,
+ phones by their digits, and a ranking, not one substring check.
+- **Pagefind**, which indexes at build time. Contacts change at
+ runtime behind the identity gate, so the index would be rebuilt on
+ every card write and served behind the same check, and its ranking
+ would replace ours rather than keep it.
+- **Keep matching on the server** and have the input fetch results.
+ One implementation of what a match is, at a request per pause in
+ typing against local SQLite.
+
+The server won, for the reason the first two lose: `Admin::Search`
+stays the one place a match is decided. SQLite FTS5 and typo
+tolerance were set aside for a family-sized book in the same pass.
+
+## Why a dialog
+
+The first cut kept the input in the header and swapped the
+dashboard's contacts column for the fetched results. That made the
+search live on one screen only; everywhere else, typing still had to
+be submitted and land on the dashboard. A dialog works the same over
+any screen, and makes the header's control a button: one height on
+every screen, and none of the fold-and-open machinery the header
+input needed on a phone.
+
+`/` opens it from anywhere a key is not already typing into a field.
+That is what replaced "focused and ready" in `docs/DESIGN.md`: a
+button cannot hold focus for a page, and opening the dialog on load
+would cover the dashboard someone came to see.
+
+## Why a phone gets a page
+
+A phone's screen is already about the dialog's width, so there the
+dialog was only a smaller screen laid over the screen. A phone's
+header links to `GET /search` instead, a page of its own holding the
+same field. It keeps its address on the
+query as it is typed, so going back to it from a result lands on
+the results, and in a GET form back to itself it answers a
+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.
+
+## Why the results page went
+
+With the dialog, `/?q=` would restate what the dialog already shows,
+and a screen that only restates another is deleted (`docs/DESIGN.md`).
+The search page is not that page back: it is the phone's way to the
+search, not the dashboard answering a query. The results are
+uncapped and scroll, since there is no "see all" to send a long list
+to.
+
+## Shape
+
+- `GET /search/results?q=` answers `Admin::SearchResults`, a fragment
+ rather than a page: the matched contacts, then the matched groups,
+ or the recently updated contacts for a blank query.
+- `Admin::SearchField` is the input and that list. It waits for
+ typing to pause, cancels a fetch the next one supersedes, and moves
+ a highlight through the result links with the arrow keys, which
+ Enter follows.
+- `Admin::SearchDialog` holds the field over any screen, wider than
+ a phone; `GET /search` (`Admin::SearchPage`) holds it on a phone,
+ with the results of its own `?q=` rendered in.
+- The dashboard is recency and birthdays, and nothing else.
diff --git a/lib/pro_tacts/admin/contact_dialog.rb b/lib/pro_tacts/admin/contact_dialog.rb
index 350640b..c85cb1e 100644
--- a/lib/pro_tacts/admin/contact_dialog.rb
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -39,8 +39,8 @@ module ProTacts
# browser submit gets past — and the server's backstop for a
# hand-crafted empty POST answers with a toast on the dashboard
# instead (see Web's POST handler).
- # `autofocus` fires when the popover is shown (it is scoped to the
- # popover, so the header search's own page-load autofocus stands).
+ # `autofocus` fires when the popover is shown, being scoped to the
+ # popover.
#
# No company variant of this dialog: organizations are created by
# the clients (Contacts.app's own Company flow PUTs a finished
@@ -49,13 +49,6 @@ module ProTacts
# (docs/plans/2026-09-24-company-cards.md, "Creating"). The editor
# knows a company card when it edits one (Contact#company?).
class ContactDialog < Phlex::HTML
- # @rbs @query: String
-
- #: (query: String) -> void
- def initialize(query:)
- @query = query
- end
-
def view_template
dialog(id: "new-contact", popover: "auto") do
header { "New contact" }
@@ -73,10 +66,6 @@ module ProTacts
plain "Last"
input(**NamePair.last(nil, nil))
end
- # The search the dialog opened over — opening a popover
- # loads no page, so this survives only the failed-create
- # re-render, keeping the results the user was looking at.
- input(type: "hidden", name: "q", value: @query)
end
footer do
button(type: "button", popovertarget: "new-contact",
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 65e8c79..236c282 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -34,7 +34,7 @@ module ProTacts
# nickname or note row over a card that carries neither, which has
# nothing to lose. The etag rides along hidden, the snapshot
# guard's half. `autofocus` on the first field: this screen's
- # entry point is the name, not the header search.
+ # entry point is the name.
class ContactsEdit < Phlex::HTML
# The property types the add dialog offers, in the order it
# lists them — exactly the rows the save can insert
diff --git a/lib/pro_tacts/admin/dashboard.rb b/lib/pro_tacts/admin/dashboard.rb
index c734693..7b42fc8 100644
--- a/lib/pro_tacts/admin/dashboard.rb
+++ b/lib/pro_tacts/admin/dashboard.rb
@@ -1,108 +1,64 @@
require "pro_tacts/admin/phlex"
-require "pro_tacts/admin/avatar"
require "pro_tacts/admin/contact_dialog"
-require "pro_tacts/admin/format"
-require "pro_tacts/admin/group_label"
-require "pro_tacts/admin/list_item"
require "pro_tacts/admin/layout"
-require "pro_tacts/admin/search"
+require "pro_tacts/admin/recent_row"
require "pro_tacts/admin/upcoming_birthdays"
module ProTacts
module Admin
- # GET / — the dashboard (docs/DESIGN.md): the search in the page
- # header, recency in the primary column, the birthdays the year is
- # about to bring in the ambient one beside it. A query narrows the
- # contacts column to matches across every contact, in the order
- # Search.contacts ranks them; an empty query
- # shows the ten most recently updated. The create dialog rides
+ # GET / — the dashboard (docs/DESIGN.md): recency in the primary
+ # column, the birthdays the year is about to bring in the ambient
+ # one beside it. It answers no query; the search is the header's
+ # dialog, on this screen as on every other
+ # (docs/plans/2026-09-30-live-search.md). The create dialog rides
# along hidden on every render — a popover opens where it sits, so
# the add button needs nothing from the server but the page it is
# already on.
class Dashboard < Phlex::HTML
+ # How many rows recently updated lists, here and on the search
+ # page before a query (Web#search_results).
RECENT_LIMIT = 10
- private_constant :RECENT_LIMIT
- # @rbs @query: String
# @rbs @login: String
# @rbs @upcoming: Array[Store::UpcomingBirthday]
- # @rbs @labels: Hash[String, Array[String]]
- # @rbs @groups: Array[Group]
# @rbs @rows: Array[Store::RecentContact]
# @rbs @notice: String?
- # The groups arrive whole because a search answers from both
- # sides of the relationship: a group is findable by its own name,
- # and a contact by the names of the groups it is in.
- #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, login: String, groups: Array[Group], ?notice: String?) -> void
- def initialize(recent:, upcoming:, query:, login:, groups:, notice: nil)
- @query = query.to_s.strip
+ #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], login: String, ?notice: String?) -> void
+ def initialize(recent:, upcoming:, login:, notice: nil)
@login = login
@upcoming = upcoming
@notice = notice
- @labels = {}
- groups.each { |group| group.members.each { (@labels[it] ||= []) << group.label } }
- @groups = @query.empty? ? [] : Search.groups(groups, query: @query)
- @rows = @query.empty? ? recent.first(RECENT_LIMIT) : Search.contacts(recent, query: @query, labels: @labels)
+ @rows = recent.first(RECENT_LIMIT)
end
def view_template
- render Layout.new(title: "Home", login: @login, wide: true, query: @query,
- autofocus: @query.empty?, notice: @notice) do
+ render Layout.new(title: "Home", login: @login, wide: true, notice: @notice) do
div(class: "dashboard") do
section do
div(class: "section-head") do
- h2(class: "type-label") { @query.empty? ? "recently updated" : "contacts" }
+ h2(class: "type-label") { "recently updated" }
# A button, not a link: it changes the page's state
# rather than navigating, and the Popover API is what
# it invokes (see ContactDialog).
button(data_size: "sm", popovertarget: "new-contact") { "add contact" }
end
if @rows.empty?
- p(class: "type-body-sm") { @query.empty? ? "No contacts yet." : "No contacts match." }
+ p(class: "type-body-sm") { "No contacts yet." }
else
ul(class: "card") do
- @rows.each do
- render_row(it)
- end
+ @rows.each { render RecentRow.new(row: it) }
end
end
- group_results if @groups.any?
end
render UpcomingBirthdays.new(upcoming: @upcoming)
end
# In the page but out of the columns: a popover opens from
# wherever it sits, and hidden is its resting state.
- render ContactDialog.new(query: @query)
+ render ContactDialog.new
end
end
-
- private
-
- # The groups whose names match, under the contacts: a group is a
- # record the search reaches as surely as a contact is
- # (docs/DESIGN.md, "When adding something new").
- #: () -> void
- def group_results
- div(class: "section-head group-results") do
- h2(class: "type-label") { "groups" }
- end
- ul(class: "card") do
- @groups.each do |group|
- render ListItem.new(href: "/groups/#{group.id}") { render GroupLabel.new(group:) }
- end
- end
- end
-
- #: (Store::RecentContact row) -> void
- def render_row(row)
- render ListItem.new(
- href: "/contacts/#{row.contact.id}",
- avatar: Avatar.new(contact: row.contact, size: "lg"),
- trailing: Format.time_ago(row.updated_at),
- ) { Format.name_label(row.contact) }
- end
end
end
end
diff --git a/lib/pro_tacts/admin/group_filter.rb b/lib/pro_tacts/admin/group_filter.rb
index 8847ac6..af6e0ff 100644
--- a/lib/pro_tacts/admin/group_filter.rb
+++ b/lib/pro_tacts/admin/group_filter.rb
@@ -138,7 +138,7 @@ module ProTacts
# positioned at the field's end (admin.css). The click empties
# the state rather than the box alone — `filter` is what the
# rows, the cap, and the offer all read — and the reveal is
- # CSS on the box, the header search's own (see Layout).
+ # CSS on the box, the search dialog's own (see admin.css).
div(class: "filter") do
input(type: "search", placeholder: @placeholder,
aria_label: @placeholder, autofocus: @autofocus,
diff --git a/lib/pro_tacts/admin/icons.rb b/lib/pro_tacts/admin/icons.rb
index 4bf5fd2..7359302 100644
--- a/lib/pro_tacts/admin/icons.rb
+++ b/lib/pro_tacts/admin/icons.rb
@@ -21,8 +21,9 @@ module ProTacts
# remove control's two strokes; calendar, the birthday row's
# picker; the import row's verdict, a circle either way and
# a check in it when the row is in the book;
- # chevron-down, the account menu's opening direction; and
- # search, what the header's search folds into on a phone. A tag
+ # chevron-down, the account menu's opening direction;
+ # search, the glyph on the header's search button; and house,
+ # the phone header's way home from the search page. A tag
# per shape rather than a bare list of path data, because
# calendar's frame is a rect and redrawing it as a path would
# be this file copying something other than what Lucide ships.
@@ -51,6 +52,10 @@ module ProTacts
[:path, {d: "m21 21-4.34-4.34"}],
[:circle, {cx: 11, cy: 11, r: 8}],
],
+ house: [
+ [:path, {d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"}],
+ [:path, {d: "M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"}],
+ ],
} #: Hash[Symbol, Array[[Symbol, Hash[Symbol, untyped]]]]
#: (Symbol name, ?size: Integer) -> void
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 39768ed..dca3d49 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -2,6 +2,7 @@ require "pro_tacts/admin/phlex"
require "pro_tacts"
require "pro_tacts/admin/icons"
+require "pro_tacts/admin/search_dialog"
module ProTacts
module Admin
@@ -9,16 +10,12 @@ 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 — 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.
+ # Gloss — with the search's button beside the name on every
+ # screen: search-first (docs/DESIGN.md) wants finding a contact
+ # possible from anywhere, and the dialog it opens rides along in
+ # every render. `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.
#
# Alpine loads on every screen, and it is the only script here.
# The admin UI was script-free until the edit screen needed to
@@ -39,17 +36,17 @@ module ProTacts
# @rbs @title: String
# @rbs @login: String
# @rbs @wide: bool
- # @rbs @query: String?
- # @rbs @autofocus: bool
+ # @rbs @searching: bool
# @rbs @notice: String?
- #: (title: String, login: String, ?wide: bool, ?query: String?, ?autofocus: bool, ?notice: String?) -> void
- def initialize(title:, login:, wide: false, query: nil, autofocus: false, notice: nil)
+ # `searching` is the search page saying it is the screen: the
+ # phone's link to the search would lead back to where it is.
+ #: (title: String, login: String, ?wide: bool, ?searching: bool, ?notice: String?) -> void
+ def initialize(title:, login:, wide: false, searching: false, notice: nil)
@title = title
@login = login
@wide = wide
- @query = query
- @autofocus = autofocus
+ @searching = searching
@notice = notice
end
@@ -90,96 +87,79 @@ module ProTacts
header(class: "admin-header") do
# The name and, beside it, the one control every screen
# shares: what this app is and how it is asked a
- # question, on the left where reading starts. 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.
+ # question, on the left where reading starts.
a(href: "/") { "pro-tacts" }
- # The search and, in the field's end, its clear: a link,
- # because clearing the dashboard's search is navigating
- # to the page with no query — the form's own target with
- # nothing submitted — landing focused on an empty input
- # again. CSS reveals it the moment there is text to clear
- # (admin.css), typed-but-unsubmitted included, so the
- # header needs no script for it; the native cancel WebKit
- # and Blink draw is suppressed there too, one affordance
- # rather than two.
- #
- # On a phone the search folds into its glyph and opens
- # across the whole header (admin.css, keyed on
- # data-open). A tap on the glyph opens it, and the label
- # is what focuses the field from there. It folds again
- # when focus leaves with nothing typed; a page carrying
- # a query renders open, the query being what its results
- # answer. Focus alone does not open it, because the
- # dashboard's autofocus would then land every phone on
- # an open search with the account hidden behind it. A
- # touch anywhere else folds it at once rather than
- # waiting on the blur, which a phone was seen to send
- # late or not at all.
- open = !@query.to_s.empty?
- fold = "if ($el.querySelector('input').value === '') " \
- "{ open = false; $el.querySelector('input').blur() }"
- form(action: "/", method: "get", class: "search-form",
- x_data: "{ open: #{open} }", "x-bind:data-open": "open",
- "@input": "open = true",
- "@focusout": "open = $el.contains($event.relatedTarget) || " \
- "$el.querySelector('input').value !== ''",
- "@pointerdown.outside": fold,
- **(open ? {data: {open: true}} : {})) do
- label("@click": "open = true") do
- render Icon.new(:search)
- input(type: "search", name: "q", value: @query,
- placeholder: "Search contacts", autofocus: @autofocus)
- end
- a(href: "/", class: "icon-button search-clear", data_size: "sm",
- aria_label: "Clear search") { render Icon.new(:x) }
+ # The search's two ways in, one shown at each width
+ # (admin.css): on a wide screen a button opening the
+ # dialog over the page (SearchDialog), in the header's
+ # middle; on a phone a link, just the glyph, to the
+ # search page (SearchPage), which has the whole screen
+ # for its results. Neither is the input itself, so the
+ # header is one height on every screen. On the search
+ # page itself the phone's glyph is home instead, the
+ # name that is the way home elsewhere being hidden there.
+ button(type: "button", class: "search-button", data_size: "sm",
+ popovertarget: SearchDialog::ID) do
+ render Icon.new(:search)
+ span { "Search" }
end
- # The destinations and the account, at the right edge:
- # the contacts list is the browse surface and the groups
- # list is where a group is created — both reachable from
- # anywhere (docs/DESIGN.md, "The core idea"), search
- # still finding a group by name — with who is asking
- # named beside them. On a phone the two move into the
- # account menu with home, the name's own link (the list
- # below repeats all three for that), leaving the header
- # the search and the account.
- nav do
- a(href: "/contacts") { "contacts" }
- a(href: "/groups") { "groups" }
+ if @searching
+ a(href: "/", class: "icon-button phone-glyph", aria_label: "Home") { render Icon.new(:house) }
+ else
+ a(href: "/search", class: "icon-button phone-glyph", aria_label: "Search") { render Icon.new(:search) }
end
- # The account the proxy vouches for (ProxyAuth), named
- # where the session reads as belonging to someone. The
- # button opens a menu rather than a page because the
- # account has no page: a login decides which cards sync
- # to whose devices (docs/plans/2026-09-12-per-user-
- # books.md) and nothing else, so what hangs off it is
- # the destinations that are not records and so
- # cannot be searched for — import, a handful of times in
- # a book's life (docs/plans/2026-09-21-import-a-vcf.md),
- # export, its counterpart, and device setup. A popover
- # by the Popover API, like
- # every floated layer here; the list sits beside its
- # button in the header because a promoted popover is
- # positioned against the viewport, not the DOM beside
- # its button, so admin.css anchors it to the wrapper by
- # name — while the Popover API still renders it above
- # everything when open.
- div(class: "user-menu") do
- button(type: "button", data_size: "sm", popovertarget: MENU) do
- span { @login }
- render Icon.new(:chevron_down)
+ div(class: "header-end") do
+ # The destinations and the account, at the right edge
+ # in one box, so the search can take the middle between
+ # it and the name (admin.css):
+ # the contacts list is the browse surface and the groups
+ # list is where a group is created — both reachable from
+ # anywhere (docs/DESIGN.md, "The core idea"), search
+ # still finding a group by name — with who is asking
+ # named beside them. On a phone the two move into the
+ # account menu with home, the name's own link (the list
+ # below repeats all three for that), leaving the header
+ # the search and the account.
+ nav do
+ a(href: "/contacts") { "contacts" }
+ a(href: "/groups") { "groups" }
end
- ul(id: MENU, popover: "auto", class: "user-menu-list") do
- li(class: "menu-nav") { a(href: "/") { "home" } }
- li(class: "menu-nav") { a(href: "/contacts") { "contacts" } }
- li(class: "menu-nav") { a(href: "/groups") { "groups" } }
- li { a(href: "/import") { "import" } }
- li { a(href: "/contacts/export.vcf") { "export" } }
- li { a(href: "/setup") { "device setup" } }
+ # The account the proxy vouches for (ProxyAuth), named
+ # where the session reads as belonging to someone. The
+ # button opens a menu rather than a page because the
+ # account has no page: a login decides which cards sync
+ # to whose devices (docs/plans/2026-09-12-per-user-
+ # books.md) and nothing else, so what hangs off it is
+ # the destinations that are not records and so
+ # cannot be searched for — import, a handful of times in
+ # a book's life (docs/plans/2026-09-21-import-a-vcf.md),
+ # export, its counterpart, and device setup. A popover
+ # by the Popover API, like
+ # every floated layer here; the list sits beside its
+ # button in the header because a promoted popover is
+ # positioned against the viewport, not the DOM beside
+ # its button, so admin.css anchors it to the wrapper by
+ # name — while the Popover API still renders it above
+ # everything when open.
+ div(class: "user-menu") do
+ button(type: "button", data_size: "sm", popovertarget: MENU) do
+ span { @login }
+ render Icon.new(:chevron_down)
+ end
+ ul(id: MENU, popover: "auto", class: "user-menu-list") do
+ li(class: "menu-nav") { a(href: "/") { "home" } }
+ li(class: "menu-nav") { a(href: "/contacts") { "contacts" } }
+ li(class: "menu-nav") { a(href: "/groups") { "groups" } }
+ li { a(href: "/import") { "import" } }
+ li { a(href: "/contacts/export.vcf") { "export" } }
+ li { a(href: "/setup") { "device setup" } }
+ end
end
end
end
+ # Out of the header's row: a popover opens in the top
+ # layer from wherever it sits.
+ render SearchDialog.new
main(class: "admin-main", **(@wide ? {data: {wide: true}} : {})) { yield }
# The shell's other edge: which build is answering and
# whether it is logging what it answers, both read off
diff --git a/lib/pro_tacts/admin/recent_row.rb b/lib/pro_tacts/admin/recent_row.rb
new file mode 100644
index 0000000..f0ec0ff
--- /dev/null
+++ b/lib/pro_tacts/admin/recent_row.rb
@@ -0,0 +1,30 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/list_item"
+
+module ProTacts
+ module Admin
+ # A contact's row where the list is one of recency — the
+ # dashboard's recently updated and the search's results, which
+ # keep recency within each rank (Search.contacts) — so the row
+ # says how long ago the card changed.
+ class RecentRow < Phlex::HTML
+ # @rbs @row: Store::RecentContact
+
+ #: (row: Store::RecentContact) -> void
+ def initialize(row:)
+ @row = row
+ end
+
+ def view_template
+ render ListItem.new(
+ href: "/contacts/#{@row.contact.id}",
+ avatar: Avatar.new(contact: @row.contact, size: "lg"),
+ trailing: Format.time_ago(@row.updated_at),
+ ) { Format.name_label(@row.contact) }
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/search.rb b/lib/pro_tacts/admin/search.rb
index fe6d11b..6f353dc 100644
--- a/lib/pro_tacts/admin/search.rb
+++ b/lib/pro_tacts/admin/search.rb
@@ -24,11 +24,14 @@ module ProTacts
# a hit on the name first — every word the start of a word of the
# name or nickname, "ada" or "love" for Ada Lovelace — then the
# hits anywhere else, recency keeping its order within each. The
- # rows arrive in recency order, and partition keeps it. `labels`
- # is the names of the groups each contact is in, by id.
- #: (Array[Store::RecentContact] rows, query: String, labels: Hash[String, Array[String]]) -> Array[Store::RecentContact]
- def self.contacts(rows, query:, labels:)
+ # rows arrive in recency order, and partition keeps it. The groups
+ # arrive whole because a contact is found by the names of the
+ # groups it is in.
+ #: (Array[Store::RecentContact] rows, query: String, groups: Array[Group]) -> Array[Store::RecentContact]
+ def self.contacts(rows, query:, groups:)
words = words(query)
+ labels = {} #: Hash[String, Array[String]]
+ groups.each { |group| group.members.each { (labels[it] ||= []) << group.label } }
matched = rows.select { |row| matches?(row.contact, words, labels.fetch(row.contact.id, [])) }
names, rest = matched.partition { name_hit?(it.contact, words) }
names + rest
diff --git a/lib/pro_tacts/admin/search_dialog.rb b/lib/pro_tacts/admin/search_dialog.rb
new file mode 100644
index 0000000..f9dfad6
--- /dev/null
+++ b/lib/pro_tacts/admin/search_dialog.rb
@@ -0,0 +1,37 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/search_field"
+
+module ProTacts
+ module Admin
+ # The search on a wide screen: a popover the header's button opens
+ # (Layout) over whatever screen it is on, holding the search
+ # (SearchField). A phone goes to the search page instead
+ # (SearchPage); why the two differ, and why a dialog at all, is
+ # docs/plans/2026-09-30-live-search.md.
+ #
+ # The Popover API does the opening and closing — the button, Esc,
+ # 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.
+ class SearchDialog < Phlex::HTML
+ ID = "search" #: String
+
+ # `/` opens the dialog unless it is being typed into a field —
+ # the dialog's own input among them — or the dialog is already
+ # open, where showPopover would throw.
+ SLASH = "if (!$el.matches(':popover-open') && " \
+ "!$event.target.closest('input, textarea, select, [contenteditable]')) " \
+ "{ $event.preventDefault(); $el.showPopover() }" #: String
+ private_constant :SLASH
+
+ def view_template
+ dialog(id: ID, popover: "auto", class: "search-dialog", x_data: true,
+ "@keydown.slash.window": SLASH) do
+ render SearchField.new
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/search_field.rb b/lib/pro_tacts/admin/search_field.rb
new file mode 100644
index 0000000..d8e93e4
--- /dev/null
+++ b/lib/pro_tacts/admin/search_field.rb
@@ -0,0 +1,96 @@
+require "json"
+
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/icons"
+
+module ProTacts
+ module Admin
+ # The search's input and the results under it, fetched from
+ # GET /search/results as it is typed: the one search the header's
+ # dialog holds on a wide screen (SearchDialog) and the search page
+ # is on a phone (SearchPage). Why the results come from the server
+ # rather than a filter in the page is
+ # docs/plans/2026-09-30-live-search.md.
+ #
+ # Alpine does the fetch, waited out until typing pauses and
+ # 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.
+ # 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.
+ #
+ # `query` and `results` are what the field renders holding: the
+ # 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.
+ 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)
+ @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: "filter") do
+ input(type: "search", name: "q", value: @query, placeholder: "Search contacts",
+ aria_label: "Search contacts", autofocus: true, x_model: "q", x_ref: "input",
+ "@input.debounce.150ms": "search()",
+ "@keydown.down.prevent": "move(1)",
+ "@keydown.up.prevent": "move(-1)",
+ "@keydown.enter.prevent": "follow()")
+ button(type: "button", class: "icon-button search-clear", data_size: "sm",
+ aria_label: "Clear search", "@click": "q = ''; search(); $refs.input.focus()") do
+ render Icon.new(:x)
+ end
+ end
+ div(class: "search-results", x_ref: "results") { render @results if @results }
+ end
+ end
+
+ private
+
+ # The field's state and the fetch. A failed fetch throws rather
+ # than showing a stale or empty list: Alpine surfaces it in the
+ # console, and the server has already reported whatever made it
+ # fail.
+ #: () -> String
+ def state
+ "{ q: #{JSON.generate(@query)}, history: #{@history}, recent: #{@recent}, 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}.`); " \
+ "html = await response.text() } " \
+ "catch (error) { if (error.name === 'AbortError') return; throw error } " \
+ "if (request !== this.request) return; " \
+ "this.$refs.results.innerHTML = html; this.mark() }, " \
+ "get links() { return [...this.$refs.results.querySelectorAll('a')] }, " \
+ "move(step) { const n = this.links.length; if (n === 0) return; " \
+ "this.active = (this.active + step + n) % n; this.mark() }, " \
+ "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' }) }, " \
+ "follow() { const link = this.q.trim() === '' ? null : this.links[this.active]; " \
+ "if (link) link.click(); else this.$el.closest('form')?.requestSubmit() } }"
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/search_page.rb b/lib/pro_tacts/admin/search_page.rb
new file mode 100644
index 0000000..1cab0bd
--- /dev/null
+++ b/lib/pro_tacts/admin/search_page.rb
@@ -0,0 +1,35 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/layout"
+require "pro_tacts/admin/search_field"
+
+module ProTacts
+ module Admin
+ # GET /search — the search on a phone, where the header's search is
+ # a link here rather than the dialog a wider screen opens (Layout;
+ # docs/plans/2026-09-30-live-search.md). The same field as the
+ # dialog's, in a GET form back to this page: typing narrows the
+ # results in place and keeps the address on the query, and without
+ # script a submitted query lands here with its results rendered.
+ class SearchPage < Phlex::HTML
+ # @rbs @login: String
+ # @rbs @query: String
+ # @rbs @results: Phlex::HTML
+
+ #: (login: String, query: String, results: Phlex::HTML) -> void
+ def initialize(login:, query:, results:)
+ @login = login
+ @query = query
+ @results = results
+ end
+
+ 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)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/search_results.rb b/lib/pro_tacts/admin/search_results.rb
new file mode 100644
index 0000000..0e04be6
--- /dev/null
+++ b/lib/pro_tacts/admin/search_results.rb
@@ -0,0 +1,52 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/group_label"
+require "pro_tacts/admin/list_item"
+require "pro_tacts/admin/recent_row"
+
+module ProTacts
+ module Admin
+ # The list under the search's input (SearchField), which
+ # GET /search/results answers alone and the search page renders
+ # in: the contacts a query matches in the order Search ranks them
+ # and then the groups — a group is a record the search reaches as
+ # surely as a contact is (docs/DESIGN.md, "When adding something
+ # new"). Uncapped, with no "see all" to send a long list to
+ # (docs/plans/2026-09-30-live-search.md).
+ class SearchResults < Phlex::HTML
+ # @rbs @contacts: Array[Store::RecentContact]
+ # @rbs @groups: Array[Group]
+ # @rbs @recent: bool
+
+ # `recent` is the list standing in for results before there is a
+ # query — the most recently updated contacts, labeled as the
+ # dashboard labels them (Web#search_results).
+ #: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool) -> void
+ def initialize(contacts:, groups:, recent: false)
+ @contacts = contacts
+ @groups = groups
+ @recent = recent
+ end
+
+ def view_template
+ if @contacts.empty? && @groups.empty?
+ p(class: "type-body-sm") { @recent ? "No contacts yet." : "Nothing matches." }
+ return
+ end
+
+ if @contacts.any?
+ h2(class: "type-label") { @recent ? "recently updated" : "contacts" }
+ ul(class: "card") { @contacts.each { render RecentRow.new(row: it) } }
+ end
+ return if @groups.empty?
+
+ h2(class: "type-label") { "groups" }
+ ul(class: "card") do
+ @groups.each do |group|
+ render ListItem.new(href: "/groups/#{group.id}") { render GroupLabel.new(group:) }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index e55dde9..50a2f32 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -131,7 +131,7 @@ module ProTacts
# DAV client's bootstrap. The create dialog rides along hidden
# in the render (see Admin::ContactDialog).
r.get do
- dashboard(query: r.params["q"])
+ dashboard
end
r.propfind do
@@ -155,4 +155,5 @@ require "pro_tacts/web/contacts"
require "pro_tacts/web/dav"
require "pro_tacts/web/groups"
require "pro_tacts/web/import"
+require "pro_tacts/web/search"
require "pro_tacts/web/setup"
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 23705e0..359119a 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -47,7 +47,7 @@ module ProTacts
# (Admin::NamePair), and nobody is known by their middle name
# alone.
if first.empty? && last.empty?
- dashboard(query: r.params["q"], notice: "A contact needs a name.")
+ dashboard(notice: "A contact needs a name.")
else
id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
store.put(id, Admin::CardForm.new_card(id, first, middle, last))
@@ -129,18 +129,14 @@ module ProTacts
private
- # The dashboard GET and a refused create render the same page,
- # which is why the query travels both paths — a create refused
- # under a search re-renders the results it was refused over.
- #: (query: String?, ?notice: String?) -> String
- def dashboard(query:, notice: nil)
+ # The dashboard GET and a refused create render the same page.
+ #: (?notice: String?) -> String
+ def dashboard(notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
Admin::Dashboard.call(
recent: store.contacts_by_recency,
upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
- query:,
login: @login,
- groups: store.all_groups,
notice:,
)
end
diff --git a/lib/pro_tacts/web/search.rb b/lib/pro_tacts/web/search.rb
new file mode 100644
index 0000000..550bea0
--- /dev/null
+++ b/lib/pro_tacts/web/search.rb
@@ -0,0 +1,50 @@
+require "pro_tacts/admin/dashboard"
+require "pro_tacts/admin/search"
+require "pro_tacts/admin/search_page"
+require "pro_tacts/admin/search_results"
+
+module ProTacts
+ class Web < Roda
+ # The search: the page a phone's header links to, and the results
+ # the search field fetches as the query is typed — on that page
+ # and in a wider screen's dialog (Admin::SearchField). The match
+ # stays in Admin::Search, the one place it is decided, rather than
+ # a second copy in the page's script
+ # (docs/plans/2026-09-30-live-search.md).
+ hash_branch("search") do |r|
+ query = r.params["q"].to_s.strip
+
+ r.is do
+ r.get do
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::SearchPage.call(login: @login, query:, results: search_results(query))
+ end
+ end
+
+ # The results alone, put under the input whole.
+ r.get "results" do
+ response["Content-Type"] = "text/html; charset=utf-8"
+ search_results(query).call
+ end
+ end
+
+ private
+
+ # A blank query answers the most recently updated contacts, the
+ # dashboard's own list, so the search page is never empty before a
+ # word is typed.
+ #: (String query) -> Admin::SearchResults
+ def search_results(query)
+ recent = store.contacts_by_recency
+ if query.empty?
+ return Admin::SearchResults.new(contacts: recent.first(Admin::Dashboard::RECENT_LIMIT), groups: [], recent: true)
+ end
+
+ groups = store.all_groups
+ Admin::SearchResults.new(
+ contacts: Admin::Search.contacts(recent, query:, groups:),
+ groups: Admin::Search.groups(groups, query:),
+ )
+ end
+ end
+end
diff --git a/public/admin.css b/public/admin.css
index 34c976a..e7bcf3e 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -102,12 +102,12 @@
/* A form in a card keeps a tighter rhythm than Gloss's form-body
defaults: the boxes shed their block padding down to the scale's
- smallest step — the same shedding the header's search does — and
+ smallest step — the same shedding a dialog's filter does — and
the stack closes to the detail grid's row gap, because a row is
now one line tall there too. Scoped to the card-body form rather
than overriding field.css globally; the label in the input
selector out-specifies field.css's input rule rather than relying
- on stylesheet order (see the header search's note). Everything
+ on stylesheet order (see .search-dialog's rules). Everything
here reaches the record's own rows by the child combinator rather
than by descent: these rules are the record's, and both dialogs'
fields keep Gloss's own — a 26rem popover is not a record, and the
@@ -431,17 +431,36 @@ textarea:focus {
color: var(--gl-color-text-primary);
text-decoration: none;
}
-/* Two groups in one flex row: the name and the search it carries on
- the left, the destinations and the account at the right edge — the
- nav's auto margin is what pushes the second group there, no
- wrappers to hold. The name sits a tier above the links — one type
- step, the identity rendered in type (docs/DESIGN.md) — while they
- step down a shade: destinations, not the name. */
+/* Three columns: the name at the start, the search in the middle,
+ and the destinations with the account at the end, held in one box
+ (Layout). The outer two share the width left over equally, so the
+ middle is the header's own middle whatever the name and the login
+ measure, and a column that needs more than its share takes it,
+ pushing the search aside rather than squeezing its own contents —
+ left to the flex row, the squeeze came out of the account's
+ chevron first. Only wider than a
+ phone, whose header is two things at its two edges (below). The
+ name sits a tier above the links — one type step, the identity
+ rendered in type (docs/DESIGN.md) — while they step down a shade:
+ destinations, not the name. */
+@media (width > 48rem) {
+ .admin-header {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ }
+}
+.header-end {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--gl-space-s);
+ min-width: max-content;
+ margin-inline-start: auto;
+}
.admin-header nav {
display: flex;
align-items: baseline;
gap: var(--gl-space-s);
- margin-left: auto;
}
.admin-header > a {
font-size: var(--gl-step-0);
@@ -451,39 +470,35 @@ textarea:focus {
color: var(--gl-color-text-secondary);
}
-/* The search's label only folds it on a phone, below; wider, it is
- the field's box and the glyph is not drawn. The menu repeats home
- and the destinations for the phone too, and wider the name and the
- nav carry them. */
-.search-form label {
- display: block;
-}
-.search-form label > svg,
+/* The menu repeats home and the destinations for the phone, below;
+ wider, the name and the nav carry them. */
.user-menu .menu-nav {
display: none;
}
/* A phone has no room for the name, the search, two destinations
- and the account in one row: left there, the search shrank to an
- empty square, the name broke at its hyphen, and the account ran
- past the right edge. So the header keeps the two things only it
- can do, the search and the account. The name goes, being the way
- home and nothing more once the tab says which instance this is,
- and home joins the destinations in the account menu (Layout
- repeats them there). The search folds into its glyph at the
- start of the row, pulled out by its padding so it stands on the
- edge the page's column does; a tap on it opens it (Layout says
- when it folds again, and why focus alone does not open it), across
- the whole header over the account, which is
- hidden rather than removed so the header keeps its height. The
- login is whatever the proxy vouches for, usually an email, so it
- ends in an ellipsis when it runs out of room. The breakpoint is
- where the one row still leaves the search room for its
- placeholder. */
+ and the account in one row: left there, the name broke at its
+ hyphen and the account ran past the right edge. So the header
+ keeps the two things only it can do, the search and the account.
+ The name goes, being the way home and nothing more once the tab
+ says which instance this is, and home joins the destinations in
+ the account menu (Layout repeats them there). The search is its
+ link to the search page, the glyph alone, in place of the
+ dialog's button: a phone's screen is the page's whole width, and
+ the dialog would only be a smaller one over it. On the search
+ page the same glyph's place goes to home (Layout). The login is
+ whatever the proxy vouches for, usually an email, so it ends in
+ an ellipsis when it runs out of room.
+
+ The bars' sides close a step inside the page column's gutter, so
+ the glyph and the account's chevron sit nearer the screen's edges
+ than the column's content does: at the gutter they read as inset,
+ and at the bars' block step as crowding the edge. The glyph's
+ link trades Gloss's fixed icon-button box, which centers the glyph
+ in room no token names, for a 2xs step of padding pulled back out
+ by the same step: the glyph stands on the bar's edge the way the
+ chevron does (.user-menu button, below). */
@media (max-width: 48rem) {
- .admin-header {
- position: relative;
- }
.admin-header > a,
.admin-header nav {
display: none;
@@ -491,59 +506,23 @@ textarea:focus {
.user-menu .menu-nav {
display: block;
}
- .admin-header .search-form {
- flex: none;
- margin-inline-start: calc(-1 * var(--gl-space-3xs));
- max-width: none;
- }
- .search-form label {
- display: flex;
- padding: var(--gl-space-3xs);
- cursor: pointer;
- }
- .search-form label > svg {
- display: block;
- }
- .admin-header .search-form input[type="search"] {
- position: absolute;
- width: 1px;
- height: 1px;
- opacity: 0;
- clip-path: inset(50%);
- }
- .admin-header .search-form[data-open] {
- position: absolute;
- inset: 0;
- display: flex;
- flex-direction: row;
- align-items: center;
- margin: 0;
- padding: 0 var(--gl-space-m);
- }
- .admin-header .search-form[data-open] label {
- flex: 1;
- padding: 0;
- cursor: auto;
- }
- .admin-header .search-form[data-open] label > svg {
+ .admin-header .search-button {
display: none;
}
- .admin-header .search-form[data-open] input[type="search"] {
- position: static;
- width: 100%;
- height: auto;
- opacity: 1;
- clip-path: none;
- }
- .admin-header .search-form[data-open] .search-clear {
- inset-inline-end: calc(var(--gl-space-m) + var(--gl-space-2xs));
+ .admin-header,
+ .admin-footer {
+ padding-inline: var(--gl-space-s);
}
- .admin-header:has(.search-form[data-open]) > :not(.search-form) {
- visibility: hidden;
+ .admin-header .phone-glyph {
+ display: inline-flex;
+ width: auto;
+ height: auto;
+ padding: var(--gl-space-2xs);
+ margin-inline-start: calc(-1 * var(--gl-space-2xs));
}
+ .header-end,
.user-menu {
min-width: 0;
- margin-inline-start: auto;
}
.user-menu button {
min-width: 0;
@@ -596,10 +575,9 @@ textarea:focus {
chrome: the menu's list is the header's DOM child but floats over
the page on a card, so left to reach it by descent it paints the
rows white on surface-card — unreadable. They keep the text color
- their own rule gives them (the menu section below). The search's
- clear is the same case: it sits in the field, not on the bar. */
+ their own rule gives them (the menu section below). */
:root[data-accent] :is(.admin-header a, .admin-header .user-menu button,
-.admin-header .search-form label, .admin-footer .type-label):not(.user-menu ul a, .search-clear) {
+.admin-header .search-button, .admin-footer .type-label):not(.user-menu ul a) {
color: var(--gl-color-accent-fg);
}
/* The one focus ring is drawn in the accent, which vanishes against a
@@ -728,36 +706,72 @@ body {
white-space: nowrap;
}
-/* field.css's `form { max-width: 28rem; }` is sized for the
- settings-form pattern; the header's search is a single input, and
- flex: 1 + a narrower cap lets the box grow beside the name toward
- a deliberate 18rem and shrink on a narrow viewport. */
-.search-form {
- flex: 1;
- max-width: 18rem;
-}
-/* field.css sizes the input's padding for form bodies (xs block);
- against the header's one-line chrome the box stands too tall, so it
- sheds the vertical half of that padding down to the scale's
- smallest step and keeps the horizontal. The selector
- out-specifies field.css's double-:not input rule rather than
- relying on stylesheet order. */
-.admin-header .search-form input[type="search"] {
+/* The search field (SearchField): the input over its results, which
+ take no room before there are any. The row Enter would follow
+ wears the hover's fill. */
+.search-field,
+.search-results {
+ display: flex;
+ flex-direction: column;
+}
+.search-field {
+ gap: var(--gl-space-s);
+}
+.search-results {
+ gap: var(--gl-space-2xs);
+}
+.search-results:empty {
+ display: none;
+}
+.search-results .card > li > a[data-active] {
+ background: var(--gl-color-surface-fill-2);
+}
+
+/* The dialog opens near the top rather than centered, so the input
+ stays put as the results under it grow and shrink; wider than
+ Gloss's 26rem, a result row being a whole name and its age. The
+ input sheds its block padding as the groups dialog's filter does,
+ the selector out-specifying field.css's double-:not input rule
+ rather than relying on stylesheet order. The results scroll in
+ place under the input rather than the dialog growing past the
+ viewport — Gloss gives a dialog overflow: hidden (the groups
+ dialog's note below). */
+.search-dialog {
+ width: 36rem;
+ margin-block: 12vh auto;
+}
+.search-dialog .filter > input[type="search"] {
padding-block: var(--gl-space-3xs);
}
+.search-dialog .search-results {
+ max-height: 60vh;
+ overflow-y: auto;
+}
+
+/* The page's field (SearchPage) spans the column: field.css caps a
+ form for the settings pattern, and this form is the page. The
+ header's phone glyph — the link to this page, or home from it —
+ shows only on a phone (above); wider, the dialog's button and the
+ name stand in for it. */
+.search-page {
+ max-width: none;
+}
+.phone-glyph {
+ display: none;
+}
/* Clearing a search field: one affordance for both kinds the app
- has — the header's search and the lists' filter — Gloss's sm
+ has — the search dialog's input and the lists' filter, both a
+ .filter box — Gloss's sm
icon-button carrying Lucide's x, overlaid at the field's end.
Revealed by :has(:not(:placeholder-shown)) on the box the input
and the control share, so it appears the moment there is text to
clear, typed-but-unsubmitted included, with no script — the
filter's button needs Alpine only for its click, which empties
- the state the rows read (GroupFilter). The input's end padding
+ the state the rows or the results read. The input's end padding
reserves the control's width by the sm icon-button's own
geometry (2× its 3xs padding + its 1.15×0.8125rem line), so text
never runs under it. */
-.search-form,
.filter {
position: relative;
}
@@ -768,7 +782,6 @@ body {
translate: 0 -50%;
display: none;
}
-.search-form:has(input:not(:placeholder-shown)) .search-clear,
.filter:has(input:not(:placeholder-shown)) .search-clear {
display: inline-flex;
}
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 0cc6be0..8416773 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -18,7 +18,17 @@ module ProTacts
end
class Dashboard
- def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, login: String, groups: Array[Group], ?notice: String?) -> String
+ RECENT_LIMIT: Integer
+ def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], login: String, ?notice: String?) -> String
+ end
+
+ class SearchResults
+ def self.new: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool) -> instance
+ def call: () -> String
+ end
+
+ class SearchPage
+ def self.call: (login: String, query: String, results: SearchResults) -> String
end
class ContactsIndex
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index e4b0578..e74e8ce 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -348,58 +348,106 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # The search lives in the page header (docs/DESIGN.md), rendered by
- # 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
+ # The search rides every screen's header (docs/DESIGN.md), the
+ # dashboard's and a record's alike — finding a contact never
+ # requires going home first. The dashboard no longer focuses
+ # anything on load: the search is a button and `/` away
+ # (Admin::SearchDialog), and the only autofocus left is inside the
+ # popovers, which fires as each one opens.
+ def test_the_search_renders_on_every_screen
with_contacts({"ada" => ADA}) do
- get "/"
- header = last_response.body.split("<main").first
- assert_includes header, "search-form"
- assert_includes header, "name=\"q\""
- assert_includes header, "autofocus"
- # Folded on a phone despite the focus (Layout says why).
- refute_includes header, " data-open"
+ ["/", "/contacts/ada"].each do |path|
+ get path
+ header = last_response.body.split("<main").first
- get "/", q: "ada"
- header = last_response.body.split("<main").first
- assert_includes header, "value=\"ada\""
- refute_includes header, "autofocus"
- # Open from the start, showing the query its results answer.
- assert_includes header, " data-open"
+ assert_includes header, %(popovertarget="search"), path
+ assert_includes header, %(<dialog id="search" popover="auto"), path
+ end
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
+ # 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
+ grace = ADA.sub("Ada Lovelace", "Grace Hopper").sub("UID:ada", "UID:grace")
+
+ with_contacts({"ada" => ADA, "grace" => grace}) do
+ get "/", q: "hopper"
+
+ assert_includes last_response.body, %(<h2 class="type-label">recently updated</h2>)
+ assert_includes last_response.body, "Ada Lovelace"
+ end
+ end
+
+ # GET /search/results is the field's list, not a page: no layout
+ # around it.
+ def test_search_answers_a_fragment
with_contacts({"ada" => ADA}) do
- get "/contacts/ada"
+ get "/search/results", q: "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"
+ assert_equal 200, last_response.status
+ assert_equal "text/html; charset=utf-8", last_response["Content-Type"]
+ assert last_response.body.start_with?(%(<h2 class="type-label">contacts</h2>))
+ refute_includes last_response.body, "<html"
+ assert_includes last_response.body, %(<a href="/contacts/ada">)
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
- birthday = born(Date.today + 3, name: "Birthday Person", id: "birthday")
+ # A blank query answers the recently updated list, the search page
+ # showing it before a word is typed and again once the input is
+ # cleared.
+ def test_search_with_a_blank_query_answers_the_recent_contacts
+ with_contacts({"ada" => ADA}) do
+ get "/search/results", q: " "
- with_contacts({"ada" => ADA, "birthday" => birthday}) do
- get "/", q: "hopper"
+ assert_equal 200, last_response.status
+ assert last_response.body.start_with?(%(<h2 class="type-label">recently updated</h2>))
+ assert_includes last_response.body, %(<a href="/contacts/ada">)
+ end
+ end
+
+ # The page a phone's header links to (Admin::SearchPage): the field
+ # in a form back to itself, focused, over the recently updated list
+ # until a word is typed.
+ def test_the_search_page_renders_the_field
+ with_contacts({"ada" => ADA}) do
+ get "/search"
+
+ assert_equal 200, last_response.status
+ main = last_response.body.split("<main").last
+ assert_includes main, %(<form action="/search" method="get" class="search-page">)
+ assert_includes main, %(<input type="search" name="q" value="" placeholder="Search contacts")
+ assert_includes main, "autofocus"
+ assert_includes main, %(<h2 class="type-label">recently updated</h2>)
+ assert_includes main, %(<a href="/contacts/ada">)
+ end
+ end
+
+ # On the search page a phone's header glyph goes home rather than
+ # back to the page it is on.
+ def test_the_search_page_trades_the_phone_search_link_for_home
+ with_contacts({}) do
+ get "/search"
+ header = last_response.body.split("<main").first
+ assert_includes header, %(<a href="/" class="icon-button phone-glyph" aria-label="Home">)
+ refute_includes header, %(<a href="/search" class="icon-button phone-glyph")
- assert_includes last_response.body, "No contacts match."
- assert_includes last_response.body, "Birthday Person"
+ get "/"
+ header = last_response.body.split("<main").first
+ assert_includes header, %(<a href="/search" class="icon-button phone-glyph" aria-label="Search">)
+ end
+ end
+
+ # A query in the address renders its results with the page, so a
+ # submitted search answers without script and going back to the
+ # page lands on the results it showed.
+ def test_the_search_page_renders_the_results_of_its_query
+ with_contacts({"ada" => ADA}) do
+ get "/search", q: "ada"
+
+ main = last_response.body.split("<main").last
+ assert_includes main, %(value="ada")
+ assert_includes main, %(<a href="/contacts/ada">)
end
end
@@ -409,15 +457,10 @@ class AdminContactsPagesTest < Minitest::Test
.sub("UID:ada", "UID:grace")
with_contacts({"ada" => ADA, "grace" => grace}) do
- get "/", q: "hopper"
+ get "/search/results", q: "hopper"
- # The columns split on the birthdays column's label: matching
- # narrows the contacts column, and Ada stays out of it — she is
- # still beside it, in the ambient column where she belongs.
- contacts_column = last_response.body.split("upcoming birthdays").first
- assert_includes contacts_column, "Grace Hopper"
- refute_includes contacts_column, "Ada Lovelace"
- assert_includes last_response.body, %(<h2 class="type-label">contacts</h2>)
+ assert_includes last_response.body, "Grace Hopper"
+ refute_includes last_response.body, "Ada Lovelace"
end
end
@@ -425,10 +468,10 @@ class AdminContactsPagesTest < Minitest::Test
# any of its values, or the groups it belongs to.
def test_search_matches_a_phone_or_email_value
with_contacts({"ada" => ADA}) do
- get "/", q: "555-0100"
+ get "/search/results", q: "555-0100"
assert_includes last_response.body, "Ada Lovelace"
- get "/", q: "ada@example.com"
+ get "/search/results", q: "ada@example.com"
assert_includes last_response.body, "Ada Lovelace"
end
end
@@ -439,7 +482,7 @@ class AdminContactsPagesTest < Minitest::Test
red = ADA.sub("FN:Ada Lovelace", "FN:Sarah\r\nNICKNAME:Red").sub("UID:ada", "UID:red")
with_contacts({"red" => red}) do
- get "/", q: "red"
+ get "/search/results", q: "red"
assert_includes last_response.body, "Sarah (Red)"
end
@@ -452,7 +495,7 @@ class AdminContactsPagesTest < Minitest::Test
employed = ADA.sub("UID:ada", "UID:employed").sub("END:VCARD", "ORG:Acme Corp\r\nEND:VCARD")
with_contacts({"employed" => employed}) do
- get "/", q: "acme"
+ get "/search/results", q: "acme"
assert_includes last_response.body, "Ada Lovelace"
end
@@ -468,23 +511,22 @@ class AdminContactsPagesTest < Minitest::Test
.sub("N:Lovelace;Ada;;;", "N:Alvarez;Jose;;;").sub("UID:ada", "UID:jose")
with_contacts({"emile" => emile, "jose" => jose}) do
- get "/", q: "munoz"
+ get "/search/results", q: "munoz"
- contacts_column = last_response.body.split("upcoming birthdays").first
- assert_includes contacts_column, "Émile Muñoz"
- refute_includes contacts_column, "Jose Alvarez"
+ assert_includes last_response.body, "Émile Muñoz"
+ refute_includes last_response.body, "Jose Alvarez"
- get "/", q: "josé"
+ get "/search/results", q: "josé"
- assert_includes last_response.body.split("upcoming birthdays").first, "Jose Alvarez"
+ assert_includes last_response.body, "Jose Alvarez"
end
end
def test_search_with_no_matches_says_so
with_contacts({"ada" => ADA}) do
- get "/", q: "nobody"
+ get "/search/results", q: "nobody"
- assert_includes last_response.body, "No contacts match."
+ assert_includes last_response.body, "Nothing matches."
end
end
@@ -1110,8 +1152,8 @@ class AdminContactsPagesTest < Minitest::Test
assert_includes last_response.body, 'src="/contacts/pic/photo"'
- get "/", q: "no-match-for-this"
- refute_includes last_response.body, 'src="/contacts/pic/photo"'
+ get "/search/results", q: "ada"
+ assert_includes last_response.body, 'src="/contacts/pic/photo"'
end
end
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 40f896f..d36bd7a 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -556,7 +556,7 @@ class AdminGroupsPagesTest < Minitest::Test
with_contacts(BOOLES) do |store|
id = household(store)
- get "/", q: "boole"
+ get "/search/results", q: "boole"
assert_includes last_response.body, %(<a href="/groups/#{id}">)
end
@@ -566,7 +566,7 @@ class AdminGroupsPagesTest < Minitest::Test
with_contacts({"george" => "George", "ada" => "Ada Lovelace"}) do |store|
household(store, members: ["george"])
- get "/", q: "boole"
+ get "/search/results", q: "boole"
assert_includes last_response.body, %(<a href="/contacts/george">)
refute_includes last_response.body, %(<a href="/contacts/ada">)
@@ -580,7 +580,7 @@ class AdminGroupsPagesTest < Minitest::Test
id = store.create_group(name: "Família")
store.add_member(id, "george")
- get "/", q: "familia"
+ get "/search/results", q: "familia"
assert_includes last_response.body, %(<a href="/groups/#{id}">)
assert_includes last_response.body, %(<a href="/contacts/george">)
diff --git a/test/pro_tacts/admin/test_layout.rb b/test/pro_tacts/admin/test_layout.rb
index ce84d6a..6f1d2b3 100644
--- a/test/pro_tacts/admin/test_layout.rb
+++ b/test/pro_tacts/admin/test_layout.rb
@@ -49,12 +49,23 @@ class AdminLayoutTest < Minitest::Test
assert_includes header_html, %(<ul id="user-menu" popover="auto")
end
- # A link, not a button: clearing the dashboard's search is navigating
- # to the page with no query.
- def test_the_search_carries_a_clear
+ # The search is a button in the header and the dialog it opens
+ # (Admin::SearchDialog), rendered on every screen, beside the link a
+ # phone shows instead (admin.css picks one per width). rack-test
+ # cannot open a popover or run the fetch, so this pins the wiring a
+ # browser acts on: the declarative pair, the link, the input focused
+ # as the dialog opens, the route its results come from, and the key
+ # that opens it.
+ def test_the_search_is_a_button_and_the_dialog_it_opens
with_env({})
- assert_includes header_html, %(<a href="/" class="icon-button search-clear" data-size="sm" aria-label="Clear search">)
+ assert_includes header_html, %(<button type="button" class="search-button" data-size="sm" popovertarget="search">)
+ assert_includes header_html, %(<a href="/search" class="icon-button phone-glyph" aria-label="Search">)
+ assert_includes header_html, %(<dialog id="search" popover="auto" class="search-dialog")
+ assert_includes header_html, %(<input type="search" name="q" value="" placeholder="Search contacts" aria-label="Search contacts" autofocus)
+ assert_includes header_html, "fetch('/search/results?q='"
+ assert_includes header_html, "@keydown.slash.window"
+ assert_includes header_html, %(aria-label="Clear search")
end
# On a phone the name and the nav are hidden and the menu carries
diff --git a/test/pro_tacts/admin/test_search.rb b/test/pro_tacts/admin/test_search.rb
index 3ec7247..5eab97d 100644
--- a/test/pro_tacts/admin/test_search.rb
+++ b/test/pro_tacts/admin/test_search.rb
@@ -2,6 +2,7 @@ require_relative "../../test_helper"
require "pro_tacts/admin/search"
require "pro_tacts/contact"
+require "pro_tacts/group"
require "pro_tacts/store"
require "pro_tacts/vcard"
@@ -15,8 +16,8 @@ class SearchTest < Minitest::Test
ProTacts::Store::RecentContact.new(contact:, updated_at: Time.now)
end
- def search(rows, query, labels: {})
- ProTacts::Admin::Search.contacts(rows, query:, labels:).map { it.contact.id }
+ def search(rows, query, groups: [])
+ ProTacts::Admin::Search.contacts(rows, query:, groups:).map { it.contact.id }
end
# Each word finds its own field: neither "ada" nor "london" is
@@ -56,8 +57,9 @@ class SearchTest < Minitest::Test
def test_the_groups_a_contact_is_in_find_it
ada = row("ada", "Ada Lovelace")
+ math = ProTacts::Group.new(id: "math", name: "Mathematicians", label: "Mathematicians", lines: [], members: ["ada"])
- assert_equal ["ada"], search([ada], "mathematicians", labels: {"ada" => ["Mathematicians"]})
+ assert_equal ["ada"], search([ada], "mathematicians", groups: [math])
end
# Charles is the more recent, and only his note says "ada"; Ada's