Skip to content

Mobile-Responsive Contacts

The Contacts module adapts its layout for mobile screens (<1024px). The detail view switches from a 3-column grid to tab-based panels, and the create form adds inline phone/email fields with multi-step API orchestration.

On desktop, the contact detail view renders three columns simultaneously: sidebar (profile, phones, emails, tags), communication feed, and notes/tasks. On mobile, a tab bar replaces the columns.

#[derive(Clone, Copy, PartialEq)]
enum MobilePanel {
Info, // Contact sidebar
Messages, // Communication feed
Notes, // Notes/tasks/activity
}

State is managed with use_signal(|| MobilePanel::Info). The tab bar renders below the header and is hidden on desktop with lg:hidden.

TabContentDesktop equivalent
InfoProfile, phones, emails, addresses, tags, assignmentsLeft column (1/4)
CommsCalls, messages, emails feedCenter column (2/4)
MoreNotes, tasks, activity sub-tabsRight column (1/4)

Each panel uses conditional CSS classes to show/hide based on the active tab:

// Info panel
class: if active_panel() == MobilePanel::Info {
"lg:col-span-1 overflow-y-auto"
} else {
"hidden lg:block lg:col-span-1 overflow-y-auto"
}

On desktop, panels are always visible via lg:block / lg:flex. On mobile, only the active panel renders — the others get hidden.

The create form collects six fields across three sections, with client-side validation and multi-step API calls.

Basic Info:

  • first_nameString
  • last_nameString

Contact Info:

  • phoneString (optional)
  • emailString, input type email (optional)

Work:

  • companyString (optional)
  • job_titleString (optional)

The layout uses a responsive grid: single column on mobile, two columns on md+ screens.

A UI-only wrapper that bundles the contact data with optional phone/email for the view layer:

#[derive(Debug, Clone, PartialEq)]
pub struct CreateContactPayload {
pub contact: ContactData,
pub phone: Option<String>,
pub email: Option<String>,
}

This type is never serialized. Empty strings are converted to None before submission.

At least one name field (first or last) must be non-empty. The error displays inline:

if first_name().trim().is_empty() && last_name().trim().is_empty() {
name_error.set(Some("At least a first or last name is required.".into()));
return;
}

The error clears when either name field receives input.

The view orchestrates three sequential API calls:

  1. Create contactPOST /api/contacts with ContactData. On failure, stop and show error.
  2. Create phonePOST /api/contacts/{id}/phones with ContactPhoneData. On failure, show toast but continue.
  3. Create emailPOST /api/contacts/{id}/emails with ContactEmailData. On failure, show toast but continue.
  4. Navigate to the new contact’s detail view.

Phone and email default to is_preferred: true and label: None. The contact always gets created even if phone/email fail (e.g., duplicate validation on the server).

Quick action buttons (phone, email, video, note) in the sidebar header increased from w-8 h-8 (32px) to w-10 h-10 (40px), meeting the 40px minimum for comfortable touch interaction.

  • Create Contact button — full-width on mobile (w-full sm:w-auto), auto-width on desktop
  • Profile save/cancel — vertical stack on mobile (flex-col-reverse), horizontal row on desktop
  • Admin buttons (Custom Fields, Manage Assignments) — hidden on mobile (hidden sm:flex)

The shared ViewContainer action slot adds flex-wrap so toolbar buttons wrap gracefully on narrow screens instead of overflowing.

The “Communication History” heading is hidden on mobile (hidden lg:block) since the tab label already identifies the panel.