Filter the contacts page by name as it is typed
The members screen's filter (Admin::GroupFilter) over the letter-grouped
list: each row carries its name label, and a letter hides once every row
under it does. GroupFilter's visible() now reads a row with no checkbox
as unticked, which is what the contacts page's rows are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7u8JvQMX2bd6sg89LV2Mc
change
commit c73b57a8462cbe96caa6b57af5d1c809faf65624
author Claude <noreply@anthropic.com>
date
parent zrskwksx
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 310a5ab..ae96dab 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -2,6 +2,7 @@ require "pro_tacts/admin/phlex"
 
 require "pro_tacts/admin/avatar"
 require "pro_tacts/admin/format"
+require "pro_tacts/admin/group_filter"
 require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/layout"
 
@@ -12,6 +13,12 @@ module ProTacts
     # "The core idea"), in Format.sort_key's order. Rows carry the
     # label the dashboard's rows do (Format.name_label) and the
     # contact's groups as chips under it.
+    #
+    # A filter over the rows narrows the list by that label as it is
+    # typed, GroupFilter's the way the members screen's is, so the
+    # book's two lists of contacts match a name the same way. A letter
+    # whose rows it all hides goes with them, heading and card, rather
+    # than standing as an empty section.
     class ContactsIndex < Phlex::HTML
       # @rbs @rows: Array[Contact]
       # @rbs @login: String
@@ -32,16 +39,17 @@ module ProTacts
 
       def view_template
         render Layout.new(title: "Contacts", login: @login) do
-          section do
+          section(class: "contacts-list", x_data: GroupFilter::STATE) do
             div(class: "section-head") do
               h2(class: "type-label") { "contacts (#{@rows.length})" }
             end
             if @rows.empty?
               p(class: "type-body-sm") { "No contacts yet." }
             else
-              div(class: "letter-groups") do
+              render GroupFilter.new(placeholder: "Filter contacts")
+              div(class: "letter-groups", x_ref: "options") do
                 letter_groups.each do |letter, rows|
-                  section do
+                  section(":hidden": "![...$el.querySelectorAll('[data-label]')].some(row => visible(row))") do
                     h3(class: "type-label") { letter }
                     ul(class: "card") do
                       rows.each { render_row(it) }
@@ -49,6 +57,7 @@ module ProTacts
                   end
                 end
               end
+              render GroupFilter::Empty.new(any: true, offer: false, noun: "contacts")
             end
           end
         end
@@ -63,7 +72,7 @@ module ProTacts
         # name is the row's link and stretches over the row
         # (.linked-row in admin.css); the chips, each a link to its
         # group's page as on the record page, sit above that stretch.
-        li(class: "linked-row") do
+        li(class: "linked-row", **GroupFilter.row(Format.name_label(contact))) do
           render Avatar.new(contact:, size: "lg")
           div(style: "flex: 1; min-width: 0;") do
             a(href: "/contacts/#{contact.id}", class: "row-link") { Format.name_label(contact) }
diff --git a/lib/pro_tacts/admin/group_filter.rb b/lib/pro_tacts/admin/group_filter.rb
index 88759ff..6e2dd7d 100644
--- a/lib/pro_tacts/admin/group_filter.rb
+++ b/lib/pro_tacts/admin/group_filter.rb
@@ -9,10 +9,12 @@ module ProTacts
     # it, and the offer to make the group the filter names
     # (docs/plans/2026-09-22-a-few-groups-at-a-time.md).
     #
-    # Shared by the three places too long a list is picked from:
-    # the two where a contact's groups are picked — the dialog on a
-    # stored contact's card (Admin::GroupDialog) and the boxes beside
-    # an arriving one (Admin::ImportGroups) — and the members screen,
+    # Shared by the three places too long a list is picked from, and
+    # by the one it is only read from, the contacts page
+    # (Admin::ContactsIndex). Of the three pickers, the two where a
+    # contact's groups are picked — the dialog on a stored contact's
+    # card (Admin::GroupDialog) and the boxes beside an arriving one
+    # (Admin::ImportGroups) — and the members screen,
     # where a group's contacts are (Admin::GroupsMembers). A book with
     # three groups and a book with three hundred are the same screen,
     # and the import's is the screen the walk opens once per contact,
