groups: the dialog's list rolls back up, and scrolls
Show-all was a one-way door: the footer left the screen.
Now the button rolls it back, and a scroller keeps Save seen.
A popover is hidden, not rebuilt on every view,
so the close clears cap and filter, and the next open opens new.
Assisted-by: Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/lib/pro_tacts/admin/group_dialog.rb b/lib/pro_tacts/admin/group_dialog.rb
index a411b43..1ad7580 100644
--- a/lib/pro_tacts/admin/group_dialog.rb
+++ b/lib/pro_tacts/admin/group_dialog.rb
@@ -21,6 +21,12 @@ module ProTacts
# still rendered: the filter is client-side, so a group that is not
# in the page is one nothing in the dialog can reach.
#
+ # Closing the popover puts the cap and the filter back. Alpine's
+ # state belongs to the element, and a popover is hidden rather
+ # than rebuilt, so without the reset a dialog reopens on whatever
+ # the last visit left in it — a filter typed ten minutes ago, or
+ # the whole list still unrolled.
+ #
# A filter naming no group exactly also offers itself as a new
# group (`new`), created and joined by the save. Unchecked until
# ticked: a filter is usually half a name typed to find a group,
@@ -70,7 +76,8 @@ module ProTacts
end
def view_template
- dialog(id: ID, popover: "auto", x_data: STATE) do
+ dialog(id: ID, popover: "auto", x_data: STATE,
+ "@toggle": "if ($event.newState === 'closed') { all = false; filter = '' }") do
header { "Groups" }
div(class: "field-stack") do
input(type: "search", placeholder: "Filter or add groups", aria_label: "Filter or add groups",
@@ -113,11 +120,20 @@ module ProTacts
# under the cap, that being the number a person is deciding
# whether to read. It stands down while the filter is typed into,
# which lifts the cap itself.
+ #
+ # It goes both ways, the label saying which way the next press
+ # goes: a list unrolled is the long dialog the cap exists to
+ # avoid, and rolling it back up should not mean closing the
+ # dialog. The opening label is rendered rather than left to
+ # x-text alone, so the button reads before Alpine runs.
#: () -> void
def rest_button
- button(type: "button", data_size: "sm", "x-show": "!all && needle === ''",
- "@click": "all = true") { "show all #{@groups.length} groups" }
+ button(type: "button", data_size: "sm", "x-show": "needle === ''", "@click": "all = !all",
+ x_text: "all ? 'show fewer' : #{rest_label.inspect}") { rest_label }
end
+
+ #: () -> String
+ def rest_label = "show all #{@groups.length} groups"
end
end
end
diff --git a/public/admin.css b/public/admin.css
index e4ea890..6173272 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -738,6 +738,21 @@ dialog > .field-stack > input[type="search"] {
padding-block: var(--gl-space-3xs);
}
+/* The groups dialog's list is the one that can outgrow the screen, so
+ it is the one that scrolls — the popover cannot, Gloss giving a
+ dialog height: fit-content and overflow: hidden, and what a dialog
+ taller than the viewport loses off the bottom is the footer holding
+ Save (docs/plans/2026-09-22-a-few-groups-at-a-time.md). The cap is
+ still what keeps the opening list short; this is for the two lists
+ the cap does not shorten, the whole list once it is asked for and a
+ contact's own memberships, which are never capped. The filter and
+ the buttons sit outside the scroller, so both stay put while it
+ moves. */
+#edit-groups form.field-stack {
+ max-height: 50vh;
+ overflow-y: auto;
+}
+
/* The stored card in its own collapsed card under the record — the
column's gap spaces the two, so no margin here. <details> is native,
keeping the admin UI script-free; the summary rides the label
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 01fefda..73b334b 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -506,7 +506,8 @@ class AdminContactsPagesTest < Minitest::Test
end
# The button counts the whole list, and renders only where a row is
- # actually held back.
+ # actually held back. It rolls the list back up as well as down, so
+ # the long dialog the cap exists to avoid is not a one-way door.
def test_the_groups_dialog_offers_the_rest_of_a_capped_list
with_contacts({"ada" => ADA}) do |store|
("a".."i").each { FixtureData.seed_group(store, name: "Group #{it}") }
@@ -514,8 +515,20 @@ class AdminContactsPagesTest < Minitest::Test
get "/contacts/ada"
assert_includes last_response.body,
- %(<button type="button" data-size="sm" x-show="!all && needle === \'\'" ) +
- %(@click="all = true">show all 9 groups</button>)
+ %(<button type="button" data-size="sm" x-show="needle === \'\'" ) +
+ %(@click="all = !all" x-text="all ? 'show fewer' : "show all 9 groups"">) +
+ %(show all 9 groups</button>)
+ end
+ end
+
+ # A popover is hidden rather than rebuilt, so what the dialog was
+ # left in is what it reopens in unless the close clears it.
+ def test_the_groups_dialog_recaps_itself_when_it_closes
+ with_contacts({"ada" => ADA}) do
+ get "/contacts/ada"
+
+ assert_includes last_response.body,
+ %(@toggle="if ($event.newState === 'closed') { all = false; filter = '' }")
end
end