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