contacts: a row's group chips open their groups
The chips were spans because the whole row was one anchor, and an
anchor cannot hold another. The row now keeps Gloss's own layout with
the name as its link, stretched over the row so a click anywhere
still opens the contact, and each chip is a link to its group's page
above that stretch, as the record page's tags are.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfL6MhttDTmvaQkrMSCska
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 444b9cb..d2219f9 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -4,7 +4,6 @@ require "pro_tacts/admin/avatar"
require "pro_tacts/admin/format"
require "pro_tacts/admin/group_label"
require "pro_tacts/admin/layout"
-require "pro_tacts/admin/list_item"
module ProTacts
module Admin
@@ -57,18 +56,20 @@ module ProTacts
#: (Contact contact) -> void
def render_row(contact)
- render ListItem.new(
- href: "/contacts/#{contact.id}",
- avatar: Avatar.new(contact:, size: "lg"),
- ) do
- div { Format.name_label(contact) }
- groups = @groups_of[contact.id]
- if groups
- # Spans rather than the record page's linked tags, because
- # the row is a link and an anchor cannot hold one; the
- # weight reset keeps the chips from inheriting the name's.
- div(class: "tag-set", style: "margin-top: var(--gl-space-3xs); font-weight: 400;") do
- groups.each { |group| span(class: "tag") { render GroupLabel.new(group:) } }
+ # Not a ListItem, whose one anchor wraps the whole row, because
+ # the chips are links too and an anchor cannot hold one. The
+ # 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
+ 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) }
+ groups = @groups_of[contact.id]
+ if groups
+ div(class: "tag-set", style: "margin-top: var(--gl-space-3xs);") do
+ groups.each { |group| a(href: "/groups/#{group.id}", class: "tag") { render GroupLabel.new(group:) } }
+ end
end
end
end
diff --git a/public/admin.css b/public/admin.css
index dd41bca..dc16315 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -674,6 +674,38 @@ section > h2 {
line-height: calc(var(--gl-step--1) * 1.55);
}
+/* 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'
+ anchors. So the row keeps Gloss's own padding and flex, the name is
+ the link, and its ::after stretches over the whole row to take a
+ click anywhere on it; the chips, positioned after it in the tree,
+ paint and take clicks above it. The hover fill moves to the row,
+ where the one-anchor row draws it, and does not light for a chip,
+ which is its own target. The focus ring stays Gloss's one ring, on
+ the name. */
+.linked-row {
+ position: relative;
+ transition: background-color var(--gl-dur-fast) var(--gl-ease);
+}
+.linked-row .row-link {
+ display: block;
+ color: inherit;
+ font-weight: 550;
+ text-decoration: none;
+}
+.linked-row .row-link::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+}
+.linked-row:has(.row-link:hover) {
+ background: var(--gl-color-surface-fill);
+}
+.linked-row a.tag {
+ position: relative;
+}
+
/* base/typography.css's `li + li { margin-top: var(--gl-space-3xs); }`
is a prose-list rule (Markdown bullet lists) that also matches
Card's row list, adding a stray gap below every row but the last. */
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 23828a8..be4e9bc 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -105,7 +105,7 @@ class AdminContactsPagesTest < Minitest::Test
assert_equal "text/html; charset=utf-8", last_response["Content-Type"]
assert_includes last_response.body, "<title>pro-tacts — Contacts</title>"
assert_includes last_response.body, "contacts (3)"
- assert_includes last_response.body, %(<a href="/contacts/boole">)
+ assert_includes last_response.body, %(<a href="/contacts/boole" class="row-link">)
body = last_response.body
assert body.index("George Boole") < body.index("Mary Shelley")
assert body.index("Mary Shelley") < body.index("Zed Aileron")
@@ -177,16 +177,15 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # The row says what the contact belongs to, as chips under the name
- # — spans rather than the record page's linked tags, because the row
- # is already a link and an anchor cannot hold one.
- def test_contacts_chips_each_row_with_its_groups
+ # The row says what the contact belongs to, as chips under the name,
+ # each opening its group the way the record page's tags do.
+ def test_contacts_chips_each_row_with_links_to_its_groups
with_contacts({"ada" => ADA}) do |store|
- FixtureData.seed_group(store, name: "Booles", members: ["ada"])
+ group_id = FixtureData.seed_group(store, name: "Booles", members: ["ada"])
get "/contacts"
- assert_includes last_response.body, %(<span class="tag">Booles</span>)
+ assert_includes last_response.body, %(<a href="/groups/#{group_id}" class="tag">Booles</a>)
end
end