# PublicForm

> Renders a form definition for visitors, with rules that show hide or require fields, validation in Arabic and English, a hidden honeypot and a thank-you. Includes a ready contact form with a topic.

Source: https://docs.nasaqui.com/components/public-form

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/public-form.json
```

The visitor side of a form built with [FormBuilder](https://docs.nasaqui.com/components/form-builder). Give it a `FormDefinition` and it draws the
fields, applies the rules (`show`, `hide`, `require`) as answers change, checks the answers in the visitor's language,
drops bot submissions quietly and shows the thank-you in Arabic or English. `ContactForm` is the same thing with the
contact preset: name, email, topic, message.

The model and its pure helpers (`validateFormValues`, `formFieldStates`, `formOriginAllowed`, `formEmbedSnippet`, and more) live in `form-model.ts` and are exported too, so the server can run the same checks.

## When to use

- A contact, inquiry, subscribe or feedback form on a marketing site, embedded from another origin.
- Any short form whose fields come from data rather than code.

## When not to use

- A long form with steps or file uploads: build it from `Field` parts (or the wizard components).
- An in-app settings form: use `Field` directly.

## Import

```tsx
import { ContactForm, PublicForm, contactFormDefinition, formOriginAllowed } from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"
```

## Quick start

```tsx
import { ContactForm } from "@fadymondy/nasaq/web";

export const Contact = () => (
  <ContactForm
    onSubmit={async (data) => {
      await fetch("/api/contact", { method: "POST", body: JSON.stringify(data) });
    }}
  />
);
```

## Anatomy

```
PublicForm                data-slot="public-form" data-kind="contact"
├─ Field per visible field   data-field="<id>"
├─ honeypot                  hidden input name="website_url", aria-hidden, not focusable
├─ error text                role="alert" when the server refused
└─ Send button               then a thank-you  data-state="done"
```

## API

### `PublicForm`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `form` | `FormDefinition` | required | `{ name, kind, fields, rules, allowedOrigins, enabled, thanksEn, thanksAr, honeypot }`. |
| `onSubmit?` | `(data) => void \| { error? } \| Promise<...>` | none | Only visible answers, trimmed. Resolve `{ error }` or throw to keep the form and show a message. |
| `defaultValues?` | `Record<string, string \| boolean>` | `{}` | |
| `submitLabel?` | `ReactNode` | "Send" | |
| `preview?` | `boolean` | `false` | Validates but never sends; used inside the builder. |
| `locale?` / `labels?` | | provider locale | Validation messages and button text. |

A form with `enabled: false` shows a closed notice instead of the fields.

### `ContactForm`

`PublicForm` with `contactFormDefinition(topics)`. Props: everything above except `form` is optional, plus `topics` (`{ value, label, labelAr? }[]`) and `form` to replace the preset.

### Fields and rules

| Kind | Control |
| --- | --- |
| `text`, `email`, `number` | `Input` (email and number are left to right) |
| `phone` | `PhoneInput`, stored as E.164 |
| `textarea` | `Textarea` |
| `select`, `radio` | `Select` / `RadioGroup`, options `{ value, label, labelAr? }` |
| `checkbox` | `Checkbox` |

A rule is a `RuleBuilder` definition: conditions on other fields, and actions of type `show`, `hide` or `require` with `config.target` set to the field id. A field a `show` rule targets stays hidden until one of its rules matches.

### Helpers

| Export | Description |
| --- | --- |
| `validateFormValues(form, values)` | Error code per field: `required`, `email`, `phone`, `number`. Hidden fields are skipped. |
| `formFieldStates(form, values)` | `{ visible, required }` per field after the rules. |
| `buildFormSubmission(form, values)` | `{ data, spam }`: visible answers only; `spam` when the honeypot was filled. |
| `formOriginAllowed(allowed, origin)` | Exact match or `https://*.example.com`. **An empty list allows nothing.** |
| `parseFormOrigins(text)` / `normalizeFormOrigin(raw)` | Comma-separated input to normalised origins. |
| `formEmbedSnippet({ baseUrl, formKey, style })` | An iframe or script snippet. |
| `contactFormDefinition(topics?)`, `newFormField`, `newFormRule`, `moveFormField`, `parseFormOptions`, `formText` | Building blocks. |

