groups: the dialog shows a few groups at a time
The groups dialog on a contact's card rendered every group visible,
and a popover here does not scroll: Gloss gives a dialog
`height: fit-content` and `overflow: hidden`, so a list longer than
the screen clips its own footer and takes Save with it.
Eight rows now stand shown. The rest are rendered hidden rather than
left out, because the filter is client-side and a row that is not in
the page is a group the dialog cannot reach; typing in the filter
lifts the cap, and `show all N groups` under the list reveals it for
someone looking rather than naming. The groups the contact is in are
never capped -- they are the boxes the dialog is opened to untick --
so they take rows from the limit instead of standing outside it.
Nothing about what the dialog posts changes: a hidden box still
submits, and the save was already a diff of what came back against
what the page loaded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DxweroD1moXnn14X4uLHtA
diff --git a/docs/plans/2026-09-22-a-few-groups-at-a-time.md b/docs/plans/2026-09-22-a-few-groups-at-a-time.md
new file mode 100644
index 0000000..7057969
--- /dev/null
+++ b/docs/plans/2026-09-22-a-few-groups-at-a-time.md
@@ -0,0 +1,74 @@
+# A few groups at a time
+
+2026-09-22. The groups dialog on a contact's card
+(`Admin::GroupDialog`) stops rendering its whole list visible. Eight
+rows stand shown; the rest are in the page, hidden, and the filter or
+one button brings them out. Nothing about what the dialog posts or
+what the save does with it changes.
+
+## Why a cap
+
+The dialog is a popover, and a popover here does not scroll: Gloss
+gives `dialog` `height: fit-content` and `overflow: hidden`, and
+admin.css adds a header, a body and a footer to that without a
+max-height anywhere. One checkbox per group was fine at four groups
+and is the dialog's whole problem at forty — the body grows until the
+element runs out of viewport, and what is clipped off the bottom is
+the footer holding Save. There is no scrollbar to reach it with. The
+list is the wrong shape for reading either way: a name is found by
+typing it, which the filter has always been there for, not by eye down
+fifty rows.
+
+## Why the rows stay in the page
+
+The filter is client-side, matching `data-label` on rows the server
+rendered (see `GroupDialog`, and the reason the label is a data
+attribute rather than script). So a group left out of the page is a
+group the dialog cannot reach at all: the filter would answer "No
+groups match" for a group that exists. A hidden row costs a few
+bytes, keeps the filter total, and keeps submitting its box.
+
+That the box still submits is not what protects membership, though.
+The save is a diff of what came back against what the page loaded
+(`was[]`, `Web#apply_groups`), so a group in neither list is one the
+save says nothing about. Rendering every row is for reachability;
+membership is safe either way, and would be safe under a
+server-side limit too. What would not survive one is the filter, so
+the cap is a matter of visibility only.
+
+## The groups a contact is in are never capped
+
+They sort where their names sort, but they are always shown. Those are
+the boxes the dialog is opened to untick, and a contact's card lists
+its groups right above the button that opens it — a dialog that
+hides one is a dialog disagreeing with the page it came from. So they
+take rows from the limit rather than standing outside it, and the
+opening list is eight rows or the contact's memberships, whichever is
+longer. A contact in thirty groups sees thirty, which is a list they
+made themselves.
+
+## What lifts it
+
+Two things, and they are not the same gesture:
+
+- Typing in the filter. The cap lifts for the duration, so a match
+ behind it shows like any other. This is the path for a group whose
+ name is known, and it is one action rather than two.
+- `show all N groups`, under the list. This is the path for looking
+ rather than naming, and it names the whole count because that is
+ the number a person is deciding whether to read. It stands down
+ while the filter has anything in it, the cap being lifted already,
+ and the dialog re-renders on every open, so the list comes back
+ capped next time.
+
+## Non-goals
+
+- Paging. A cap that the filter lifts needs no page two, and pages
+ would put the group a person wants behind a count they cannot
+ guess.
+- A scroller on the popover. It would fix the overflow and leave the
+ reading problem, and the filter is the answer to that.
+- Anything server-side: no limit on `Store#all_groups`, no second
+ read, no query. The dialog already has the list.
+- The group list at `/groups` (`Admin::GroupsIndex`), which is a page
+ and scrolls like one.
diff --git a/lib/pro_tacts/admin/group_dialog.rb b/lib/pro_tacts/admin/group_dialog.rb
index 97e8869..a411b43 100644
--- a/lib/pro_tacts/admin/group_dialog.rb
+++ b/lib/pro_tacts/admin/group_dialog.rb
@@ -15,6 +15,12 @@ module ProTacts
# 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.
+ #
# 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,
@@ -23,13 +29,29 @@ module ProTacts
ID = "edit-groups" #: String
FORM = "edit-groups-form" #: String
- STATE = "{ filter: '', " \
+ # 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.filter.trim().toLowerCase()) }, " \
+ "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() { const name = this.filter.trim().toLowerCase(); " \
- "return name !== '' && !this.labels.includes(name) } }" #: String
+ "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.
@@ -37,6 +59,14 @@ module ProTacts
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)
end
def view_template
@@ -49,7 +79,8 @@ module ProTacts
class: "field-stack", x_ref: "options") do
@groups.each do |group|
joined = group.members.include?(@contact.id)
- label(data: {label: group.label.downcase}, ":hidden": "!shows($el.dataset.label)") do
+ label(data: {label: group.label.downcase, capped: @capped.include?(group.id)},
+ ":hidden": "!visible($el)") do
input(type: "checkbox", name: "groups[]", value: group.id, checked: joined)
render GroupLabel.new(group:)
end
@@ -65,6 +96,7 @@ module ProTacts
template(x_if: "none && !creatable") do
p(class: "gl-muted") { @groups.empty? ? "No groups yet." : "No groups match." }
end
+ rest_button if @capped.any?
end
footer do
button(type: "button", popovertarget: ID, popovertargetaction: "hide") { "Cancel" }
@@ -72,6 +104,20 @@ 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.
+ #: () -> void
+ def rest_button
+ button(type: "button", data_size: "sm", "x-show": "!all && needle === ''",
+ "@click": "all = true") { "show all #{@groups.length} groups" }
+ end
end
end
end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 98937c9..01fefda 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -472,6 +472,64 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # The cap (docs/plans/2026-09-22-a-few-groups-at-a-time.md): eight
+ # rows stand shown and the ninth is marked, rendered for the filter
+ # to reach rather than left out of the page.
+ def test_the_groups_dialog_shows_a_few_groups_at_a_time
+ with_contacts({"ada" => ADA}) do |store|
+ ("a".."i").each { FixtureData.seed_group(store, name: "Group #{it}") }
+
+ get "/contacts/ada"
+ dialog = last_response.body[%r{<dialog id="edit-groups".*?</dialog>}].to_s
+
+ assert_includes dialog, %(<label data-label="group h" :hidden="!visible($el)">)
+ assert_includes dialog, %(<label data-label="group i" data-capped :hidden="!visible($el)">)
+ assert_equal 1, dialog.scan("data-capped").length
+ end
+ end
+
+ # A group the contact is in takes a row from the cap rather than
+ # standing outside it: nine groups, the last of them joined, and the
+ # eighth is what goes under.
+ def test_the_groups_dialog_caps_nothing_a_contact_is_in
+ with_contacts({"ada" => ADA}) do |store|
+ ("a".."h").each { FixtureData.seed_group(store, name: "Group #{it}") }
+ FixtureData.seed_group(store, name: "Zulus", members: ["ada"])
+
+ get "/contacts/ada"
+ dialog = last_response.body[%r{<dialog id="edit-groups".*?</dialog>}].to_s
+
+ assert_includes dialog, %(<label data-label="zulus" :hidden="!visible($el)">)
+ assert_includes dialog, %(<label data-label="group h" data-capped :hidden="!visible($el)">)
+ assert_equal 1, dialog.scan("data-capped").length
+ end
+ end
+
+ # The button counts the whole list, and renders only where a row is
+ # actually held back.
+ 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}") }
+
+ 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>)
+ end
+ end
+
+ def test_the_groups_dialog_offers_no_such_thing_uncapped
+ with_contacts({"ada" => ADA}) do |store|
+ ("a".."h").each { FixtureData.seed_group(store, name: "Group #{it}") }
+
+ get "/contacts/ada"
+
+ refute_includes last_response.body, "show all"
+ refute_includes last_response.body, "data-capped"
+ end
+ end
+
# Joining is the membership and what comes of it: the card serves
# what the group lends, and the change log tells every client so.
def test_checking_a_group_adds_the_contact_to_it