frontend-forms
Form behaviour: native inputs first, a form library with a schema, when to validate, error messages that say how to fix, submission and double submit, server errors mapped to fields, unsaved changes, autosave, multi-step forms and file inputs. Read before building a form's logic; its look is `ui-part-forms`.
- License 许可
- Apache-2.0
- 所属仓库
- enowdev/enowxcli
- 最近更新
- 2026-10-06
- Stars
- ★ 67
- Forks
- 21
01 前言 Front Matter
- name
- frontend-forms
- description
- Form behaviour: native inputs first, a form library with a schema, when to validate, error messages that say how to fix, submission and double submit, server errors mapped to fields, unsaved changes, autosave, multi-step forms and file inputs. Read before building a form's logic; its look is `ui-part-forms`.
03 SKILL.md 正文
Form behaviour
The generated form is a stack of divs with placeholders for labels,
type="number" for a card number, red borders from the first keystroke,
"Invalid input" under every field, a submit button that sends twice on a
double click, and a server error that wipes everything typed. People fill
forms to get something done; this is how a form helps them finish. The
look is ui-part-forms, the page ui-page-form, the whole frontend
frontend.
1. Native first
- A real
<form>with a<button type="submit">, so Enter submits and browsers, password managers and assistive technology know what it is. Other buttons inside it aretype="button". - A
<label for>on every field (a placeholder is an example, never the label); radios and checkboxes that belong together in a<fieldset>with a<legend>. - The right
type,autocomplete,inputmodeandenterkeyhint, so phones show the right keyboard and browsers fill what they know:
| Field | Attributes |
|---|---|
| Full name | autocomplete="name" |
type="email" autocomplete="email" |
|
| Phone | type="tel" autocomplete="tel" |
| Street address | autocomplete="address-line1" and address-line2 (or street-address on one textarea) |
| City, region | autocomplete="address-level2", address-level1 |
| Postcode | autocomplete="postal-code", plain text (many countries use letters) |
| Country | a searchable combobox (ui-part-choices) whose input has autocomplete="country-name" |
| Card number, expiry, CVC | inputmode="numeric" with autocomplete="cc-number", cc-exp, cc-csc |
| One-time code | inputmode="numeric" autocomplete="one-time-code", one field that takes a paste |
| New password (sign-up, change) | type="password" autocomplete="new-password" |
| Sign-in | autocomplete="username" (with webauthn for passkeys), then current-password |
| Search | type="search" enterkeyhint="search" |
type="number"only for quantities people step through: it acceptse, drops leading zeros and changes on scroll. Codes and card numbers are text withinputmode="numeric"; postcodes too, where every code is digits.required,minlengthandmaxlengthtell the browser and assistive technology the rules; when the library shows its own messages,noValidateon the form keeps the browser's bubbles from competing.- Inputs at 16px or larger, or iOS Safari zooms the page on focus.
2. A library and one schema
- React: react-hook-form with
zodResolver(@hookform/resolvers); TanStack Form for typed, framework-agnostic forms; Conform when forms post to server actions or React Router actions and must work without JavaScript. Vue: VeeValidate with@vee-validate/zod. SvelteKit: Superforms. Angular: typed reactive forms. - One schema per form in the feature's
api/folder, shared with the server when both are TypeScript (the server validates again anyway), its messages through i18n keys (i18n).
const InviteSchema = z.object({
email: z.email("Enter an email address, like name@example.com"),
role: z.enum(["member", "admin"], { error: "Choose a role" }),
});
type Invite = z.infer<typeof InviteSchema>;
const form = useForm<Invite>({
resolver: zodResolver(InviteSchema),
mode: "onTouched", // first check when a field is left, then on every change
defaultValues: { email: "", role: "member" },
});
3. When to validate
- A field is checked when it is left, then on every change once it shows
an error, so the error clears the moment it is fixed (react-hook-form's
mode: "onTouched"). Never on the first keystroke: "Enter a valid email" while someone is still typing it is shouting. - On submit, everything is checked and focus moves to the first invalid
field (react-hook-form's
shouldFocusError, on by default). - The submit button stays enabled while fields are invalid: a disabled button cannot say what is missing. Pressing it shows the errors.
- Checks that need the server (a username taken, a coupon valid) run when the field is left, or debounced 300 to 500ms, with "Checking" shown, and again on submit.
- Rules across fields (end after start, passwords match) report on the
field the person will change:
refinewith apathin the schema.
4. Messages that say how to fix it
- Under the field, in words, tied with
aria-describedby, witharia-invalid="true"on the input; never only a red border (frontend-accessibility). - What is wrong and what to do, in the person's terms:
| Not | Say |
|---|---|
| Invalid input | Enter a date after today |
| Required | Enter your email address |
| Invalid format | Enter a postcode, like 40115 |
| Too short | Use 12 characters or more |
| Out of range | Enter an amount between 10,000 and 5,000,000 |
- A long form also shows, after a failed submit, a summary at the top: a heading ("There is a problem"), each error as a link to its field, focus moved to the summary, and "Error: " added to the start of the page title.
const id = useId();
const error = form.formState.errors.email;
const describedBy = [`${id}-hint`, error && `${id}-error`].filter(Boolean).join(" ");
<label htmlFor={id}>Email</label>
<p id={`${id}-hint`}>We send the invitation to this address.</p>
<input id={id} type="email" autoComplete="email" aria-invalid={error ? true : undefined}
aria-describedby={describedBy} {...form.register("email")} />
{error && <p id={`${id}-error`}>{error.message}</p>}
5. Submission
- While sending, the button says what is happening ("Sending invite") with
a spinner, keeps its width, and takes no second press
(
disabled={formState.isSubmitting}), so a double click sends once. - Success is said (a
role="status"message, or the next page), then the form resets or the page moves on, focus on the message or the new heading. Failure keeps everything typed; the dialog or page stays open. - A
422with field errors marks those fields; anything else shows at the top of the form with what to do (frontend-errors):
async function onSubmit(values: Invite) {
try {
await sendInvite.mutateAsync(values);
form.reset();
} catch (error) {
if (error instanceof ApiError && error.status === 422) {
Object.entries(error.fields).forEach(([field, code], i) =>
form.setError(field as keyof Invite, { type: "server", message: t(`errors.${code}`) },
{ shouldFocus: i === 0 }));
return;
}
form.setError("root.server", { message: describeError(error).message });
}
}
- Payments and orders carry an idempotency key, so a retry after a timeout
does not charge twice (
frontend-data). - Progressive enhancement where the stack offers it: server actions
(
<form action={action}>with React 19'suseActionState), React Router's<Form>, SvelteKit form actions withuse:enhance. The form posts before the JavaScript loads; Conform or Superforms keep one schema for both sides.
6. Unsaved changes
- Warn on leaving only when the form is dirty and not being submitted
(
formState.isDirty); never on a form that autosaves. - Reload, closing the tab, typing a URL:
beforeunload, which shows the browser's own text:
useEffect(() => {
if (!isDirty) return;
const warn = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = true; // older Chromium needs it
};
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [isDirty]);
- Navigation inside the app: the router's blocker (React Router's
useBlocker, TanStack Router'suseBlocker, Vue Router'sonBeforeRouteLeave, SvelteKit'sbeforeNavigatewithcancel()), with a dialog naming the loss: "Leave without saving? Your changes to this invoice will be lost.", "Keep editing" as the default, and "Leave". The Next.js App Router has no full blocker (<Link onNavigate>cancels link clicks, not Back): save a draft there instead.
7. Autosave
- For long edits (a document, a profile, settings with many fields), not for actions with consequences: sending, paying and publishing keep an explicit button.
- Save 1 to 2s after the last change, when a field is left, and when the
page is hidden (
visibilitychange, withfetch(url, { keepalive: true })), one save in flight at a time; changes made meanwhile go in the next. - Status in a
role="status"region near the title: "Saving", "Saved at 14:02", "Not saved: you are offline" with what happens to the changes, "Could not save. Retry". - Send the version the edit started from (
If-Matchor aversionfield); on a409, show that it changed elsewhere and let the person compare, never overwrite silently (backend-api).
8. Multi-step forms
- Only when the task is long or later steps depend on earlier answers: three to five steps, each with a name.
- One URL per step (
/checkout/shipping) or one state per step; Back, the browser's or the button, returns with the answers kept: one form instance across steps, or a draft insessionStorage(never card numbers or passwords). - Validate the step before moving on (react-hook-form's
trigger(stepFields)), focusing the first error. - Show the place: "Step 2 of 4: Shipping", the step names, done steps clickable.
- A review step before the one final submit, each section with a "Change" link that returns to the review; the confirmation gives a reference and what happens next.
9. Particular inputs
- Names: one "Full name" field (and "What should we call you?" when needed). Any script, apostrophes, hyphens, spaces and one-letter names are valid: no "letters only" rule, no forced capitals.
- Email: trimmed, lightly checked; the confirmation email is the test.
- Phone: a country code defaulting to the user's country, parsed and
validated with
libphonenumber-js, stored as E.164 (+6281234567890), shown in the national format. - Addresses: country first, and the fields follow it (not every country has states or postcodes); an address lookup as a shortcut, never the only way in.
- Dates:
ui-part-dates. A date of birth as three fields (day, month, year:bday-day,bday-month,bday-year); send ISOYYYY-MM-DD. - Money: text with
inputmode="decimal"and the currency beside it, parsed by locale (1.500.000in Indonesian,1,500,000in English), sent as an integer in the smallest unit with its currency. - Passwords: a show and hide button (
aria-pressed, "Show password"), paste allowed, the rules shown before typing, at least 64 characters accepted (backend-auth). Do not fight password managers withautocomplete="off". - Passkeys:
autocomplete="username webauthn"on the sign-in username offers saved passkeys in the autofill list. - Choices: radios for up to about 7 options, a select or combobox beyond; consent as one checkbox, unticked.
- Files: a real button that opens the chooser, types and size limit
stated before choosing (
ui-part-uploads); client checks give quick feedback, the server decides (backend-files).
10. Security
The server validates everything: client validation is a convenience
anyone can skip. Prices, roles and owner ids never come from a form field,
hidden or not. Forms that change state are protected against CSRF
(frontend-security); sign-in, sign-up and reset answer a 429 with
"Too many attempts. Try again in 30 seconds." (backend-security).
Check it
- Keyboard only: Tab reaches every field in order, Enter submits, focus lands on the first error, the summary's links jump to their fields.
- A screen reader reads each label, hint and error when the field is
focused (
frontend-accessibility). - Autofill: the browser fills name, email, address and card; a password manager offers to save on sign-up and fills on sign-in.
- On a phone at 360px: the right keyboard for each field, no zoom on focus.
- Throttled network, double click on submit: one request (the Network panel, or a counter in the MSW handler); the pending state shows.
- MSW answers 422 with field errors, then 500: fields marked, then a message at the form; nothing typed is lost.
- A dirty form warns on reload; a clean one does not.
- Tests fill, submit and assert the errors and the success
(
frontend-testing);previewfinds no unnamed control.
Avoid
Placeholders as labels; type="number" for codes; errors on the first
keystroke; a disabled submit button that never says why; "Invalid input";
errors shown only in red; a form that sends twice or forgets what was
typed; server field errors shown as one generic toast; warnings on clean
forms; autosave for payments; client validation trusted by the server; a
price or role taken from a hidden field; autocomplete="off" on
credentials.
04 同仓库更多 Skills
demo-skill
Project-scope demo skill for tests.
writing
Writing words people read: the specific thing instead of claims, words to drop, sentences, interface copy (buttons, labels, errors, empty states, confirmations), page copy, notifications and emails, alt text and link text, numbers and dates, words that translate well, documentation and voice. Read before writing a page's copy, an interface's text or a document.
ui
Designing or restyling an interface: direction, layout, type, colour, icons, components, states, responsive behaviour. Read before building a page, a screen or a set of components.
ui-themes
Light and dark themes, and colour that holds in both: tokens with a value per theme, and what each kind of content needs to stay visible (icons, SVG drawings and line art, logos, photos, screenshots, charts, code, maps, borders, shadows, focus, form controls), switching without a flash, and checking both. Read before building a second theme or a theme toggle, and before any drawing, logo or chart on a page that has two themes.
ui-structure
Structure before styling: the blueprint written before any interface code (the screen's job, the regions its kind of page needs, their order and why, the layout skeleton in numbers, the components and their states), the anatomy and order of a homepage, a dashboard, a list, a detail page, settings, a form, sign-in, pricing, docs, an article and a checkout, and how to turn the blueprint into code (tokens, layout primitives, components, sections, page). Read first, before building or rebuilding any page or screen.
ui-stack-vue
Building interfaces in Vue 3 and Nuxt: project layout, script setup with typed defineProps, defineEmits and defineModel, attribute fallthrough, slots and scoped slots, template refs, ref, reactive and computed, watch versus watchEffect, composables and VueUse, provide and inject, Pinia, keyed v-for, transitions, Nuxt 4 (app directory, pages, layouts, useFetch and useAsyncData keys, $fetch in handlers, server routes, runtime config, useSeoMeta, NuxtImg, middleware), VeeValidate forms, Reka UI and Nuxt UI, testing, vue-tsc, and the traps. Read when the project uses Vue or Nuxt.