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>
change pxnunqywkoyxnmmuqyuolvutrpwznqvw
commit a48f4ff777f8581562cb60e75a1438d566efcc5a
author Alpha Chen <alpha@kejadlen.dev>
date
parent uomtowpr
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' : &quot;show all 9 groups&quot;">) +
+                      %(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