Give the company create its own popover, not a toggle
A checkbox swapping the name fields made the popover lurch around its
own content-sized box, and a mode switch inside a three-field form was
the wrong shape anyway: the person dialog is exactly what it was, and
the footer link swaps whole popovers.
Assisted-by: GLM-5.3 via pi
diff --git a/docs/plans/2026-09-24-company-cards.md b/docs/plans/2026-09-24-company-cards.md
index 168d1c7..e8617a5 100644
--- a/docs/plans/2026-09-24-company-cards.md
+++ b/docs/plans/2026-09-24-company-cards.md
@@ -18,11 +18,16 @@ components apart yet.
## Creating
-The dashboard's new-contact dialog gained a Company checkbox. Checked,
-it hides the name pair and asks for one Organization box — Alpine
-showing whichever side the toggle chose, each required only while
-shown, so a hidden box can never block the submit. The route branches
-on the submitted `company` param, and `CardForm.new_org_card` writes
+The dashboard's new-contact dialog carries a quiet "New company"
+link at the foot of its actions, opening a second popover of its own:
+one Organization field, the same Create. The first cut was a Company
+checkbox swapping the name fields inside the one dialog, and it was
+wrong twice — a mode switch inside a three-field form, and a popover
+resizing around its own content every time it flipped. The second
+dialog is the honest shape: the person's dialog is exactly what it
+was, and the swap is whole popovers (`popover: "auto"` closes the
+first when the second opens), not fields inside one. The org form
+posts `company` with the one name, and `CardForm.new_org_card` writes
the shape Contacts.app emits: the name in N's family slot, `FN` and
`ORG` of the same, the flag. An organization is made here, not by
converting a person later — the editor never flips the flag.
diff --git a/lib/pro_tacts/admin/contact_dialog.rb b/lib/pro_tacts/admin/contact_dialog.rb
index e464fb4..ab1ee7e 100644
--- a/lib/pro_tacts/admin/contact_dialog.rb
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -42,13 +42,13 @@ module ProTacts
# `autofocus` fires when the popover is shown (it is scoped to the
# popover, so the header search's own page-load autofocus stands).
#
- # The Company toggle makes an organization instead of a person
- # (docs/plans/2026-09-24-company-cards.md): one organization box
- # replaces the pair, each side required only while shown, and the
- # route branches on the submitted `company` param. An organization
- # is created here rather than converted from a person later — the
- # editor renders a company card's one name field, and nothing
- # flips the flag.
+ # An organization is made by its own popover beside this one
+ # (docs/plans/2026-09-24-company-cards.md), opened from the
+ # footer's link — not by a toggle swapping the name fields, which
+ # made the popover lurch around its own content-sized box. Created
+ # here rather than converted from a person later: the editor
+ # renders a company card's one name field, and nothing flips the
+ # flag.
class ContactDialog < Phlex::HTML
# @rbs @query: String
@@ -61,39 +61,18 @@ module ProTacts
dialog(id: "new-contact", popover: "auto") do
header { "New contact" }
form(id: "new-contact-form", action: "/contacts", method: "post",
- x_data: "{ company: false, #{NamePair.state(nil, nil)} }") do
- # The company toggle: a Gloss checkbox row, not a field —
- # a checkbox is no Field control (Gloss's Checkbox contract
- # styles the bare label wrapping one as an inline row), and
- # the quiet option reads as what the name fields stand
- # behind. Checking it hides the pair and asks for the one
- # name an organization is known by
- # (CardForm.new_org_card). x-model both ways, so the state
- # the fields bind to is the checkbox's own, and the
- # submitted `company` param is the browser's serialization
- # of a checked box.
- label do
- input(type: "checkbox", name: "company", value: "1", x_model: "company")
- plain "Company"
- end
- label(class: "field", x_show: "!company") do
+ x_data: "{ #{NamePair.state(nil, nil)} }") do
+ label(class: "field") do
plain "First"
- input(**NamePair.first(nil, nil, guard: "!company"), autofocus: true)
+ input(**NamePair.first(nil, nil), autofocus: true)
end
- label(class: "field", x_show: "!company") do
+ label(class: "field") do
plain "Middle"
input(type: "text", name: "middle")
end
- label(class: "field", x_show: "!company") do
+ label(class: "field") do
plain "Last"
- input(**NamePair.last(nil, nil, guard: "!company"))
- end
- # Required only while shown, the pair's own rule turned
- # around: a hidden box demanding a name would block every
- # person's create.
- label(class: "field", x_show: "company") do
- plain "Organization"
- input(type: "text", name: "organization", ":required": "company")
+ input(**NamePair.last(nil, nil))
end
# The search the dialog opened over — opening a popover
# loads no page, so this survives only the failed-create
@@ -101,12 +80,46 @@ module ProTacts
input(type: "hidden", name: "q", value: @query)
end
footer do
+ # The company create's way in, on the footer's left (Gloss
+ # right-justifies a dialog's actions; this is the one
+ # thing on the other side). Opening the second popover
+ # closes this one, `popover: "auto"` being the many-open-
+ # at-once refusal, so the swap is whole dialogs rather
+ # than fields inside one — a popover sizes to its content,
+ # and fields swapping inside it made the box lurch.
+ button(type: "button", style: "margin-right: auto",
+ popovertarget: "new-company") { "New company" }
button(type: "button", popovertarget: "new-contact",
popovertargetaction: "hide") { "Cancel" }
button(type: "submit", form: "new-contact-form",
data: {variant: "primary"}) { "Create" }
end
end
+
+ # The company create's own popover, the person dialog's footer
+ # link the way in. One field — an organization is known by one
+ # name, the mononym's own rule — and the hidden `company` the
+ # route branches on (CardForm.new_org_card). The same popover
+ # machinery as the person's: Popover API open and close, no
+ # script, values surviving a light dismiss, autofocus scoped
+ # to the popover once shown.
+ dialog(id: "new-company", popover: "auto") do
+ header { "New company" }
+ form(id: "new-company-form", action: "/contacts", method: "post") do
+ input(type: "hidden", name: "company", value: "1")
+ label(class: "field") do
+ plain "Organization"
+ input(type: "text", name: "organization", required: true, autofocus: true)
+ end
+ input(type: "hidden", name: "q", value: @query)
+ end
+ footer do
+ button(type: "button", popovertarget: "new-company",
+ popovertargetaction: "hide") { "Cancel" }
+ button(type: "submit", form: "new-company-form",
+ data: {variant: "primary"}) { "Create" }
+ end
+ end
end
end
end
diff --git a/lib/pro_tacts/admin/name_pair.rb b/lib/pro_tacts/admin/name_pair.rb
index 7604cef..e2e0a57 100644
--- a/lib/pro_tacts/admin/name_pair.rb
+++ b/lib/pro_tacts/admin/name_pair.rb
@@ -22,24 +22,19 @@ module ProTacts
"firstBlank: #{blank?(first)}, lastBlank: #{blank?(last)}"
end
- # `guard` names an Alpine state the pair stands behind — the
- # create dialog's company toggle, which hides the pair and must
- # stop it demanding a name while hidden: a required box the
- # visitor cannot see blocks the submit the toggle asked for.
- #: (String? first, String? last, ?guard: String?) -> Hash[Symbol, untyped]
- def self.first(first, last, guard: nil)
- box("first", first, required: blank?(last), own: "firstBlank", other: "lastBlank", guard:)
+ #: (String? first, String? last) -> Hash[Symbol, untyped]
+ def self.first(first, last)
+ box("first", first, required: blank?(last), own: "firstBlank", other: "lastBlank")
end
- #: (String? first, String? last, ?guard: String?) -> Hash[Symbol, untyped]
- def self.last(first, last, guard: nil)
- box("last", last, required: blank?(first), own: "lastBlank", other: "firstBlank", guard:)
+ #: (String? first, String? last) -> Hash[Symbol, untyped]
+ def self.last(first, last)
+ box("last", last, required: blank?(first), own: "lastBlank", other: "firstBlank")
end
- #: (String name, String? value, required: bool, own: String, other: String, ?guard: String?) -> Hash[Symbol, untyped]
- def self.box(name, value, required:, own:, other:, guard: nil)
- {type: "text", name:, value:, required:, ":required": [guard, other].compact.join(" && "),
- "@input": "#{own} = !$el.value.trim()"}
+ #: (String name, String? value, required: bool, own: String, other: String) -> Hash[Symbol, untyped]
+ def self.box(name, value, required:, own:, other:)
+ {type: "text", name:, value:, required:, ":required": other, "@input": "#{own} = !$el.value.trim()"}
end
# The server's own test of a blank half (Web's contacts routes).
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index b2b1e4f..718b4cb 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -34,13 +34,13 @@ module ProTacts
# the change log a sync token counts on lands with the card.
# A nameless create is a dashboard re-render with a toast: the
# browser cannot produce one — the dialog requires one name box
- # or the other, Admin::NamePair, or the one organization box
- # the Company toggle swaps in — so this is the backstop, and
+ # or the other, Admin::NamePair, or the company dialog's one
+ # organization box — so this is the backstop, and
# a popover cannot be declared open in markup — the toast is
# the refusal the
# re-rendered page can actually show.
r.post do
- # The Company toggle's create: one name, the card
+ # The company dialog's create: one name, the card
# CardForm.new_org_card writes. The same nameless backstop,
# for the same reason.
if r.params["company"]
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index ee01aa8..780bfdc 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1095,24 +1095,37 @@ class AdminContactsPagesTest < Minitest::Test
assert_includes body, '<button data-size="sm" popovertarget="new-contact">add contact</button>'
assert_includes body, '<dialog id="new-contact" popover="auto">'
assert_includes body, '<form id="new-contact-form" action="/contacts" method="post" ' \
- 'x-data="{ company: false, firstBlank: true, lastBlank: true }">'
+ 'x-data="{ firstBlank: true, lastBlank: true }">'
# The captions render — a bare string mid-block is void in
# Phlex, so the labels carry their text through plain (the
# assertion keeps a regression from rendering a silent label).
- # The Company toggle swaps the pair for the one organization
- # box, and every field is required only while shown.
- assert_includes body, '<label><input type="checkbox" name="company" value="1" x-model="company">Company</label>'
- assert_includes body, '<label class="field" x-show="!company">First<input type="text" name="first" required ' \
- ':required="!company && lastBlank" @input="firstBlank = !$el.value.trim()" autofocus></label>'
- assert_includes body, '<label class="field" x-show="!company">Middle<input type="text" name="middle"></label>'
- assert_includes body, '<label class="field" x-show="!company">Last<input type="text" name="last" required ' \
- ':required="!company && firstBlank" @input="lastBlank = !$el.value.trim()"></label>'
- assert_includes body, '<label class="field" x-show="company">Organization' \
- '<input type="text" name="organization" :required="company"></label>'
+ assert_includes body, '<label class="field">First<input type="text" name="first" required ' \
+ ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus></label>'
+ assert_includes body, '<label class="field">Middle<input type="text" name="middle"></label>'
+ assert_includes body, '<label class="field">Last<input type="text" name="last" required ' \
+ ':required="firstBlank" @input="lastBlank = !$el.value.trim()"></label>'
assert_includes body, 'popovertargetaction="hide"'
end
end
+ # The company create is its own popover, opened from the person
+ # dialog's footer link — not a toggle swapping the name fields,
+ # which made the popover lurch around its own content-sized box.
+ def test_the_dashboard_carries_the_new_company_dialog
+ get "/"
+
+ body = last_response.body
+ assert_includes body, '<button type="button" style="margin-right: auto" ' \
+ 'popovertarget="new-company">New company</button>'
+ assert_includes body, '<dialog id="new-company" popover="auto">'
+ assert_includes body, "<header>New company</header>"
+ assert_includes body, '<form id="new-company-form" action="/contacts" method="post">'
+ assert_includes body, '<input type="hidden" name="company" value="1">'
+ assert_includes body, '<label class="field">Organization' \
+ '<input type="text" name="organization" required autofocus></label>'
+ assert_includes body, '<button type="submit" form="new-company-form" data-variant="primary">Create</button>'
+ end
+
# A create lands through Store#put — change log, index, and all —
# and the answer is a redirect to the record, the details page
# where further editing belongs.