From 89a06b460b2d2973a7b892d6dd1179b282301523 Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Wed, 21 Oct 2026 18:50:03 +0100 Subject: [PATCH 1/2] fix(template): clear errors on edit so the submit click lands --- docs/r/nigerian-address-form.json | 2 +- .../components/nigerian-address-form.test.tsx | 13 ++++++++++ .../components/nigerian-address-form.tsx | 26 +++++++++++++++---- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/docs/r/nigerian-address-form.json b/docs/r/nigerian-address-form.json index 3649299..05a295a 100644 --- a/docs/r/nigerian-address-form.json +++ b/docs/r/nigerian-address-form.json @@ -16,7 +16,7 @@ "files": [ { "path": "components/nigerian-address-form.tsx", - "content": "\"use client\";\n\nimport { type FocusEvent, type FormEvent, useRef, useState, useSyncExternalStore, useTransition } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport { NativeSelect, NativeSelectOption } from \"@/components/ui/native-select\";\nimport {\n type Address,\n type AddressErrors,\n type AddressInput,\n addressSchema,\n checkPostcode,\n EXAMPLE,\n fieldErrors,\n STATES,\n} from \"@/lib/nigerian-address\";\n\ntype Props = {\n /** Return field errors to show them, such as a postcode the server refused. */\n onSubmit: (address: Address) => Promise | AddressErrors | void;\n postcodeRequired?: boolean;\n submitLabel?: string;\n};\n\ntype Name = keyof AddressInput;\n\nconst noop = () => () => {};\n\nexport function NigerianAddressForm({ onSubmit, postcodeRequired = false, submitLabel = \"Continue\" }: Props) {\n const form = useRef(null);\n const [errors, setErrors] = useState({});\n const [suggestion, setSuggestion] = useState();\n const [failed, setFailed] = useState(false);\n const [pending, startTransition] = useTransition();\n // Until React runs, a click would submit the address as a plain GET.\n const ready = useSyncExternalStore(noop, () => true, () => false);\n\n function show(next: AddressErrors) {\n setErrors(next);\n const first = Object.keys(next)[0];\n if (first) form.current?.querySelector(`[name=\"${first}\"]`)?.focus();\n }\n\n function checkOnBlur(event: FocusEvent) {\n const input = event.currentTarget;\n const check = input.value.trim() ? checkPostcode(input.value) : undefined;\n if (check?.ok) input.value = check.postcode;\n setSuggestion(check && !check.ok ? check.suggestion : undefined);\n setErrors(({ postcode: _, ...rest }) => (check && !check.ok ? { ...rest, postcode: check.error } : rest));\n }\n\n function applySuggestion() {\n const input = form.current?.elements.namedItem(\"postcode\");\n if (input instanceof HTMLInputElement && suggestion) {\n input.value = suggestion;\n // The button that had focus is about to go away.\n input.focus();\n }\n setSuggestion(undefined);\n setErrors(({ postcode: _, ...rest }) => rest);\n }\n\n function submit(event: FormEvent) {\n event.preventDefault();\n setFailed(false);\n const result = addressSchema({ postcodeRequired }).safeParse(\n Object.fromEntries(new FormData(event.currentTarget)),\n );\n if (!result.success) return show(fieldErrors(result.error));\n setErrors({});\n startTransition(async () => {\n try {\n const refused = await onSubmit(result.data);\n if (refused) show(refused);\n } catch {\n setFailed(true);\n }\n });\n }\n\n function field(name: Name) {\n const error = errors[name];\n return {\n id: name,\n name,\n \"aria-invalid\": error ? true : undefined,\n \"aria-describedby\": error ? `${name}-error` : undefined,\n };\n }\n\n function error(name: Name) {\n return errors[name] && {errors[name]};\n }\n\n return (\n
\n \n \n Full name\n \n {error(\"name\")}\n \n \n Phone number\n \n {error(\"phone\")}\n \n \n Street address\n \n {error(\"street\")}\n \n \n Town or city\n \n {error(\"city\")}\n \n \n Local government area (optional)\n \n {error(\"lga\")}\n \n \n State\n \n \n Choose a state\n \n {STATES.map((state) => (\n \n {state}\n \n ))}\n \n {error(\"state\")}\n \n \n Postcode{postcodeRequired ? \"\" : \" (optional)\"}\n \n \n The 11-character NIPOST postcode. Find yours at{\" \"}\n \n postcode.gov.ng\n \n .\n \n {error(\"postcode\")}\n {suggestion && (\n \n )}\n \n {failed && We could not send your address. Try again.}\n \n \n
\n );\n}\n", + "content": "\"use client\";\n\nimport { type FocusEvent, type FormEvent, useRef, useState, useSyncExternalStore, useTransition } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport { NativeSelect, NativeSelectOption } from \"@/components/ui/native-select\";\nimport {\n type Address,\n type AddressErrors,\n type AddressInput,\n addressSchema,\n checkPostcode,\n EXAMPLE,\n fieldErrors,\n STATES,\n} from \"@/lib/nigerian-address\";\n\ntype Props = {\n /** Return field errors to show them, such as a postcode the server refused. */\n onSubmit: (address: Address) => Promise | AddressErrors | void;\n postcodeRequired?: boolean;\n submitLabel?: string;\n};\n\ntype Name = keyof AddressInput;\n\nconst noop = () => () => {};\n\nexport function NigerianAddressForm({ onSubmit, postcodeRequired = false, submitLabel = \"Continue\" }: Props) {\n const form = useRef(null);\n const [errors, setErrors] = useState({});\n const [suggestion, setSuggestion] = useState();\n const [failed, setFailed] = useState(false);\n const [pending, startTransition] = useTransition();\n // Until React runs, a click would submit the address as a plain GET.\n const ready = useSyncExternalStore(noop, () => true, () => false);\n\n function show(next: AddressErrors) {\n setErrors(next);\n const first = Object.keys(next)[0];\n if (first) form.current?.querySelector(`[name=\"${first}\"]`)?.focus();\n }\n\n function checkOnBlur(event: FocusEvent) {\n const input = event.currentTarget;\n if (!input.value.trim()) return;\n const check = checkPostcode(input.value);\n if (check.ok) {\n input.value = check.postcode;\n return;\n }\n setSuggestion(check.suggestion);\n setErrors((current) => ({ ...current, postcode: check.error }));\n }\n\n // Cleared on edit, not on blur: a message vanishing as the customer clicks\n // \"Place order\" moves the button out from under the pointer.\n function clearOnEdit(event: FormEvent) {\n const { name } = event.target as HTMLInputElement;\n if (name === \"postcode\") setSuggestion(undefined);\n setErrors((current) => {\n if (!(name in current)) return current;\n const { [name as Name]: _, ...rest } = current;\n return rest;\n });\n }\n\n function applySuggestion() {\n const input = form.current?.elements.namedItem(\"postcode\");\n if (input instanceof HTMLInputElement && suggestion) {\n input.value = suggestion;\n // The button that had focus is about to go away.\n input.focus();\n }\n setSuggestion(undefined);\n setErrors(({ postcode: _, ...rest }) => rest);\n }\n\n function submit(event: FormEvent) {\n event.preventDefault();\n setFailed(false);\n const result = addressSchema({ postcodeRequired }).safeParse(\n Object.fromEntries(new FormData(event.currentTarget)),\n );\n if (!result.success) return show(fieldErrors(result.error));\n setErrors({});\n startTransition(async () => {\n try {\n const refused = await onSubmit(result.data);\n if (refused) show(refused);\n } catch {\n setFailed(true);\n }\n });\n }\n\n function field(name: Name) {\n const error = errors[name];\n return {\n id: name,\n name,\n \"aria-invalid\": error ? true : undefined,\n \"aria-describedby\": error ? `${name}-error` : undefined,\n };\n }\n\n function error(name: Name) {\n return errors[name] && {errors[name]};\n }\n\n return (\n
\n \n \n Full name\n \n {error(\"name\")}\n \n \n Phone number\n \n {error(\"phone\")}\n \n \n Street address\n \n {error(\"street\")}\n \n \n Town or city\n \n {error(\"city\")}\n \n \n Local government area (optional)\n \n {error(\"lga\")}\n \n \n State\n \n \n Choose a state\n \n {STATES.map((state) => (\n \n {state}\n \n ))}\n \n {error(\"state\")}\n \n \n Postcode{postcodeRequired ? \"\" : \" (optional)\"}\n \n \n The 11-character NIPOST postcode. Find yours at{\" \"}\n \n postcode.gov.ng\n \n .\n \n {error(\"postcode\")}\n {suggestion && (\n \n )}\n \n {failed && We could not send your address. Try again.}\n \n \n
\n );\n}\n", "type": "registry:component" }, { diff --git a/templates/nigerian-address-form/components/nigerian-address-form.test.tsx b/templates/nigerian-address-form/components/nigerian-address-form.test.tsx index 5c5e460..9625721 100644 --- a/templates/nigerian-address-form/components/nigerian-address-form.test.tsx +++ b/templates/nigerian-address-form/components/nigerian-address-form.test.tsx @@ -83,3 +83,16 @@ it("says so when sending fails", async () => { await user.click(screen.getByRole("button", { name: "Continue" })); expect((await screen.findByRole("alert")).textContent).toBe("We could not send your address. Try again."); }); + +it("clears an error once the customer edits the field", async () => { + render(); + const user = userEvent.setup(); + const input = screen.getByLabelText(/^Postcode/); + await user.type(input, "100271"); + await user.tab(); + expect(screen.getByRole("alert").textContent).toMatch(/old six-digit code/); + await user.type(input, "{Backspace}"); + expect(screen.queryByRole("alert")).toBeNull(); + await user.tab(); + expect(screen.getByRole("alert").textContent).toMatch(/A postcode has 11/); +}); diff --git a/templates/nigerian-address-form/components/nigerian-address-form.tsx b/templates/nigerian-address-form/components/nigerian-address-form.tsx index ddc5269..5669cb6 100644 --- a/templates/nigerian-address-form/components/nigerian-address-form.tsx +++ b/templates/nigerian-address-form/components/nigerian-address-form.tsx @@ -44,10 +44,26 @@ export function NigerianAddressForm({ onSubmit, postcodeRequired = false, submit function checkOnBlur(event: FocusEvent) { const input = event.currentTarget; - const check = input.value.trim() ? checkPostcode(input.value) : undefined; - if (check?.ok) input.value = check.postcode; - setSuggestion(check && !check.ok ? check.suggestion : undefined); - setErrors(({ postcode: _, ...rest }) => (check && !check.ok ? { ...rest, postcode: check.error } : rest)); + if (!input.value.trim()) return; + const check = checkPostcode(input.value); + if (check.ok) { + input.value = check.postcode; + return; + } + setSuggestion(check.suggestion); + setErrors((current) => ({ ...current, postcode: check.error })); + } + + // Cleared on edit, not on blur: a message vanishing as the customer clicks + // "Place order" moves the button out from under the pointer. + function clearOnEdit(event: FormEvent) { + const { name } = event.target as HTMLInputElement; + if (name === "postcode") setSuggestion(undefined); + setErrors((current) => { + if (!(name in current)) return current; + const { [name as Name]: _, ...rest } = current; + return rest; + }); } function applySuggestion() { @@ -94,7 +110,7 @@ export function NigerianAddressForm({ onSubmit, postcodeRequired = false, submit } return ( -
+ Full name From b0d784f0be84470404b8a9d2d80ed7b8f495f6ae Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Wed, 21 Oct 2026 18:50:06 +0100 Subject: [PATCH 2/2] fix(template): keep the example postcode on one line --- docs/r/nigerian-address-form.json | 2 +- .../components/nigerian-address-form.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/r/nigerian-address-form.json b/docs/r/nigerian-address-form.json index 05a295a..93e85eb 100644 --- a/docs/r/nigerian-address-form.json +++ b/docs/r/nigerian-address-form.json @@ -16,7 +16,7 @@ "files": [ { "path": "components/nigerian-address-form.tsx", - "content": "\"use client\";\n\nimport { type FocusEvent, type FormEvent, useRef, useState, useSyncExternalStore, useTransition } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport { NativeSelect, NativeSelectOption } from \"@/components/ui/native-select\";\nimport {\n type Address,\n type AddressErrors,\n type AddressInput,\n addressSchema,\n checkPostcode,\n EXAMPLE,\n fieldErrors,\n STATES,\n} from \"@/lib/nigerian-address\";\n\ntype Props = {\n /** Return field errors to show them, such as a postcode the server refused. */\n onSubmit: (address: Address) => Promise | AddressErrors | void;\n postcodeRequired?: boolean;\n submitLabel?: string;\n};\n\ntype Name = keyof AddressInput;\n\nconst noop = () => () => {};\n\nexport function NigerianAddressForm({ onSubmit, postcodeRequired = false, submitLabel = \"Continue\" }: Props) {\n const form = useRef(null);\n const [errors, setErrors] = useState({});\n const [suggestion, setSuggestion] = useState();\n const [failed, setFailed] = useState(false);\n const [pending, startTransition] = useTransition();\n // Until React runs, a click would submit the address as a plain GET.\n const ready = useSyncExternalStore(noop, () => true, () => false);\n\n function show(next: AddressErrors) {\n setErrors(next);\n const first = Object.keys(next)[0];\n if (first) form.current?.querySelector(`[name=\"${first}\"]`)?.focus();\n }\n\n function checkOnBlur(event: FocusEvent) {\n const input = event.currentTarget;\n if (!input.value.trim()) return;\n const check = checkPostcode(input.value);\n if (check.ok) {\n input.value = check.postcode;\n return;\n }\n setSuggestion(check.suggestion);\n setErrors((current) => ({ ...current, postcode: check.error }));\n }\n\n // Cleared on edit, not on blur: a message vanishing as the customer clicks\n // \"Place order\" moves the button out from under the pointer.\n function clearOnEdit(event: FormEvent) {\n const { name } = event.target as HTMLInputElement;\n if (name === \"postcode\") setSuggestion(undefined);\n setErrors((current) => {\n if (!(name in current)) return current;\n const { [name as Name]: _, ...rest } = current;\n return rest;\n });\n }\n\n function applySuggestion() {\n const input = form.current?.elements.namedItem(\"postcode\");\n if (input instanceof HTMLInputElement && suggestion) {\n input.value = suggestion;\n // The button that had focus is about to go away.\n input.focus();\n }\n setSuggestion(undefined);\n setErrors(({ postcode: _, ...rest }) => rest);\n }\n\n function submit(event: FormEvent) {\n event.preventDefault();\n setFailed(false);\n const result = addressSchema({ postcodeRequired }).safeParse(\n Object.fromEntries(new FormData(event.currentTarget)),\n );\n if (!result.success) return show(fieldErrors(result.error));\n setErrors({});\n startTransition(async () => {\n try {\n const refused = await onSubmit(result.data);\n if (refused) show(refused);\n } catch {\n setFailed(true);\n }\n });\n }\n\n function field(name: Name) {\n const error = errors[name];\n return {\n id: name,\n name,\n \"aria-invalid\": error ? true : undefined,\n \"aria-describedby\": error ? `${name}-error` : undefined,\n };\n }\n\n function error(name: Name) {\n return errors[name] && {errors[name]};\n }\n\n return (\n \n \n \n Full name\n \n {error(\"name\")}\n \n \n Phone number\n \n {error(\"phone\")}\n \n \n Street address\n \n {error(\"street\")}\n \n \n Town or city\n \n {error(\"city\")}\n \n \n Local government area (optional)\n \n {error(\"lga\")}\n \n \n State\n \n \n Choose a state\n \n {STATES.map((state) => (\n \n {state}\n \n ))}\n \n {error(\"state\")}\n \n \n Postcode{postcodeRequired ? \"\" : \" (optional)\"}\n \n \n The 11-character NIPOST postcode. Find yours at{\" \"}\n \n postcode.gov.ng\n \n .\n \n {error(\"postcode\")}\n {suggestion && (\n \n )}\n \n {failed && We could not send your address. Try again.}\n \n \n \n );\n}\n", + "content": "\"use client\";\n\nimport { type FocusEvent, type FormEvent, useRef, useState, useSyncExternalStore, useTransition } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport { NativeSelect, NativeSelectOption } from \"@/components/ui/native-select\";\nimport {\n type Address,\n type AddressErrors,\n type AddressInput,\n addressSchema,\n checkPostcode,\n EXAMPLE,\n fieldErrors,\n STATES,\n} from \"@/lib/nigerian-address\";\n\ntype Props = {\n /** Return field errors to show them, such as a postcode the server refused. */\n onSubmit: (address: Address) => Promise | AddressErrors | void;\n postcodeRequired?: boolean;\n submitLabel?: string;\n};\n\ntype Name = keyof AddressInput;\n\nconst noop = () => () => {};\n\nexport function NigerianAddressForm({ onSubmit, postcodeRequired = false, submitLabel = \"Continue\" }: Props) {\n const form = useRef(null);\n const [errors, setErrors] = useState({});\n const [suggestion, setSuggestion] = useState();\n const [failed, setFailed] = useState(false);\n const [pending, startTransition] = useTransition();\n // Until React runs, a click would submit the address as a plain GET.\n const ready = useSyncExternalStore(noop, () => true, () => false);\n\n function show(next: AddressErrors) {\n setErrors(next);\n const first = Object.keys(next)[0];\n if (first) form.current?.querySelector(`[name=\"${first}\"]`)?.focus();\n }\n\n function checkOnBlur(event: FocusEvent) {\n const input = event.currentTarget;\n if (!input.value.trim()) return;\n const check = checkPostcode(input.value);\n if (check.ok) {\n input.value = check.postcode;\n return;\n }\n setSuggestion(check.suggestion);\n setErrors((current) => ({ ...current, postcode: check.error }));\n }\n\n // Cleared on edit, not on blur: a message vanishing as the customer clicks\n // \"Place order\" moves the button out from under the pointer.\n function clearOnEdit(event: FormEvent) {\n const { name } = event.target as HTMLInputElement;\n if (name === \"postcode\") setSuggestion(undefined);\n setErrors((current) => {\n if (!(name in current)) return current;\n const { [name as Name]: _, ...rest } = current;\n return rest;\n });\n }\n\n function applySuggestion() {\n const input = form.current?.elements.namedItem(\"postcode\");\n if (input instanceof HTMLInputElement && suggestion) {\n input.value = suggestion;\n // The button that had focus is about to go away.\n input.focus();\n }\n setSuggestion(undefined);\n setErrors(({ postcode: _, ...rest }) => rest);\n }\n\n function submit(event: FormEvent) {\n event.preventDefault();\n setFailed(false);\n const result = addressSchema({ postcodeRequired }).safeParse(\n Object.fromEntries(new FormData(event.currentTarget)),\n );\n if (!result.success) return show(fieldErrors(result.error));\n setErrors({});\n startTransition(async () => {\n try {\n const refused = await onSubmit(result.data);\n if (refused) show(refused);\n } catch {\n setFailed(true);\n }\n });\n }\n\n function field(name: Name) {\n const error = errors[name];\n return {\n id: name,\n name,\n \"aria-invalid\": error ? true : undefined,\n \"aria-describedby\": error ? `${name}-error` : undefined,\n };\n }\n\n function error(name: Name) {\n const message = errors[name];\n if (!message) return null;\n // Keep the example code on one line.\n const [before, after] = message.split(EXAMPLE);\n return (\n \n {after === undefined ? message : <>{before}{EXAMPLE}{after}}\n \n );\n }\n\n return (\n
\n \n \n Full name\n \n {error(\"name\")}\n \n \n Phone number\n \n {error(\"phone\")}\n \n \n Street address\n \n {error(\"street\")}\n \n \n Town or city\n \n {error(\"city\")}\n \n \n Local government area (optional)\n \n {error(\"lga\")}\n \n \n State\n \n \n Choose a state\n \n {STATES.map((state) => (\n \n {state}\n \n ))}\n \n {error(\"state\")}\n \n \n Postcode{postcodeRequired ? \"\" : \" (optional)\"}\n \n \n The 11-character NIPOST postcode. Find yours at{\" \"}\n \n postcode.gov.ng\n \n .\n \n {error(\"postcode\")}\n {suggestion && (\n \n )}\n \n {failed && We could not send your address. Try again.}\n \n \n
\n );\n}\n", "type": "registry:component" }, { diff --git a/templates/nigerian-address-form/components/nigerian-address-form.tsx b/templates/nigerian-address-form/components/nigerian-address-form.tsx index 5669cb6..62a494e 100644 --- a/templates/nigerian-address-form/components/nigerian-address-form.tsx +++ b/templates/nigerian-address-form/components/nigerian-address-form.tsx @@ -106,7 +106,15 @@ export function NigerianAddressForm({ onSubmit, postcodeRequired = false, submit } function error(name: Name) { - return errors[name] && {errors[name]}; + const message = errors[name]; + if (!message) return null; + // Keep the example code on one line. + const [before, after] = message.split(EXAMPLE); + return ( + + {after === undefined ? message : <>{before}{EXAMPLE}{after}} + + ); } return (