C7 — Forms & Validation Patterns
Scope: Standard form patterns, validation rules, field types, and error-display conventions for all Ghasi Melmastoon surfaces (web, mobile, desktop). Covers guest booking forms, front-desk capture forms, backoffice config forms, and multi-step wizards.
Stack: React Hook Form + Zod (web/desktop renderer). React Native: React Hook Form + Zod (via
@hookform/resolvers/zod). No ad-hoc form state or direct DOM manipulation.
1. Form architecture
1.1 Libraries
| Layer | Library | Notes |
|---|---|---|
| Form state | react-hook-form v7+ | Mode: onBlur for most forms, onChange for search/filter forms |
| Schema validation | zod v3+ | Server schema imported from shared @ghasi/schemas package |
| Resolver | @hookform/resolvers/zod | Shared schemas ensure client/server parity |
| Field components | @ghasi/ui-melmastoon form atoms (<TextField>, <Select>, <DatePicker>, etc.) | Wraps Radix UI where applicable |
1.2 Submit flow
User submits form
→ RHF runs Zod schema (client-side)
→ If invalid: inline field errors (see §2.2)
→ If valid: optimistic disable of form + loading state
→ BFF request
→ On 422 (VALIDATION_FAILED): merge BFF errors[] into RHF state (per §2.3)
→ On success: navigate or show success toast
→ On other error: look up C2 error-to-UI matrix
2. Field types and conventions
2.1 Text fields
<TextField
name="guestName"
label={t("guest.name")}
required
autoComplete="name"
dir={locale.dir} // "ltr" | "rtl"
inputMode="text"
maxLength={100}
description={t("guest.name.hint")} // helper text below field
/>
Rules:
- All labels are visible (no placeholder-only labels — fails WCAG 1.3.5).
autoCompleteis always set where applicable (improves UX + passes WCAG 1.3.5 purpose criteria).dirattribute on the field itself may differ from page direction (e.g., an English email on a Pashto page isdir="ltr").- Max length enforced client-side and server-side.
2.2 Numeric fields
- Financial amounts (rates, charges, refunds): always input in Latin numerals regardless of locale. Show
inputMode="decimal". Append the currency symbol after the field. - Non-financial counts (adult count, nights, room number):
inputMode="numeric". Locale-aware display allowed. - Validation:
z.number().positive().max(9999999)for amounts;z.number().int().min(1).max(99)for counts.
2.3 Date / calendar fields
| Context | Component | Calendars shown |
|---|---|---|
| Guest booking (check-in/out) | <DateRangePicker> | Gregorian + Hijri (side by side, tenant-configurable) |
| Reporting (date filter) | <DatePicker> | Gregorian only |
| Tax submission date | <DatePicker> | Gregorian + Hijri |
| Staff shift scheduling | <DatePicker> | Gregorian only |
- Hijri / Solar Hijri calendar support uses the
temporal-polyfill+@js-temporal/polyfillwithIntl.DateTimeFormatfor display. - Date values stored and transmitted as ISO 8601 (
YYYY-MM-DD); calendar conversion is display-only.
2.4 Phone number fields
- Component:
<PhoneField>with country dial-code picker. - Default country: derived from
tenant.region(AF → +93, IR → +98, TJ → +992, PK → +92). - Validation:
zod-phone-numberlibrary withlibphonenumber-js. - Stored in E.164 format.
2.5 Select / dropdown
- Component: Radix UI
<Select>wrapped by@ghasi/ui-melmastoon. - Always provide a null option ("Choose…") for optional fields.
- For > 20 options, use
<Combobox>(searchable) instead. - RTL: arrow icon flips; popover opens towards the logical start of the field.
2.6 File upload
- Component:
<FileUpload>with drag-drop zone + browse button. - Accepted types declared explicitly (e.g.,
accept="image/jpeg,image/png,application/pdf"). - Max file size: 10 MiB (document), 20 MiB (photo).
- On select: client-side MIME type + size validation before upload.
- Upload via
file-storage-servicesigned URL; progress shown with L-03 progress bar. - A11y: drop zone has
role="button"+ keyboard trigger.
2.7 Checkbox / radio groups
- Always use fieldset + legend for groups.
<Checkbox>from@ghasi/ui-melmastoon(Radix Checkbox primitive).- Single checkbox (terms agreement):
z.literal(true, { message: t("form.required") }).
3. Validation patterns
3.1 Client-side (Zod schemas)
All schemas live in @ghasi/schemas (shared package). Frontend imports and uses them directly:
import { GuestDetailsSchema } from "@ghasi/schemas/booking";
const form = useForm<z.infer<typeof GuestDetailsSchema>>({
resolver: zodResolver(GuestDetailsSchema),
mode: "onBlur",
});
3.2 Error display
- Errors appear below the affected field (not at the top of the form).
- Icon:
alert-circle14px before the message. aria-invalid="true"+aria-describedby="<field>-error"on the input.- On form submit with errors: focus moves to the first errored field.
- Multiple errors per field: show only the first (Zod
formErrors.flatten()first message).
3.3 Server-side error merge
When the BFF returns MELMASTOON.GENERAL.VALIDATION_FAILED (422) with errors[]:
const onSubmit = async (data) => {
const result = await submitToServer(data);
if (result.error?.code === "MELMASTOON.GENERAL.VALIDATION_FAILED") {
result.error.errors.forEach(({ field, code }) => {
form.setError(field, {
type: "server",
message: t(`errors.${code.toLowerCase().replaceAll(".", "_")}`),
});
});
return;
}
};
4. Multi-step wizard patterns
Used for: booking funnel, tenant onboarding, walk-in booking capture, theme authoring.
4.1 Anatomy
[Step indicator]
Step 1 ●——— Step 2 ○——— Step 3 ○
[Step content area]
Form for current step
[Navigation bar]
← Back Next →
- Each step is a separate React subtree, not a tab panel.
- State shared via React context (
<WizardProvider>). - Zod schema per step; validate on "Next" before advancing.
- Browser "Back" navigates wizard step, not page (intercept
popstate). - On desktop: step progress persists in local SQLite draft (outbox) so users can leave and return.
4.2 Step indicator
- Linear progress (step number / total).
- Each step: completed (✓), current (●), upcoming (○).
- RTL: step sequence reads right-to-left visually.
- A11y:
aria-label="Step N of M: <title>"on each indicator item.
4.3 Dirty state / exit confirmation
- If the user navigates away from a partially filled wizard, show an exit confirmation modal: "You have unsaved changes — leave or continue?".
- Exception: read-only review step.
5. Form-specific patterns by context
5.1 Guest details capture (booking funnel)
Required fields:
- Full name (
guestName: string 2–100 chars) - Email (
email: RFC 5322 email) - Phone (
phone: E.164, optional if email provided) - Nationality (
nationality: ISO 3166-1 alpha-2) - Check-in time estimate (time picker, optional)
- Special requests (textarea, 500 char max, optional)
5.2 Walk-in booking (front desk)
Required fields:
- Room selection (property-specific room picker)
- Check-in / check-out dates
- Adult count, child count
- Guest name + phone or ID document number
- Payment method (cash / card / mobile)
- Cash deposit amount (if cash)
- Rate plan
Offline note: all walk-in forms must work fully offline. Data written to local SQLite outbox immediately on submit.
5.3 Folio charge
Required fields:
- Charge category (select from canonical list)
- Amount (Latin numerals, currency appended)
- Description (optional, 200 chars)
- Applied-by staff (auto-filled from session)
5.4 Tax report filter
Required fields:
- Date range (Gregorian + Hijri picker)
- Report currency (select)
- Export format (PDF / CSV)
6. Accessibility requirements
- All form controls have visible, persistent labels (not placeholder-only).
- Error summaries at the top of the form in addition to inline errors are required for forms > 5 fields or when errors may be off-screen.
- Keyboard navigation: Tab, Shift+Tab, Enter to submit, Escape to cancel.
- Focus ring:
--color-focusRingtoken, 2px solid, 2px offset. - Color is never the only indicator of an error state (icon + text required).
7. Open Questions
- Hijri calendar for check-in/out: should both calendars be shown side-by-side always, or only when
tenant.localeis one ofps-AF,fa-AF,fa-IR? - Guest name field: should we collect separate first name + last name, or full name? Legal documents in Afghanistan often use a single name (patronymic only).
- File upload: should tenant logo uploads go through
theme-config-servicedirectly or via BFF?