From cf1c8b66db2539d82fd9b65deaaac6a41ea5dd41 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sun, 3 May 2026 16:15:07 +0200 Subject: [PATCH] feat(client): phone-edit row dilation + mobile contacts layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InlinePhoneField now lays the country picker + number on top, with Save + Cancel buttons on a second line — the previous single-line cluster was cramped at every viewport size and broke entirely below ~480px. A new onEditingChange callback notifies the parent when the field enters edit mode, so contact rows can react. ContactsEditor uses it to "dilate" the row visually: lift out of the muted baseline with a soft primary ring + slightly brighter surface + bumped padding. Single visual signal replaces the need for any "now editing" label, and the dilation also hides the noisy chip cluster (label / star / trash) that would otherwise fight the editor for space. Mobile improvements applied at the same time: - Each row stacks value editor on top, action cluster below at --- src/components/clients/contacts-editor.tsx | 121 +++++++++++-------- src/components/shared/inline-phone-field.tsx | 79 +++++++----- 2 files changed, 123 insertions(+), 77 deletions(-) diff --git a/src/components/clients/contacts-editor.tsx b/src/components/clients/contacts-editor.tsx index f6fe92f..a4b701b 100644 --- a/src/components/clients/contacts-editor.tsx +++ b/src/components/clients/contacts-editor.tsx @@ -155,6 +155,7 @@ function ContactRow({ onRemove: () => void; }) { const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal; + const [phoneEditing, setPhoneEditing] = useState(false); async function togglePrimary() { try { @@ -174,17 +175,31 @@ function ContactRow({ } return ( -
- {/* Left: channel + value */} -
+
+ {/* Top / left: channel + value */} +
-
+
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? ( { if (!e164) { toast.error('Phone number is required'); @@ -208,42 +223,46 @@ function ContactRow({
- {/* Right: tag + actions */} -
-
- { - await onUpdate({ label: v }); - }} - /> + {/* Bottom / right: tag + actions. Hidden while the phone editor is active + to keep focus on the form — no chips fighting for space, no noise. */} + {!phoneEditing ? ( +
+
+ { + await onUpdate({ label: v }); + }} + /> +
+ + + +
- - - - -
+ ) : null}
); } @@ -330,7 +349,9 @@ function NewContactForm({ const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim()); return ( -
+ // Single row on sm+; wraps onto multiple lines below 640px so the channel + // picker, value field, label, and buttons each get their own usable width. +
{isPhoneChannel ? ( -
+
setPhoneValue(v)} @@ -365,7 +386,7 @@ function NewContactForm({ value={value} onChange={(e) => setValue(e.target.value)} placeholder={channel === 'email' ? 'name@example.com' : 'value'} - className="h-7 text-sm flex-1 min-w-0" + className="h-7 min-w-0 flex-1 basis-full text-sm sm:basis-auto" autoFocus disabled={saving} onKeyDown={(e) => { @@ -382,7 +403,7 @@ function NewContactForm({ value={label} onChange={(e) => setLabel(e.target.value)} placeholder="tag (optional)" - className="h-7 text-xs w-28" + className="h-7 w-28 text-xs" disabled={saving} onKeyDown={(e) => { if (e.key === 'Enter') { @@ -393,12 +414,14 @@ function NewContactForm({ }} /> - - +
+ + +
); } diff --git a/src/components/shared/inline-phone-field.tsx b/src/components/shared/inline-phone-field.tsx index bb808ce..a752ad5 100644 --- a/src/components/shared/inline-phone-field.tsx +++ b/src/components/shared/inline-phone-field.tsx @@ -17,6 +17,12 @@ interface InlinePhoneFieldProps { /** Falls back to this country if `country` isn't set. */ defaultCountry?: CountryCode; onSave: (next: { e164: string | null; country: CountryCode }) => Promise; + /** + * Notifies the parent when the field enters/exits edit mode. Lets the row + * dim or hide noise (tag chips, action buttons) while the user is focused + * on the editor. + */ + onEditingChange?: (editing: boolean) => void; emptyText?: string; disabled?: boolean; className?: string; @@ -28,12 +34,13 @@ export function InlinePhoneField({ country, defaultCountry, onSave, + onEditingChange, emptyText = '—', disabled, className, 'data-testid': testId, }: InlinePhoneFieldProps) { - const [editing, setEditing] = useState(false); + const [editing, setEditingRaw] = useState(false); const [draft, setDraft] = useState(() => { if (!e164 && !country) return null; return { @@ -43,6 +50,11 @@ export function InlinePhoneField({ }); const [saving, setSaving] = useState(false); + function setEditing(next: boolean) { + setEditingRaw(next); + onEditingChange?.(next); + } + async function commit() { const next = draft ?? { e164: null, country: defaultCountry ?? 'US' }; if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) { @@ -62,39 +74,50 @@ export function InlinePhoneField({ if (editing) { return ( -
+ // Two clean lines: country picker + number on top, action pair below. +
setDraft(v)} defaultCountry={defaultCountry} data-testid={testId} /> - - +
+ + +
); }