## Examples

### A form that asks more when it matters

```tsx
import { PublicForm, contactFormDefinition, newFormRule } from "@fadymondy/nasaq/web";

const form = contactFormDefinition();
form.fields.push({ id: "order", kind: "text", label: "Order number", labelAr: "رقم الطلب", required: true });
const rule = newFormRule("show");
rule.actions[0]!.config.target = "order";
rule.conditions.children = [{ kind: "condition", id: "c1", field: "topic", op: "is", value: "support" }];
form.rules.push(rule);

export const Support = () => <PublicForm form={form} onSubmit={(data) => console.log(data)} />;
```

### Check the origin on the server

```ts
import { formOriginAllowed } from "@fadymondy/nasaq/web";

export function canEmbed(form: { allowedOrigins: string[] }, origin: string) {
  return formOriginAllowed(form.allowedOrigins, origin);
}
```

## Accessibility

- Every field has a visible label; optional ones say "(optional)". Errors are text under the field, the field is `aria-invalid`, and focus moves to the first field with a problem.
- The honeypot is `aria-hidden`, out of the tab order and named "Leave this field empty".
- The thank-you and closed states use `role="status"`; a server error uses `role="alert"`.

## RTL & i18n

- Labels, help, placeholders and options come in pairs (`label` and `labelAr`); the visitor's locale picks one and falls back to the other.
- Email and number inputs stay left to right inside Arabic forms. Numbers accept Arabic-Indic digits.
- The thank-you has `thanksEn` and `thanksAr`.

## Styling & tokens

Uses `Field`, `Input`, `Select`, `Button` tokens. `className` goes on the form; the layout is one column.

## Do / Don't

- **Do** run `validateFormValues`, `buildFormSubmission` and `formOriginAllowed` on the server too: the client is not a gate.
- **Do** show the thank-you for spam as well; telling a bot it was caught teaches it.
- **Don't** put secrets in a form definition: it is public.

## Related

