import: filter the groups beside a contact's card
Before: every group this book has was a checkbox beside every
arriving contact, in one flat list, with a text box under it to name
another. Three groups read fine. Three hundred is not a list anyone
reads, and the walk opens it once per contact.

After: the boxes are filtered, and the filter is the groups dialog's
own — typing narrows the rows, and a filter naming no group offers
itself as one to make, which retires the separate text box. One place
to name a group rather than a list and a field under it.

The filter was the dialog's alone, so it moved to Admin::GroupFilter
and both pickers render it: the x-data, the row attributes that carry
a name to match on, the offer to create, and what the list says when
nothing matches. Two pickers over the same question are a place where
the two could disagree.

The dialog keeps its filter outside the form it filters, which is the
sturdier answer to Enter-saves-mid-filter where it is available. The
import has no outside — the boxes are the editor's own fields — so it
guards the key instead.

The names an import has staged are filtered like the groups, so a
name it is already making is not offered a second time as a name to
make.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
change
commit 150dafcbe41fc0651b395957eab85324bfe9a0b0
author Claude <noreply@anthropic.com>
date
parent ca21083c
diff --git a/Steepfile b/Steepfile
index c56d4cf..31930df 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,7 +22,7 @@ target :lib do
   # the files stay checked.
   ignore(*%w[
     avatar contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
-    group_dialog group_label groups_edit groups_index groups_show icons import_groups import_original import_review
+    group_dialog group_filter group_label groups_edit groups_index groups_show icons import_groups import_original import_review
     import_upload layout list_item phlex upcoming_birthdays
   ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
   signature "sig"
diff --git a/lib/pro_tacts/admin/group_dialog.rb b/lib/pro_tacts/admin/group_dialog.rb
index 1ad7580..87c45ed 100644
--- a/lib/pro_tacts/admin/group_dialog.rb
+++ b/lib/pro_tacts/admin/group_dialog.rb
@@ -1,5 +1,6 @@
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts/admin/group_filter"
 require "pro_tacts/admin/group_label"
 
 module ProTacts
@@ -10,100 +11,56 @@ module ProTacts
     # loaded with beside what it sends back (`was[]`), so the route can
     # move only what was toggled (Web#apply_groups).
     #
-    # Alpine filters, matching each row's lowercased label in
-    # data-label so no group's name is spliced into script; a row it
-    # hides still submits its box. The filter sits outside the form,
-    # because Enter in a form's text box would save mid-filter.
-    #
-    # Only LIMIT rows stand shown, the rest hidden behind the filter or
-    # the button that reveals them
-    # (docs/plans/2026-09-22-a-few-groups-at-a-time.md). Every group is
-    # still rendered: the filter is client-side, so a group that is not
-    # in the page is one nothing in the dialog can reach.
+    # The filter over those boxes, the cap on how many stand shown, the
+    # button that lifts it, and the offer to make the group the filter
+    # names are Admin::GroupFilter's, shared with the import's own
+    # picker (Admin::ImportGroups). The filter box sits outside the
+    # form here, because Enter in a form's text box would save
+    # mid-filter and there is somewhere outside to put it.
     #
     # 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,
-    # and a save must not create "boo" on the way to Booles.
     class GroupDialog < Phlex::HTML
       ID = "edit-groups" #: String
       FORM = "edit-groups-form" #: String
 
-      # How many rows a dialog opens with. The popover does not scroll
-      # (Gloss gives a dialog `height: fit-content` and
-      # `overflow: hidden`), so a list longer than the screen is a
-      # Save clipped off the bottom of it: enough rows here that a
-      # household's whole list is on the screen, few enough that a
-      # long one still ends in its footer.
-      LIMIT = 8 #: Integer
-
-      # `needle` is the filter as the rows are matched against it, one
-      # definition for the three readers of it. `visible` is what the
-      # cap adds to `shows`: a capped row joins the list once the
-      # filter is typed into or the whole list is asked for, and a
-      # filter is the one of the two that can reach a single group
-      # without the rest.
-      STATE = "{ filter: '', all: false, " \
-              "get needle() { return this.filter.trim().toLowerCase() }, " \
-              "get labels() { return [...this.$refs.options.querySelectorAll('[data-label]')]" \
-              ".map(row => row.dataset.label) }, " \
-              "shows(label) { return label.includes(this.needle) }, " \
-              "visible(row) { return this.shows(row.dataset.label) && " \
-              "(this.all || this.needle !== '' || !('capped' in row.dataset)) }, " \
-              "get none() { return !this.labels.some(label => this.shows(label)) }, " \
-              "get creatable() { return this.needle !== '' && !this.labels.includes(this.needle) } }" #: String
-
       # Alphabetical rather than the id order a tag keeps: this is a list
       # to find a name in, and nothing here stays put across a rename.
+      # The groups this contact is in are what the cap spares: those
+      # are the boxes the dialog is opened to untick.
       #: (contact: Contact, groups: Array[Store::Group]) -> void
       def initialize(contact:, groups:)
         @contact = contact
         @groups = groups.sort_by { it.label.downcase }
-        # A group the contact is in is never capped, however far down
-        # the list it sorts: those are the boxes the dialog is opened
-        # to untick, and one hidden from the person unticking it makes
-        # the dialog disagree with the card it was opened from. They
-        # take rows from the limit rather than standing outside it, so
-        # the list is the limit or the membership, whichever is longer.
-        joined, rest = @groups.partition { it.members.include?(contact.id) }
-        @capped = rest.drop((LIMIT - joined.length).clamp(0, LIMIT)).map(&:id)
+        @capped = GroupFilter.capped(
+          @groups.map(&:id),
+          joined: @groups.select { it.members.include?(contact.id) }.map(&:id),
+        )
       end
 
       def view_template
-        dialog(id: ID, popover: "auto", x_data: STATE,
+        dialog(id: ID, popover: "auto", x_data: GroupFilter::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",
-                  autofocus: true, x_model: "filter")
+            render GroupFilter.new(autofocus: true)
             form(id: FORM, action: "/contacts/#{@contact.id}/groups", method: "post",
                  class: "field-stack", x_ref: "options") do
               @groups.each do |group|
                 joined = group.members.include?(@contact.id)
-                label(data: {label: group.label.downcase, capped: @capped.include?(group.id)},
-                      ":hidden": "!visible($el)") do
+                label(**GroupFilter.row(group.label, capped: @capped.include?(group.id))) do
                   input(type: "checkbox", name: "groups[]", value: group.id, checked: joined)
                   render GroupLabel.new(group:)
                 end
                 input(type: "hidden", name: "was[]", value: group.id) if joined
               end
-              template(x_if: "creatable") do
-                label do
-                  input(type: "checkbox", name: "new", ":value": "filter.trim()")
-                  span(class: "gl-muted", style: "font-style: italic;", x_text: "filter.trim()")
-                end
-              end
+              render GroupFilter::Fresh.new
             end
-            template(x_if: "none && !creatable") do
-              p(class: "gl-muted") { @groups.empty? ? "No groups yet." : "No groups match." }
-            end
-            rest_button if @capped.any?
+            render GroupFilter::Empty.new(any: @groups.any?)
+            render GroupFilter::Rest.new(count: @groups.length) if @capped.any?
           end
           footer do
             button(type: "button", popovertarget: ID, popovertargetaction: "hide") { "Cancel" }
@@ -111,29 +68,6 @@ module ProTacts
           end
         end
       end
-
-      private
-
-      # The way past the cap for someone who is looking rather than
-      # naming: the filter reaches a group already known by name, this
-      # reaches the list. It counts the whole list rather than the part
-      # 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": "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/lib/pro_tacts/admin/group_filter.rb b/lib/pro_tacts/admin/group_filter.rb
new file mode 100644
index 0000000..1bc70f0
--- /dev/null
+++ b/lib/pro_tacts/admin/group_filter.rb
@@ -0,0 +1,177 @@
+require "pro_tacts/admin/phlex"
+
+module ProTacts
+  module Admin
+    # Picking groups out of a list too long to read: the filter over
+    # the rows, the cap on how many stand shown, the button that lifts
+    # 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 two places 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). 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,
+    # so the two are the same problem — and a second answer to it
+    # would only be a place where they could disagree.
+    #
+    # Alpine does the filtering, matching each row's lowercased label
+    # in `data-label` so no group's name is ever spliced into script.
+    # A row it hides still submits its box: hiding is not unticking,
+    # and a contact's other groups are not being answered for by a
+    # word typed to find one of them. Every row stays in the page for
+    # the same reason the plan gives — the filter is client-side, so a
+    # row the server left out is a row nothing here can reach.
+    class GroupFilter < Phlex::HTML
+      # How many rows a list opens with. The dialog is a popover and
+      # cannot scroll past its own footer (see the plan, and the
+      # scroller admin.css gives it); the import's list is on an
+      # ordinary page and caps for the reading rather than the
+      # clipping. Enough rows that a household's whole list is there,
+      # few enough that a long one still ends in its buttons.
+      LIMIT = 8 #: Integer
+
+      # `needle` is the filter as the rows are matched against it, one
+      # definition for the three readers of it. `visible` is what the
+      # cap adds to `shows`: a capped row joins the list once the
+      # filter is typed into or the whole list is asked for, and a
+      # filter is the one of the two that can reach a single group
+      # without the rest.
+      STATE = "{ filter: '', all: false, " \
+              "get needle() { return this.filter.trim().toLowerCase() }, " \
+              "get labels() { return [...this.$refs.options.querySelectorAll('[data-label]')]" \
+              ".map(row => row.dataset.label) }, " \
+              "shows(label) { return label.includes(this.needle) }, " \
+              "visible(row) { return this.shows(row.dataset.label) && " \
+              "(this.all || this.needle !== '' || !('capped' in row.dataset)) }, " \
+              "get none() { return !this.labels.some(label => this.shows(label)) }, " \
+              "get creatable() { return this.needle !== '' && !this.labels.includes(this.needle) } }" #: String
+
+      # What a filterable row wears: the label to match on, read off
+      # the element rather than out of a closure, whether the cap holds
+      # it back, and the hiding the two of them drive. Every row the
+      # filter is meant to see carries it — a group this book has, and
+      # a name an import is about to make — because `creatable` is
+      # "none of these", and a row the filter cannot see is a row it
+      # would offer to create twice.
+      #: (String label, ?capped: bool) -> Hash[untyped, untyped]
+      def self.row(label, capped: false)
+        {data: {label: label.downcase, capped:}, ":hidden": "!visible($el)"}
+      end
+
+      # Which of `ids` the cap holds back, in the order they render.
+      # A row already ticked is never capped, however far down the
+      # list it sorts: those are the boxes a person opens the list to
+      # untick, and one hidden from them makes the list disagree with
+      # the screen it was opened from. They take rows from the limit
+      # rather than standing outside it, so what stands shown is the
+      # limit or the ticked rows, whichever is longer. `shown` is for
+      # rows that are always there and are not in `ids` — the names an
+      # import is about to make — which take from the limit the same
+      # way.
+      #: (Array[String] ids, joined: Array[String], ?shown: Integer) -> Array[String]
+      def self.capped(ids, joined:, shown: 0)
+        ticked, rest = ids.partition { joined.include?(it) }
+        rest.drop((LIMIT - ticked.length - shown).clamp(0, LIMIT))
+      end
+
+      # @rbs @autofocus: bool
+      # @rbs @in_form: bool
+
+      # `autofocus` where the picker is a popover that just opened and
+      # nothing else wants the caret; not where it is one field among
+      # a card's own.
+      #
+      # `in_form` where the boxes sit inside a larger form. Enter in a
+      # text box submits the form around it, and half a name typed to
+      # find a group is not a save. The dialog needs neither the flag
+      # nor the guard, its filter standing outside the form it
+      # filters, which is the sturdier answer where it is available.
+      #: (?autofocus: bool, ?in_form: bool) -> void
+      def initialize(autofocus: false, in_form: false)
+        @autofocus = autofocus
+        @in_form = in_form
+      end
+
+      def view_template
+        input(type: "search", placeholder: "Filter or add groups",
+              aria_label: "Filter or add groups", autofocus: @autofocus,
+              x_model: "filter", **enter)
+      end
+
+      private
+
+      #: () -> Hash[untyped, untyped]
+      def enter
+        return {} unless @in_form
+
+        {"@keydown.enter.prevent": "filter = filter.trim()"}
+      end
+
+      # The filter itself, offered as a group to make, under the rows
+      # it matched none of. Inside the form, being a field of it, and
+      # unticked: a filter is as often half a name typed to find a
+      # group as it is a new one, and a save must not make "boo" on
+      # the way to Booles.
+      class Fresh < Phlex::HTML
+        def view_template
+          template(x_if: "creatable") do
+            label do
+              input(type: "checkbox", name: "new", ":value": "filter.trim()")
+              span(class: "gl-muted", style: "font-style: italic;", x_text: "filter.trim()")
+            end
+          end
+        end
+      end
+
+      # What the list says when the filter matches nothing and is not
+      # a name either — which, the filter being blank, is also what an
+      # empty book says.
+      class Empty < Phlex::HTML
+        # @rbs @any: bool
+
+        #: (any: bool) -> void
+        def initialize(any:)
+          @any = any
+        end
+
+        def view_template
+          template(x_if: "none && !creatable") do
+            p(class: "gl-muted") { @any ? "No groups match." : "No groups yet." }
+          end
+        end
+      end
+
+      # The way past the cap for someone who is looking rather than
+      # naming: the filter reaches a group already known by name, this
+      # reaches the list. It counts the whole list rather than the part
+      # 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 list the cap exists to
+      # avoid, and rolling it back up should not mean closing the
+      # screen it is on. The opening label is rendered rather than
+      # left to x-text alone, so the button reads before Alpine runs.
+      class Rest < Phlex::HTML
+        # @rbs @count: Integer
+
+        #: (count: Integer) -> void
+        def initialize(count:)
+          @count = count
+        end
+
+        def view_template
+          button(type: "button", data_size: "sm", "x-show": "needle === ''", "@click": "all = !all",
+                 x_text: "all ? 'show fewer' : #{label_text.inspect}") { label_text }
+        end
+
+        private
+
+        #: () -> String
+        def label_text = "show all #{@count} groups"
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/import_groups.rb b/lib/pro_tacts/admin/import_groups.rb
index c21baa6..274a525 100644
--- a/lib/pro_tacts/admin/import_groups.rb
+++ b/lib/pro_tacts/admin/import_groups.rb
@@ -1,5 +1,6 @@
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts/admin/group_filter"
 require "pro_tacts/admin/group_label"
 
 module ProTacts
@@ -16,13 +17,18 @@ module ProTacts
     # (Admin::GroupDialog) — there the card exists and a membership is
     # a write of its own, here neither is true until the confirm.
     #
-    # A box per group this book has, then a box per group this import
-    # has been told to make and has not made yet, then somewhere to
-    # name another. A named group is not created here: it is made at
-    # the confirm (Import::Write#group_id), because a group created
-    # while the walk is still going is a group left behind by an
-    # import that was abandoned. So it rides as a name until then, and
-    # shows as a ticked box like any other so that it can be unticked.
+    # A filter over a box per group this book has, then a box per
+    # group this import has been told to make and has not made yet,
+    # then the filter itself offered as another. The filter is the
+    # dialog's own (Admin::GroupFilter): a book with three hundred
+    # groups is a screen either picker would otherwise bury, and this
+    # one is walked once per contact.
+    #
+    # A named group is not created here: it is made at the confirm
+    # (Import::Write#group_id), because a group created while the walk
+    # is still going is a group left behind by an import that was
+    # abandoned. So it rides as a name until then, and shows as a
+    # ticked box like any other so that it can be unticked.
     class ImportGroups < Phlex::HTML
       # @rbs @groups: Array[Store::Group]
       # @rbs @joined: Array[String]
@@ -40,32 +46,36 @@ module ProTacts
       end
 
       def view_template
-        div(class: "field-stack") do
+        div(class: "field-stack", x_data: GroupFilter::STATE) do
           span(class: "type-label") { "groups" }
-          @groups.each do |group|
-            label do
-              input(type: "checkbox", name: "groups[]", value: group.id,
-                    checked: @joined.include?(group.id))
-              render GroupLabel.new(group:)
+          # Enter guarded rather than the filter moved: the dialog
+          # keeps its filter outside the form it filters, and here
+          # there is no outside — the boxes are the editor's own
+          # fields (GroupFilter#initialize).
+          render GroupFilter.new(in_form: true)
+          div(class: "field-stack", x_ref: "options") do
+            @groups.each do |group|
+              label(**GroupFilter.row(group.label)) do
+                input(type: "checkbox", name: "groups[]", value: group.id,
+                      checked: @joined.include?(group.id))
+                render GroupLabel.new(group:)
+              end
             end
-          end
-          # Italic and muted, the shape the groups dialog gives a name
-          # that is not a group yet (Admin::GroupDialog): the box
-          # behaves like the ones above it, and the styling is what
-          # says this one is about to be made.
-          @named.each do |name|
-            label do
-              input(type: "checkbox", name: "named[]", value: name, checked: true)
-              span(class: "gl-muted", style: "font-style: italic;") { name }
+            # Italic and muted, the shape the groups dialog gives a
+            # name that is not a group yet: the box behaves like the
+            # ones above it, and the styling is what says this one is
+            # about to be made. Filterable like them too, so that a
+            # name this import is already making is not offered as a
+            # name to make.
+            @named.each do |name|
+              label(**GroupFilter.row(name)) do
+                input(type: "checkbox", name: "named[]", value: name, checked: true)
+                span(class: "gl-muted", style: "font-style: italic;") { name }
+              end
             end
+            render GroupFilter::Fresh.new
           end
-          # One at a time, and blank by default: a name typed here is
-          # added to the list above on save, where it can be read back
-          # and taken off again.
-          label(class: "field") do
-            span { "New group" }
-            input(type: "text", name: "new", placeholder: "made when the import is confirmed")
-          end
+          render GroupFilter::Empty.new(any: @groups.any? || @named.any?)
         end
       end
     end
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 1ae2a66..3fee465 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -293,6 +293,26 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
+  # A book with three hundred groups is the same screen as a book
+  # with three, and the walk opens it once per contact: the boxes are
+  # filtered, and the filter is the groups dialog's own
+  # (Admin::GroupFilter). Every row it is meant to see says which
+  # name it matches on, and the filter itself is the way to name a
+  # group this book does not have — one place rather than a list and
+  # a text field under it.
+  def test_the_groups_beside_a_card_are_filtered_like_the_dialogs
+    with_contacts({}) do |store|
+      store.create_group(name: "school")
+      id = upload(JANE)
+
+      get "/import/#{id}/0"
+
+      assert_includes last_response.body, %(<input type="search" placeholder="Filter or add groups")
+      assert_includes last_response.body, %(data-label="school")
+      assert_includes last_response.body, %(<input type="checkbox" name="new" :value="filter.trim()">)
+    end
+  end
+
   # A group that does not exist yet is named beside the contact and
   # made at the confirm, not before: a group created while the walk
   # is still going is one left behind by an import that was
@@ -310,11 +330,14 @@ class AdminImportPagesTest < Minitest::Test
       assert_empty store.all_groups
 
       # Read back as a box of its own, ticked, so it can be taken off
-      # again before the confirm.
+      # again before the confirm — and filtered like the rest, so a
+      # name this import is already making is not offered again as a
+      # name to make.
       get "/import/#{id}/0"
 
       assert_includes last_response.body,
                       %(<input type="checkbox" name="named[]" value="Clarks" checked>)
+      assert_includes last_response.body, %(data-label="clarks")
 
       post "/import/#{id}/confirm", "group" => ""
       clarks = store.all_groups.find { it.name == "Clarks" }