[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6D1YDM758MM2VYNBCB53YGP":3,"skill-same:01M6D1YDM758MM2VYNBCB53YGP":71},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":43,"linkOnly":44,"markdownContent":45,"markdownBody":46,"frontMatter":47,"refName":48,"commitSha":49,"skillDirectory":50,"forksCount":51,"repositoryHtmlUrl":52,"repositoryHomepageUrl":53,"ownerHtmlUrl":54,"ownerBlogUrl":53,"ownerDescription":55,"updatedAt":56,"install":57,"resources":70},"01M6D1YDM758MM2VYNBCB53YGP","frontend-forms",null,"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`.","enowdev\u002Fenowxcli","enowdev","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F179420349?v=4","01M6D1MVX2KC0X3Z9NESE7P22K","Enow Dev.","01M6D1MW1FMZGBDKWAJY4GQX81",67,"Apache-2.0","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Ffrontend-forms\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000009","前端开发","SCENARIO","使用场景",[24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42],"frontend forms","forms","frontend","how","logic","look","say","schema","fix","read","before building","first","messages","error messages","server","ui","file","library","validate","2026-11-10 10:12:30",false,"---\nname: frontend-forms\ndescription: \"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`.\"\n---\n\n# Form behaviour\n\nThe generated form is a stack of `div`s with placeholders for labels,\n`type=\"number\"` for a card number, red borders from the first keystroke,\n\"Invalid input\" under every field, a submit button that sends twice on a\ndouble click, and a server error that wipes everything typed. People fill\nforms to get something done; this is how a form helps them finish. The\nlook is `ui-part-forms`, the page `ui-page-form`, the whole frontend\n`frontend`.\n\n## 1. Native first\n\n- A real `\u003Cform>` with a `\u003Cbutton type=\"submit\">`, so Enter submits and\n  browsers, password managers and assistive technology know what it is.\n  Other buttons inside it are `type=\"button\"`.\n- A `\u003Clabel for>` on every field (a placeholder is an example, never the\n  label); radios and checkboxes that belong together in a `\u003Cfieldset>`\n  with a `\u003Clegend>`.\n- The right `type`, `autocomplete`, `inputmode` and `enterkeyhint`, so\n  phones show the right keyboard and browsers fill what they know:\n\n| Field | Attributes |\n|---|---|\n| Full name | `autocomplete=\"name\"` |\n| Email | `type=\"email\" autocomplete=\"email\"` |\n| Phone | `type=\"tel\" autocomplete=\"tel\"` |\n| Street address | `autocomplete=\"address-line1\"` and `address-line2` (or `street-address` on one textarea) |\n| City, region | `autocomplete=\"address-level2\"`, `address-level1` |\n| Postcode | `autocomplete=\"postal-code\"`, plain text (many countries use letters) |\n| Country | a searchable combobox (`ui-part-choices`) whose input has `autocomplete=\"country-name\"` |\n| Card number, expiry, CVC | `inputmode=\"numeric\"` with `autocomplete=\"cc-number\"`, `cc-exp`, `cc-csc` |\n| One-time code | `inputmode=\"numeric\" autocomplete=\"one-time-code\"`, one field that takes a paste |\n| New password (sign-up, change) | `type=\"password\" autocomplete=\"new-password\"` |\n| Sign-in | `autocomplete=\"username\"` (with `webauthn` for passkeys), then `current-password` |\n| Search | `type=\"search\" enterkeyhint=\"search\"` |\n\n- `type=\"number\"` only for quantities people step through: it accepts `e`,\n  drops leading zeros and changes on scroll. Codes and card numbers are\n  text with `inputmode=\"numeric\"`; postcodes too, where every code is digits.\n- `required`, `minlength` and `maxlength` tell the browser and assistive\n  technology the rules; when the library shows its own messages,\n  `noValidate` on the form keeps the browser's bubbles from competing.\n- Inputs at 16px or larger, or iOS Safari zooms the page on focus.\n\n## 2. A library and one schema\n\n- React: react-hook-form with `zodResolver` (`@hookform\u002Fresolvers`);\n  TanStack Form for typed, framework-agnostic forms; Conform when forms\n  post to server actions or React Router actions and must work without\n  JavaScript. Vue: VeeValidate with `@vee-validate\u002Fzod`. SvelteKit:\n  Superforms. Angular: typed reactive forms.\n- One schema per form in the feature's `api\u002F` folder, shared with the\n  server when both are TypeScript (the server validates again anyway), its\n  messages through i18n keys (`i18n`).\n\n```tsx\nconst InviteSchema = z.object({\n  email: z.email(\"Enter an email address, like name@example.com\"),\n  role: z.enum([\"member\", \"admin\"], { error: \"Choose a role\" }),\n});\ntype Invite = z.infer\u003Ctypeof InviteSchema>;\n\nconst form = useForm\u003CInvite>({\n  resolver: zodResolver(InviteSchema),\n  mode: \"onTouched\", \u002F\u002F first check when a field is left, then on every change\n  defaultValues: { email: \"\", role: \"member\" },\n});\n```\n\n## 3. When to validate\n\n- A field is checked when it is left, then on every change once it shows\n  an error, so the error clears the moment it is fixed (react-hook-form's\n  `mode: \"onTouched\"`). Never on the first keystroke: \"Enter a valid\n  email\" while someone is still typing it is shouting.\n- On submit, everything is checked and focus moves to the first invalid\n  field (react-hook-form's `shouldFocusError`, on by default).\n- The submit button stays enabled while fields are invalid: a disabled\n  button cannot say what is missing. Pressing it shows the errors.\n- Checks that need the server (a username taken, a coupon valid) run when\n  the field is left, or debounced 300 to 500ms, with \"Checking\" shown, and\n  again on submit.\n- Rules across fields (end after start, passwords match) report on the\n  field the person will change: `refine` with a `path` in the schema.\n\n## 4. Messages that say how to fix it\n\n- Under the field, in words, tied with `aria-describedby`, with\n  `aria-invalid=\"true\"` on the input; never only a red border\n  (`frontend-accessibility`).\n- What is wrong and what to do, in the person's terms:\n\n| Not | Say |\n|---|---|\n| Invalid input | Enter a date after today |\n| Required | Enter your email address |\n| Invalid format | Enter a postcode, like 40115 |\n| Too short | Use 12 characters or more |\n| Out of range | Enter an amount between 10,000 and 5,000,000 |\n\n- A long form also shows, after a failed submit, a summary at the top: a\n  heading (\"There is a problem\"), each error as a link to its field, focus\n  moved to the summary, and \"Error: \" added to the start of the page title.\n\n```tsx\nconst id = useId();\nconst error = form.formState.errors.email;\nconst describedBy = [`${id}-hint`, error && `${id}-error`].filter(Boolean).join(\" \");\n\n\u003Clabel htmlFor={id}>Email\u003C\u002Flabel>\n\u003Cp id={`${id}-hint`}>We send the invitation to this address.\u003C\u002Fp>\n\u003Cinput id={id} type=\"email\" autoComplete=\"email\" aria-invalid={error ? true : undefined}\n  aria-describedby={describedBy} {...form.register(\"email\")} \u002F>\n{error && \u003Cp id={`${id}-error`}>{error.message}\u003C\u002Fp>}\n```\n\n## 5. Submission\n\n- While sending, the button says what is happening (\"Sending invite\") with\n  a spinner, keeps its width, and takes no second press\n  (`disabled={formState.isSubmitting}`), so a double click sends once.\n- Success is said (a `role=\"status\"` message, or the next page), then the\n  form resets or the page moves on, focus on the message or the new\n  heading. Failure keeps everything typed; the dialog or page stays open.\n- A `422` with field errors marks those fields; anything else shows at the\n  top of the form with what to do (`frontend-errors`):\n\n```ts\nasync function onSubmit(values: Invite) {\n  try {\n    await sendInvite.mutateAsync(values);\n    form.reset();\n  } catch (error) {\n    if (error instanceof ApiError && error.status === 422) {\n      Object.entries(error.fields).forEach(([field, code], i) =>\n        form.setError(field as keyof Invite, { type: \"server\", message: t(`errors.${code}`) },\n          { shouldFocus: i === 0 }));\n      return;\n    }\n    form.setError(\"root.server\", { message: describeError(error).message });\n  }\n}\n```\n\n- Payments and orders carry an idempotency key, so a retry after a timeout\n  does not charge twice (`frontend-data`).\n- Progressive enhancement where the stack offers it: server actions\n  (`\u003Cform action={action}>` with React 19's `useActionState`), React\n  Router's `\u003CForm>`, SvelteKit form actions with `use:enhance`. The form\n  posts before the JavaScript loads; Conform or Superforms keep one schema\n  for both sides.\n\n## 6. Unsaved changes\n\n- Warn on leaving only when the form is dirty and not being submitted\n  (`formState.isDirty`); never on a form that autosaves.\n- Reload, closing the tab, typing a URL: `beforeunload`, which shows the\n  browser's own text:\n\n```ts\nuseEffect(() => {\n  if (!isDirty) return;\n  const warn = (event: BeforeUnloadEvent) => {\n    event.preventDefault();\n    event.returnValue = true; \u002F\u002F older Chromium needs it\n  };\n  window.addEventListener(\"beforeunload\", warn);\n  return () => window.removeEventListener(\"beforeunload\", warn);\n}, [isDirty]);\n```\n\n- Navigation inside the app: the router's blocker (React Router's\n  `useBlocker`, TanStack Router's `useBlocker`, Vue Router's\n  `onBeforeRouteLeave`, SvelteKit's `beforeNavigate` with `cancel()`),\n  with a dialog naming the loss: \"Leave without saving? Your changes to\n  this invoice will be lost.\", \"Keep editing\" as the default, and\n  \"Leave\". The Next.js App Router has no full blocker (`\u003CLink onNavigate>`\n  cancels link clicks, not Back): save a draft there instead.\n\n## 7. Autosave\n\n- For long edits (a document, a profile, settings with many fields), not\n  for actions with consequences: sending, paying and publishing keep an\n  explicit button.\n- Save 1 to 2s after the last change, when a field is left, and when the\n  page is hidden (`visibilitychange`, with `fetch(url, { keepalive: true })`),\n  one save in flight at a time; changes made meanwhile go in the next.\n- Status in a `role=\"status\"` region near the title: \"Saving\", \"Saved at\n  14:02\", \"Not saved: you are offline\" with what happens to the changes,\n  \"Could not save. Retry\".\n- Send the version the edit started from (`If-Match` or a `version`\n  field); on a `409`, show that it changed elsewhere and let the person\n  compare, never overwrite silently (`backend-api`).\n\n## 8. Multi-step forms\n\n- Only when the task is long or later steps depend on earlier answers:\n  three to five steps, each with a name.\n- One URL per step (`\u002Fcheckout\u002Fshipping`) or one state per step; Back, the\n  browser's or the button, returns with the answers kept: one form\n  instance across steps, or a draft in `sessionStorage` (never card numbers\n  or passwords).\n- Validate the step before moving on (react-hook-form's\n  `trigger(stepFields)`), focusing the first error.\n- Show the place: \"Step 2 of 4: Shipping\", the step names, done steps\n  clickable.\n- A review step before the one final submit, each section with a \"Change\"\n  link that returns to the review; the confirmation gives a reference and\n  what happens next.\n\n## 9. Particular inputs\n\n- **Names**: one \"Full name\" field (and \"What should we call you?\" when\n  needed). Any script, apostrophes, hyphens, spaces and one-letter names are\n  valid: no \"letters only\" rule, no forced capitals.\n- **Email**: trimmed, lightly checked; the confirmation email is the test.\n- **Phone**: a country code defaulting to the user's country, parsed and\n  validated with `libphonenumber-js`, stored as E.164 (`+6281234567890`),\n  shown in the national format.\n- **Addresses**: country first, and the fields follow it (not every\n  country has states or postcodes); an address lookup as a shortcut, never\n  the only way in.\n- **Dates**: `ui-part-dates`. A date of birth as three fields (day, month,\n  year: `bday-day`, `bday-month`, `bday-year`); send ISO `YYYY-MM-DD`.\n- **Money**: text with `inputmode=\"decimal\"` and the currency beside it,\n  parsed by locale (`1.500.000` in Indonesian, `1,500,000` in English),\n  sent as an integer in the smallest unit with its currency.\n- **Passwords**: a show and hide button (`aria-pressed`, \"Show password\"),\n  paste allowed, the rules shown before typing, at least 64 characters\n  accepted (`backend-auth`). Do not fight password managers with\n  `autocomplete=\"off\"`.\n- **Passkeys**: `autocomplete=\"username webauthn\"` on the sign-in username\n  offers saved passkeys in the autofill list.\n- **Choices**: radios for up to about 7 options, a select or combobox\n  beyond; consent as one checkbox, unticked.\n- **Files**: a real button that opens the chooser, types and size limit\n  stated before choosing (`ui-part-uploads`); client checks give quick\n  feedback, the server decides (`backend-files`).\n\n## 10. Security\n\nThe server validates everything: client validation is a convenience\nanyone can skip. Prices, roles and owner ids never come from a form field,\nhidden or not. Forms that change state are protected against CSRF\n(`frontend-security`); sign-in, sign-up and reset answer a `429` with\n\"Too many attempts. Try again in 30 seconds.\" (`backend-security`).\n\n## Check it\n\n- Keyboard only: Tab reaches every field in order, Enter submits, focus\n  lands on the first error, the summary's links jump to their fields.\n- A screen reader reads each label, hint and error when the field is\n  focused (`frontend-accessibility`).\n- Autofill: the browser fills name, email, address and card; a password\n  manager offers to save on sign-up and fills on sign-in.\n- On a phone at 360px: the right keyboard for each field, no zoom on focus.\n- Throttled network, double click on submit: one request (the Network\n  panel, or a counter in the MSW handler); the pending state shows.\n- MSW answers 422 with field errors, then 500: fields marked, then a\n  message at the form; nothing typed is lost.\n- A dirty form warns on reload; a clean one does not.\n- Tests fill, submit and assert the errors and the success\n  (`frontend-testing`); `preview` finds no unnamed control.\n\n## Avoid\n\nPlaceholders as labels; `type=\"number\"` for codes; errors on the first\nkeystroke; a disabled submit button that never says why; \"Invalid input\";\nerrors shown only in red; a form that sends twice or forgets what was\ntyped; server field errors shown as one generic toast; warnings on clean\nforms; autosave for payments; client validation trusted by the server; a\nprice or role taken from a hidden field; `autocomplete=\"off\"` on\ncredentials.\n","# Form behaviour\n\nThe generated form is a stack of `div`s with placeholders for labels,\n`type=\"number\"` for a card number, red borders from the first keystroke,\n\"Invalid input\" under every field, a submit button that sends twice on a\ndouble click, and a server error that wipes everything typed. People fill\nforms to get something done; this is how a form helps them finish. The\nlook is `ui-part-forms`, the page `ui-page-form`, the whole frontend\n`frontend`.\n\n## 1. Native first\n\n- A real `\u003Cform>` with a `\u003Cbutton type=\"submit\">`, so Enter submits and\n  browsers, password managers and assistive technology know what it is.\n  Other buttons inside it are `type=\"button\"`.\n- A `\u003Clabel for>` on every field (a placeholder is an example, never the\n  label); radios and checkboxes that belong together in a `\u003Cfieldset>`\n  with a `\u003Clegend>`.\n- The right `type`, `autocomplete`, `inputmode` and `enterkeyhint`, so\n  phones show the right keyboard and browsers fill what they know:\n\n| Field | Attributes |\n|---|---|\n| Full name | `autocomplete=\"name\"` |\n| Email | `type=\"email\" autocomplete=\"email\"` |\n| Phone | `type=\"tel\" autocomplete=\"tel\"` |\n| Street address | `autocomplete=\"address-line1\"` and `address-line2` (or `street-address` on one textarea) |\n| City, region | `autocomplete=\"address-level2\"`, `address-level1` |\n| Postcode | `autocomplete=\"postal-code\"`, plain text (many countries use letters) |\n| Country | a searchable combobox (`ui-part-choices`) whose input has `autocomplete=\"country-name\"` |\n| Card number, expiry, CVC | `inputmode=\"numeric\"` with `autocomplete=\"cc-number\"`, `cc-exp`, `cc-csc` |\n| One-time code | `inputmode=\"numeric\" autocomplete=\"one-time-code\"`, one field that takes a paste |\n| New password (sign-up, change) | `type=\"password\" autocomplete=\"new-password\"` |\n| Sign-in | `autocomplete=\"username\"` (with `webauthn` for passkeys), then `current-password` |\n| Search | `type=\"search\" enterkeyhint=\"search\"` |\n\n- `type=\"number\"` only for quantities people step through: it accepts `e`,\n  drops leading zeros and changes on scroll. Codes and card numbers are\n  text with `inputmode=\"numeric\"`; postcodes too, where every code is digits.\n- `required`, `minlength` and `maxlength` tell the browser and assistive\n  technology the rules; when the library shows its own messages,\n  `noValidate` on the form keeps the browser's bubbles from competing.\n- Inputs at 16px or larger, or iOS Safari zooms the page on focus.\n\n## 2. A library and one schema\n\n- React: react-hook-form with `zodResolver` (`@hookform\u002Fresolvers`);\n  TanStack Form for typed, framework-agnostic forms; Conform when forms\n  post to server actions or React Router actions and must work without\n  JavaScript. Vue: VeeValidate with `@vee-validate\u002Fzod`. SvelteKit:\n  Superforms. Angular: typed reactive forms.\n- One schema per form in the feature's `api\u002F` folder, shared with the\n  server when both are TypeScript (the server validates again anyway), its\n  messages through i18n keys (`i18n`).\n\n```tsx\nconst InviteSchema = z.object({\n  email: z.email(\"Enter an email address, like name@example.com\"),\n  role: z.enum([\"member\", \"admin\"], { error: \"Choose a role\" }),\n});\ntype Invite = z.infer\u003Ctypeof InviteSchema>;\n\nconst form = useForm\u003CInvite>({\n  resolver: zodResolver(InviteSchema),\n  mode: \"onTouched\", \u002F\u002F first check when a field is left, then on every change\n  defaultValues: { email: \"\", role: \"member\" },\n});\n```\n\n## 3. When to validate\n\n- A field is checked when it is left, then on every change once it shows\n  an error, so the error clears the moment it is fixed (react-hook-form's\n  `mode: \"onTouched\"`). Never on the first keystroke: \"Enter a valid\n  email\" while someone is still typing it is shouting.\n- On submit, everything is checked and focus moves to the first invalid\n  field (react-hook-form's `shouldFocusError`, on by default).\n- The submit button stays enabled while fields are invalid: a disabled\n  button cannot say what is missing. Pressing it shows the errors.\n- Checks that need the server (a username taken, a coupon valid) run when\n  the field is left, or debounced 300 to 500ms, with \"Checking\" shown, and\n  again on submit.\n- Rules across fields (end after start, passwords match) report on the\n  field the person will change: `refine` with a `path` in the schema.\n\n## 4. Messages that say how to fix it\n\n- Under the field, in words, tied with `aria-describedby`, with\n  `aria-invalid=\"true\"` on the input; never only a red border\n  (`frontend-accessibility`).\n- What is wrong and what to do, in the person's terms:\n\n| Not | Say |\n|---|---|\n| Invalid input | Enter a date after today |\n| Required | Enter your email address |\n| Invalid format | Enter a postcode, like 40115 |\n| Too short | Use 12 characters or more |\n| Out of range | Enter an amount between 10,000 and 5,000,000 |\n\n- A long form also shows, after a failed submit, a summary at the top: a\n  heading (\"There is a problem\"), each error as a link to its field, focus\n  moved to the summary, and \"Error: \" added to the start of the page title.\n\n```tsx\nconst id = useId();\nconst error = form.formState.errors.email;\nconst describedBy = [`${id}-hint`, error && `${id}-error`].filter(Boolean).join(\" \");\n\n\u003Clabel htmlFor={id}>Email\u003C\u002Flabel>\n\u003Cp id={`${id}-hint`}>We send the invitation to this address.\u003C\u002Fp>\n\u003Cinput id={id} type=\"email\" autoComplete=\"email\" aria-invalid={error ? true : undefined}\n  aria-describedby={describedBy} {...form.register(\"email\")} \u002F>\n{error && \u003Cp id={`${id}-error`}>{error.message}\u003C\u002Fp>}\n```\n\n## 5. Submission\n\n- While sending, the button says what is happening (\"Sending invite\") with\n  a spinner, keeps its width, and takes no second press\n  (`disabled={formState.isSubmitting}`), so a double click sends once.\n- Success is said (a `role=\"status\"` message, or the next page), then the\n  form resets or the page moves on, focus on the message or the new\n  heading. Failure keeps everything typed; the dialog or page stays open.\n- A `422` with field errors marks those fields; anything else shows at the\n  top of the form with what to do (`frontend-errors`):\n\n```ts\nasync function onSubmit(values: Invite) {\n  try {\n    await sendInvite.mutateAsync(values);\n    form.reset();\n  } catch (error) {\n    if (error instanceof ApiError && error.status === 422) {\n      Object.entries(error.fields).forEach(([field, code], i) =>\n        form.setError(field as keyof Invite, { type: \"server\", message: t(`errors.${code}`) },\n          { shouldFocus: i === 0 }));\n      return;\n    }\n    form.setError(\"root.server\", { message: describeError(error).message });\n  }\n}\n```\n\n- Payments and orders carry an idempotency key, so a retry after a timeout\n  does not charge twice (`frontend-data`).\n- Progressive enhancement where the stack offers it: server actions\n  (`\u003Cform action={action}>` with React 19's `useActionState`), React\n  Router's `\u003CForm>`, SvelteKit form actions with `use:enhance`. The form\n  posts before the JavaScript loads; Conform or Superforms keep one schema\n  for both sides.\n\n## 6. Unsaved changes\n\n- Warn on leaving only when the form is dirty and not being submitted\n  (`formState.isDirty`); never on a form that autosaves.\n- Reload, closing the tab, typing a URL: `beforeunload`, which shows the\n  browser's own text:\n\n```ts\nuseEffect(() => {\n  if (!isDirty) return;\n  const warn = (event: BeforeUnloadEvent) => {\n    event.preventDefault();\n    event.returnValue = true; \u002F\u002F older Chromium needs it\n  };\n  window.addEventListener(\"beforeunload\", warn);\n  return () => window.removeEventListener(\"beforeunload\", warn);\n}, [isDirty]);\n```\n\n- Navigation inside the app: the router's blocker (React Router's\n  `useBlocker`, TanStack Router's `useBlocker`, Vue Router's\n  `onBeforeRouteLeave`, SvelteKit's `beforeNavigate` with `cancel()`),\n  with a dialog naming the loss: \"Leave without saving? Your changes to\n  this invoice will be lost.\", \"Keep editing\" as the default, and\n  \"Leave\". The Next.js App Router has no full blocker (`\u003CLink onNavigate>`\n  cancels link clicks, not Back): save a draft there instead.\n\n## 7. Autosave\n\n- For long edits (a document, a profile, settings with many fields), not\n  for actions with consequences: sending, paying and publishing keep an\n  explicit button.\n- Save 1 to 2s after the last change, when a field is left, and when the\n  page is hidden (`visibilitychange`, with `fetch(url, { keepalive: true })`),\n  one save in flight at a time; changes made meanwhile go in the next.\n- Status in a `role=\"status\"` region near the title: \"Saving\", \"Saved at\n  14:02\", \"Not saved: you are offline\" with what happens to the changes,\n  \"Could not save. Retry\".\n- Send the version the edit started from (`If-Match` or a `version`\n  field); on a `409`, show that it changed elsewhere and let the person\n  compare, never overwrite silently (`backend-api`).\n\n## 8. Multi-step forms\n\n- Only when the task is long or later steps depend on earlier answers:\n  three to five steps, each with a name.\n- One URL per step (`\u002Fcheckout\u002Fshipping`) or one state per step; Back, the\n  browser's or the button, returns with the answers kept: one form\n  instance across steps, or a draft in `sessionStorage` (never card numbers\n  or passwords).\n- Validate the step before moving on (react-hook-form's\n  `trigger(stepFields)`), focusing the first error.\n- Show the place: \"Step 2 of 4: Shipping\", the step names, done steps\n  clickable.\n- A review step before the one final submit, each section with a \"Change\"\n  link that returns to the review; the confirmation gives a reference and\n  what happens next.\n\n## 9. Particular inputs\n\n- **Names**: one \"Full name\" field (and \"What should we call you?\" when\n  needed). Any script, apostrophes, hyphens, spaces and one-letter names are\n  valid: no \"letters only\" rule, no forced capitals.\n- **Email**: trimmed, lightly checked; the confirmation email is the test.\n- **Phone**: a country code defaulting to the user's country, parsed and\n  validated with `libphonenumber-js`, stored as E.164 (`+6281234567890`),\n  shown in the national format.\n- **Addresses**: country first, and the fields follow it (not every\n  country has states or postcodes); an address lookup as a shortcut, never\n  the only way in.\n- **Dates**: `ui-part-dates`. A date of birth as three fields (day, month,\n  year: `bday-day`, `bday-month`, `bday-year`); send ISO `YYYY-MM-DD`.\n- **Money**: text with `inputmode=\"decimal\"` and the currency beside it,\n  parsed by locale (`1.500.000` in Indonesian, `1,500,000` in English),\n  sent as an integer in the smallest unit with its currency.\n- **Passwords**: a show and hide button (`aria-pressed`, \"Show password\"),\n  paste allowed, the rules shown before typing, at least 64 characters\n  accepted (`backend-auth`). Do not fight password managers with\n  `autocomplete=\"off\"`.\n- **Passkeys**: `autocomplete=\"username webauthn\"` on the sign-in username\n  offers saved passkeys in the autofill list.\n- **Choices**: radios for up to about 7 options, a select or combobox\n  beyond; consent as one checkbox, unticked.\n- **Files**: a real button that opens the chooser, types and size limit\n  stated before choosing (`ui-part-uploads`); client checks give quick\n  feedback, the server decides (`backend-files`).\n\n## 10. Security\n\nThe server validates everything: client validation is a convenience\nanyone can skip. Prices, roles and owner ids never come from a form field,\nhidden or not. Forms that change state are protected against CSRF\n(`frontend-security`); sign-in, sign-up and reset answer a `429` with\n\"Too many attempts. Try again in 30 seconds.\" (`backend-security`).\n\n## Check it\n\n- Keyboard only: Tab reaches every field in order, Enter submits, focus\n  lands on the first error, the summary's links jump to their fields.\n- A screen reader reads each label, hint and error when the field is\n  focused (`frontend-accessibility`).\n- Autofill: the browser fills name, email, address and card; a password\n  manager offers to save on sign-up and fills on sign-in.\n- On a phone at 360px: the right keyboard for each field, no zoom on focus.\n- Throttled network, double click on submit: one request (the Network\n  panel, or a counter in the MSW handler); the pending state shows.\n- MSW answers 422 with field errors, then 500: fields marked, then a\n  message at the form; nothing typed is lost.\n- A dirty form warns on reload; a clean one does not.\n- Tests fill, submit and assert the errors and the success\n  (`frontend-testing`); `preview` finds no unnamed control.\n\n## Avoid\n\nPlaceholders as labels; `type=\"number\"` for codes; errors on the first\nkeystroke; a disabled submit button that never says why; \"Invalid input\";\nerrors shown only in red; a form that sends twice or forgets what was\ntyped; server field errors shown as one generic toast; warnings on clean\nforms; autosave for payments; client validation trusted by the server; a\nprice or role taken from a hidden field; `autocomplete=\"off\"` on\ncredentials.\n",{"name":5,"description":7},"main","38293fe1b096e6bf3646c7344c38786ed688f464","crates\u002Fenowx-core\u002Fskills\u002Ffrontend-forms",21,"https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli","https:\u002F\u002Fenowx.ai","https:\u002F\u002Fgithub.com\u002Fenowdev","Pretending to be a Developer","2026-10-06 02:05:35",{"source":8,"skillName":5,"commands":58,"prompt":68,"zipAvailable":69},[59,62,65],{"tool":60,"command":61},"npx","npx skills add enowdev\u002Fenowxcli --skill frontend-forms",{"tool":63,"command":64},"bunx","bunx skills add enowdev\u002Fenowxcli --skill frontend-forms",{"tool":66,"command":67},"pnpm","pnpm dlx skills add enowdev\u002Fenowxcli --skill frontend-forms","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Ffrontend-forms\u002FSKILL.md",true,[],[72,86,111,125,152,173],{"skillNo":73,"name":74,"nameZh":6,"description":75,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":76,"categories":77,"keywords":81,"publishedAt":43},"01M6D2HQYSQD9N7MTNDT4A7CBM","demo-skill","Project-scope demo skill for tests.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fworkspace\u002F.agents\u002Fskills\u002Fdemo-skill\u002FSKILL.md",[78],{"categoryNo":79,"categoryName":80,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发",[82,83,84,85,33],"demo skill","demo","tests","project",{"skillNo":87,"name":88,"nameZh":6,"description":89,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":90,"categories":91,"keywords":100,"publishedAt":43},"01M6D2HM0GCPM0TM2HPG9HHR2R","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.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fwriting\u002FSKILL.md",[92,95],{"categoryNo":93,"categoryName":94,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000018","写作与文案",{"categoryNo":96,"categoryName":97,"taxonomyType":98,"taxonomyTypeDesc":99},"01JBSEEDCAT000000000000036","内容创作者","PROFESSION","适用职业",[88,101,102,103,104,105,33,106,107,108,109,110],"text","emails","empty","drop","document","page","voice","notifications","documentation","translate",{"skillNo":112,"name":39,"nameZh":6,"description":113,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":114,"categories":115,"keywords":119,"publishedAt":43},"01M6D2HG07YS49NM196Q0R3AZ8","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.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fui\u002FSKILL.md",[116],{"categoryNo":117,"categoryName":118,"taxonomyType":98,"taxonomyTypeDesc":99},"01JBSEEDCAT000000000000032","设计师",[39,120,121,122,123,106,124,33,34],"icons","components","SET","responsive","layout",{"skillNo":126,"name":127,"nameZh":6,"description":128,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":129,"categories":130,"keywords":137,"publishedAt":43},"01M6D2HB59HZHWG1RYZDV4SJ91","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.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fui-themes\u002FSKILL.md",[131,134],{"categoryNo":132,"categoryName":133,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000015","视觉设计",{"categoryNo":135,"categoryName":136,"taxonomyType":98,"taxonomyTypeDesc":99},"01JBSEEDCAT000000000000030","开发工程师",[39,138,139,140,141,142,143,144,145,33,34,146,106,147,148,149,120,150,151],"ui themes","switching","photos","tokens","drawing","focus","maps","Art","flash","code","Charts","content","screenshots","logo",{"skillNo":153,"name":154,"nameZh":6,"description":155,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":156,"categories":157,"keywords":161,"publishedAt":43},"01M6D2H5R5KRAE524C8QX0T7Q0","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.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fui-structure\u002FSKILL.md",[158,159,160],{"categoryNo":93,"categoryName":94,"taxonomyType":21,"taxonomyTypeDesc":22},{"categoryNo":135,"categoryName":136,"taxonomyType":98,"taxonomyTypeDesc":99},{"categoryNo":96,"categoryName":97,"taxonomyType":98,"taxonomyTypeDesc":99},[162,39,163,121,164,165,147,166,167,168,124,141,169,170,171,33,34,106,172,27],"structure","ui structure","pricing","dashboard","Blueprint","article","anatomy","styling","primitives","list","why",{"skillNo":174,"name":175,"nameZh":6,"description":176,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":177,"categories":178,"keywords":184,"publishedAt":43},"01M6D2H0ZZBJG77MBGPZHA6MWD","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.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fui-stack-vue\u002FSKILL.md",[179,182,183],{"categoryNo":180,"categoryName":181,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000008","测试与质量",{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},{"categoryNo":117,"categoryName":118,"taxonomyType":98,"taxonomyTypeDesc":99},[39,185,186,187,25,188,189,190,124,85,191,192,33,193,194,195,38,196,197,198],"vue","stack","ui stack vue","pages","watch","runtime","transitions","fetch","pinia","nuxt","template","config","directory","testing"]