- [FormBuilder](https://docs.nasaqui.com/components/form-builder) · [Testimonials](https://docs.nasaqui.com/components/testimonials) · [PhoneInput](https://docs.nasaqui.com/components/phone-input) · [Field](https://docs.nasaqui.com/components/field)

## Lab

https://docs.nasaqui.com/?path=/docs/components-form-builders-public-form--docs

## Code

### React

```tsx
import { ContactForm } from "@fadymondy/nasaq/web";

export const Contact = () => (
  <ContactForm
    onSubmit={async (data) => {
      await fetch("/api/contact", { method: "POST", body: JSON.stringify(data) });
    }}
  />
);
```

### shadcn

```tsx
import { ContactForm } from "@/components/ui/public-form";

export const Contact = () => (
  <ContactForm
    onSubmit={async (data) => {
      await fetch("/api/contact", { method: "POST", body: JSON.stringify(data) });
    }}
  />
);
```

### Vue

```vue
<script setup lang="ts">
import { NqContactForm, type FormValues } from "@fadymondy/nasaq/vue";

// Your API call. Throw, or return { error: "..." }, to keep the form and show a message.
async function send(data: FormValues) {
  await fetch("/api/contact", { method: "POST", body: JSON.stringify(data) });
}
</script>

<template>
  <div class="w-96">
    <NqContactForm :on-submit="send" />
  </div>
</template>
```

### Blade

```blade
{{-- Your API call. Resolve { error: "…" } to keep the form and show a message; reject to show "Something went wrong". --}}
<div class="w-96">
    <x-nq::public-form.contact
        x-on:nq-public-form="$event.detail.waitUntil(fetch('/api/contact', { method: 'POST', body: JSON.stringify($event.detail.data) }))" />
</div>
```

### HTML + Alpine

```html
<div class="w-96">
    <form data-slot="public-form" data-kind="contact" novalidate x-data="nqPublicForm(JSON.parse('{\u0022fields\u0022:[{\u0022id\u0022:\u0022name\u0022,\u0022kind\u0022:\u0022text\u0022,\u0022required\u0022:true},{\u0022id\u0022:\u0022email\u0022,\u0022kind\u0022:\u0022email\u0022,\u0022required\u0022:true},{\u0022id\u0022:\u0022topic\u0022,\u0022kind\u0022:\u0022select\u0022,\u0022required\u0022:true},{\u0022id\u0022:\u0022message\u0022,\u0022kind\u0022:\u0022textarea\u0022,\u0022required\u0022:true}],\u0022rules\u0022:[],\u0022honeypot\u0022:true,\u0022preview\u0022:false,\u0022defaults\u0022:{\u0022name\u0022:\u0022\u0022,\u0022email\u0022:\u0022\u0022,\u0022topic\u0022:\u0022\u0022,\u0022message\u0022:\u0022\u0022,\u0022website_url\u0022:\u0022\u0022},\u0022errors\u0022:{\u0022required\u0022:\u0022This field is required.\u0022,\u0022email\u0022:\u0022Enter a valid email address.\u0022,\u0022phone\u0022:\u0022Enter a phone number with its country code.\u0022,\u0022number\u0022:\u0022Enter a number.\u0022},\u0022failed\u0022:\u0022Something went wrong. Please try again.\u0022}'))" x-bind:data-state="done ? 'done' : null"
        x-on:submit.prevent="onSubmit()" x-on:nq-public-form="$event.detail.waitUntil(fetch(&#039;/api/contact&#039;, { method: &#039;POST&#039;, body: JSON.stringify($event.detail.data) }))" class="relative flex w-full flex-col gap-4">
        <div x-show="! done" class="flex flex-col gap-4">
                                <div data-slot="field" x-data="nqField(false)" x-modelable="invalid" x-id="['nq-field']"
         data-valid     x-model="bad[`name`]" data-field="name" x-show="isVisible(`name`)" style="" class="flex flex-col gap-1.5">
    <label data-slot="field-label"  class="text-label text-foreground data-disabled:opacity-50">Your name
                        <span class="ms-1 font-normal text-muted-foreground" x-show="! isRequired(`name`)" style="display: none">(optional)</span></label>
                                                    <input data-slot="input" type="text"
                    name="name" value="" x-model="answers[`name`]" x-bind:required="isRequired(`name`) ? true : null" class="w-full min-w-0 rounded-control border border-input bg-card px-3 text-body text-foreground min-h-[var(--nq-touch-min,0px)] transition-colors duration-150 ease-nq outline-none placeholder:text-muted-foreground focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px] h-control">
                                                <div data-slot="field-error" role="alert" x-show="invalid"  style="display: none"  class="text-caption text-nq-danger-text"><span x-text="fe[`name`]"></span></div>
</div>
                                <div data-slot="field" x-data="nqField(false)" x-modelable="invalid" x-id="['nq-field']"
         data-valid     x-model="bad[`email`]" data-field="email" x-show="isVisible(`email`)" style="" class="flex flex-col gap-1.5">
    <label data-slot="field-label"  class="text-label text-foreground data-disabled:opacity-50">Email
                        <span class="ms-1 font-normal text-muted-foreground" x-show="! isRequired(`email`)" style="display: none">(optional)</span></label>
                                                    <input data-slot="input" type="text"
     dir="ltr"                 name="email" value="" inputmode="email" autocomplete="email" x-model="answers[`email`]" x-bind:required="isRequired(`email`) ? true : null" class="w-full min-w-0 rounded-control border border-input bg-card px-3 text-body text-foreground min-h-[var(--nq-touch-min,0px)] transition-colors duration-150 ease-nq outline-none placeholder:text-muted-foreground focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px] h-control text-start">
                                                <div data-slot="field-error" role="alert" x-show="invalid"  style="display: none"  class="text-caption text-nq-danger-text"><span x-text="fe[`email`]"></span></div>
</div>
                                <div data-slot="field" x-data="nqField(false)" x-modelable="invalid" x-id="['nq-field']"
         data-valid     x-model="bad[`topic`]" data-field="topic" x-show="isVisible(`topic`)" style="" class="flex flex-col gap-1.5">
    <label data-slot="field-label"  class="text-label text-foreground data-disabled:opacity-50">Topic
                        <span class="ms-1 font-normal text-muted-foreground" x-show="! isRequired(`topic`)" style="display: none">(optional)</span></label>
                                                    <div data-slot="select" x-data="nqSelect(null, false)" x-modelable="value" x-id="['nq-select']" x-model="answers[`topic`]" class="contents">
    <button data-slot="select-trigger" x-ref="trigger" x-bind="trigger"
        class="flex h-control w-full min-w-0 items-center justify-between gap-2 rounded-control border border-input bg-card px-3 text-body text-foreground min-h-[var(--nq-touch-min,0px)] cursor-default select-none outline-none transition-colors duration-150 ease-nq focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-popup-open:border-nq-focus data-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px]">
    <span data-slot="select-value" x-text="label() ?? 'Choose an option'" :data-placeholder="empty() ? '' : undefined"
    class="min-w-0 flex-1 truncate text-start data-placeholder:text-muted-foreground"></span>
    <span class="flex shrink-0 text-muted-foreground [&_svg]:size-4"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m7 15 5 5 5-5"/>
  <path d="m7 9 5-5 5 5"/>
</svg></span>
</button>
                        <template x-teleport="body">
    <div data-slot="select-content" x-ref="popup" x-bind="popup" x-nq-presence="open" x-anchor.bottom-start.offset.4="$refs.trigger"
        class="z-50 min-w-[var(--anchor-width)] max-h-[var(--available-height)] overflow-y-auto rounded-floating border border-border bg-popover p-1.5 text-popover-foreground shadow-floating outline-none transition-opacity duration-150 ease-nq data-starting-style:opacity-0 data-ending-style:opacity-0">
        <div data-slot="select-item" x-bind="item('sales', false)"
    class="relative flex h-nav-row min-h-[var(--nq-touch-min,0px)] cursor-default select-none items-center gap-2.5 rounded-control ps-8 pe-2.5 text-body-sm text-foreground outline-none data-highlighted:bg-nq-selected data-disabled:pointer-events-none data-disabled:opacity-50">
    <span aria-hidden="true" class="absolute start-2.5 inline-flex size-4 items-center justify-center">
        <span x-show="isSelected('sales')" x-cloak class="contents"><svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M20 6 9 17l-5-5"/>
</svg></span>
    </span>
    <span data-slot="select-item-text" class="min-w-0 flex-1 truncate">Sales</span>
</div>
                                                            <div data-slot="select-item" x-bind="item('support', false)"
    class="relative flex h-nav-row min-h-[var(--nq-touch-min,0px)] cursor-default select-none items-center gap-2.5 rounded-control ps-8 pe-2.5 text-body-sm text-foreground outline-none data-highlighted:bg-nq-selected data-disabled:pointer-events-none data-disabled:opacity-50">
    <span aria-hidden="true" class="absolute start-2.5 inline-flex size-4 items-center justify-center">
        <span x-show="isSelected('support')" x-cloak class="contents"><svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M20 6 9 17l-5-5"/>
</svg></span>
    </span>
    <span data-slot="select-item-text" class="min-w-0 flex-1 truncate">Support</span>
</div>
                                                            <div data-slot="select-item" x-bind="item('other', false)"
    class="relative flex h-nav-row min-h-[var(--nq-touch-min,0px)] cursor-default select-none items-center gap-2.5 rounded-control ps-8 pe-2.5 text-body-sm text-foreground outline-none data-highlighted:bg-nq-selected data-disabled:pointer-events-none data-disabled:opacity-50">
    <span aria-hidden="true" class="absolute start-2.5 inline-flex size-4 items-center justify-center">
        <span x-show="isSelected('other')" x-cloak class="contents"><svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M20 6 9 17l-5-5"/>
</svg></span>
    </span>
    <span data-slot="select-item-text" class="min-w-0 flex-1 truncate">Something else</span>
</div>
    </div>
</template>
            <template x-if="multiple">
            <template x-for="v in value" :key="v"><input type="hidden" name="topic[]" :value="v"></template>
        </template>
        <template x-if="!multiple"><input type="hidden" name="topic" :value="value ?? ''"></template>
    </div>
                                                <div data-slot="field-error" role="alert" x-show="invalid"  style="display: none"  class="text-caption text-nq-danger-text"><span x-text="fe[`topic`]"></span></div>
</div>
                                <div data-slot="field" x-data="nqField(false)" x-modelable="invalid" x-id="['nq-field']"
         data-valid     x-model="bad[`message`]" data-field="message" x-show="isVisible(`message`)" style="" class="flex flex-col gap-1.5">
    <label data-slot="field-label"  class="text-label text-foreground data-disabled:opacity-50">Message
                        <span class="ms-1 font-normal text-muted-foreground" x-show="! isRequired(`message`)" style="display: none">(optional)</span></label>
                                                    <textarea data-slot="textarea"
                name="message" rows="4" x-model="answers[`message`]" x-bind:required="isRequired(`message`) ? true : null" class="w-full min-w-0 rounded-control border border-input bg-card px-3 text-body text-foreground transition-colors duration-150 ease-nq outline-none placeholder:text-muted-foreground focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px] min-h-20 py-2"></textarea>
                                                <div data-slot="field-error" role="alert" x-show="invalid"  style="display: none"  class="text-caption text-nq-danger-text"><span x-text="fe[`message`]"></span></div>
</div>
                    <div aria-hidden="true" class="pointer-events-none absolute -z-10 h-0 w-0 overflow-hidden opacity-0">
                <label>
                    Leave this field empty
                    <input type="text" name="website_url" tabindex="-1" autocomplete="off" value="" x-model="answers.website_url">
                </label>
            </div>
        <p role="alert" class="text-body-sm text-nq-danger-text" x-show="error" style="display: none" x-text="error"></p>
        <div class="flex flex-wrap items-center gap-3">
            <button data-slot="button"
     type="submit"                         x-bind:disabled="pending" x-bind:data-disabled="pending ? &#039;&#039; : null" x-bind:aria-busy="pending ? &#039;true&#039; : null" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border-border bg-card text-foreground hover:bg-nq-hover h-control px-[var(--nq-control-pad)]">
        <template x-if="pending"><svg data-slot="spinner" aria-hidden="true" class="size-4 animate-spin motion-reduce:animate-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg></template>
                Send</button>
                    </div>
        </div>
        <div data-slot="public-form-done" data-state="done" role="status" x-show="done" style="display: none" class="flex flex-col items-start gap-3 rounded-card border border-border bg-card p-5">
            <svg aria-hidden="true" class="size-6 text-nq-success" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <circle cx="12" cy="12" r="10"/>
  <path d="m9 12 2 2 4-4"/>
</svg>            <p class="text-body">Thanks, we will get back to you soon.</p>
            <button data-slot="button"
     type="button"                         x-on:click="again()" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground underline decoration-nq-line underline-offset-4 hover:decoration-current h-auto px-0">
        Send another response</button>
        </div>
    </form>
</div>
```