@@ -22,7 +24,9 @@ module ProTacts
     # and not the offer to make what the filter names: a filter word
     # cannot mint a contact — that is the dashboard's dialog, with
     # its name fields — so its placeholder says nothing about adding,
-    # and `named` idles, nothing rendering Fresh to push onto it.
+    # and `named` idles, nothing rendering Fresh to push onto it. The
+    # contacts page renders the filter alone: its rows have no boxes,
+    # and nothing caps a list whose whole point is the whole book.
     #
     # Alpine does the filtering, matching each row's lowercased label
     # in `data-label` so no group's name is ever spliced into script.
@@ -50,9 +54,10 @@ module ProTacts
       # make one vanish would be hiding a decision rather than
       # narrowing a list — and the tick is read off the checkbox at
       # each evaluation, so a row answers the next filter change
-      # already ticked. `rows` is whatever carries `data-label` — a
-      # label in the dialog and the import, an li on the members
-      # screen, whose label sits inside its row — and is the element
+      # already ticked, and a row with no box at all — the contacts
+      # page's — is ticked by nobody. `rows` is whatever carries
+      # `data-label` — a label in the dialog and the import, an li on
+      # the members screen and the contacts page, whose label sits inside its row — and is the element
       # list `none` reads: no row visible, which is what "No groups
       # match" claims. `named` is the names a tick on the offer
       # committed to making (Named): `creatable` and `none` read it
@@ -65,7 +70,7 @@ module ProTacts
               ".map(row => row.dataset.label) }, " \
               "get rows() { return [...this.$refs.options.querySelectorAll('[data-label]')] }, " \
               "shows(label) { return label.includes(this.needle) }, " \
-              "visible(row) { return row.querySelector('input[type=checkbox]').checked || " \
+              "visible(row) { return row.querySelector('input[type=checkbox]')?.checked || " \
               "(this.shows(row.dataset.label) && " \
               "(this.all || this.needle !== '' || !('capped' in row.dataset))) }, " \
               "get none() { return !this.named.length && " \
diff --git a/public/admin.css b/public/admin.css
index 3b14fc6..f290330 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -817,6 +817,14 @@ section > h2 {
   line-height: calc(var(--gl-step--1) * 1.55);
 }
 
+/* The contacts page's filter (Admin::ContactsIndex), between the
+   heading and the letters it narrows: the section-head's own step to
+   the list, so the field reads as the list's first line rather than
+   as the heading's. */
+.contacts-list > .filter {
+  margin-bottom: var(--gl-space-s);
+}
+
 /* A row with links inside it (ContactsIndex's rows, whose group
    chips open their groups): Gloss's whole-row control is an anchor
    that is the row's only child, and an anchor cannot hold the chips'
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 120a7ba..c49e35a 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -205,11 +205,29 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The filter is the members screen's (Admin::GroupFilter): each row
+  # carries the label it matches on, lowercased, and each letter hides
+  # once the filter hides every row under it.
+  def test_contacts_filters_the_rows_by_name
+    red = ADA.sub("FN:Ada Lovelace", "FN:Sarah\r\nNICKNAME:Red").sub("UID:ada", "UID:red")
+
+    with_contacts({"red" => red}) do
+      get "/contacts"
+
+      body = last_response.body
+      assert_includes body, %(placeholder="Filter contacts")
+      assert_includes body, %(data-label="sarah (red)")
+      assert_includes body, %(<section :hidden="![...$el.querySelectorAll('[data-label]')].some(row => visible(row))">)
+      assert_includes body, "No contacts match."
+    end
+  end
+
   def test_contacts_with_no_contacts_says_so
     with_contacts({}) { get "/contacts" }
 
     assert_includes last_response.body, "contacts (0)"
     assert_includes last_response.body, "No contacts yet."
+    refute_includes last_response.body, "Filter contacts"
   end
 
   # The link's answer: every stored card's bytes in id order — the
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index cb00506..938510b 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -721,7 +721,7 @@ class AdminImportPagesTest < Minitest::Test
       # neither the filter nor the cap: a decision the form still
       # submits is never hidden.
       assert_includes last_response.body, %(<template x-for="name in named"><label>)
-      assert_includes last_response.body, %(row.querySelector('input[type=checkbox]').checked)
+      assert_includes last_response.body, %(row.querySelector('input[type=checkbox]')?.checked)
     end
   end