Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/r/nigerian-address-form.json
Original file line number Diff line number Diff line change
Expand Up @@ -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> | 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<HTMLFormElement>(null);\n const [errors, setErrors] = useState<AddressErrors>({});\n const [suggestion, setSuggestion] = useState<string>();\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<HTMLElement>(`[name=\"${first}\"]`)?.focus();\n }\n\n function checkOnBlur(event: FocusEvent<HTMLInputElement>) {\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<HTMLFormElement>) {\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] && <FieldError id={`${name}-error`}>{errors[name]}</FieldError>;\n }\n\n return (\n <form ref={form} onSubmit={submit} noValidate aria-busy={pending}>\n <FieldGroup>\n <Field data-invalid={!!errors.name}>\n <FieldLabel htmlFor=\"name\">Full name</FieldLabel>\n <Input {...field(\"name\")} autoComplete=\"name\" />\n {error(\"name\")}\n </Field>\n <Field data-invalid={!!errors.phone}>\n <FieldLabel htmlFor=\"phone\">Phone number</FieldLabel>\n <Input {...field(\"phone\")} type=\"tel\" autoComplete=\"tel\" inputMode=\"tel\" placeholder=\"0803 123 4567\" />\n {error(\"phone\")}\n </Field>\n <Field data-invalid={!!errors.street}>\n <FieldLabel htmlFor=\"street\">Street address</FieldLabel>\n <Input {...field(\"street\")} autoComplete=\"street-address\" />\n {error(\"street\")}\n </Field>\n <Field data-invalid={!!errors.city}>\n <FieldLabel htmlFor=\"city\">Town or city</FieldLabel>\n <Input {...field(\"city\")} autoComplete=\"address-level2\" />\n {error(\"city\")}\n </Field>\n <Field data-invalid={!!errors.lga}>\n <FieldLabel htmlFor=\"lga\">Local government area (optional)</FieldLabel>\n <Input {...field(\"lga\")} />\n {error(\"lga\")}\n </Field>\n <Field data-invalid={!!errors.state}>\n <FieldLabel htmlFor=\"state\">State</FieldLabel>\n <NativeSelect {...field(\"state\")} autoComplete=\"address-level1\" defaultValue=\"\" className=\"w-full\">\n <NativeSelectOption value=\"\" disabled>\n Choose a state\n </NativeSelectOption>\n {STATES.map((state) => (\n <NativeSelectOption key={state} value={state}>\n {state}\n </NativeSelectOption>\n ))}\n </NativeSelect>\n {error(\"state\")}\n </Field>\n <Field data-invalid={!!errors.postcode}>\n <FieldLabel htmlFor=\"postcode\">Postcode{postcodeRequired ? \"\" : \" (optional)\"}</FieldLabel>\n <Input\n {...field(\"postcode\")}\n autoComplete=\"postal-code\"\n autoCapitalize=\"characters\"\n autoCorrect=\"off\"\n spellCheck={false}\n maxLength={24}\n placeholder={EXAMPLE}\n onBlur={checkOnBlur}\n />\n <FieldDescription>\n The 11-character NIPOST postcode. Find yours at{\" \"}\n <a href=\"https://postcode.gov.ng/find\" target=\"_blank\" rel=\"noreferrer\">\n postcode.gov.ng\n </a>\n .\n </FieldDescription>\n {error(\"postcode\")}\n {suggestion && (\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"w-fit\" onClick={applySuggestion}>\n Use {suggestion}\n </Button>\n )}\n </Field>\n {failed && <FieldError>We could not send your address. Try again.</FieldError>}\n <Button type=\"submit\" disabled={!ready || pending}>\n {pending ? \"Sending…\" : submitLabel}\n </Button>\n </FieldGroup>\n </form>\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> | 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<HTMLFormElement>(null);\n const [errors, setErrors] = useState<AddressErrors>({});\n const [suggestion, setSuggestion] = useState<string>();\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<HTMLElement>(`[name=\"${first}\"]`)?.focus();\n }\n\n function checkOnBlur(event: FocusEvent<HTMLInputElement>) {\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<HTMLFormElement>) {\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<HTMLFormElement>) {\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 <FieldError id={`${name}-error`}>\n {after === undefined ? message : <>{before}<span className=\"whitespace-nowrap\">{EXAMPLE}</span>{after}</>}\n </FieldError>\n );\n }\n\n return (\n <form ref={form} onSubmit={submit} onChange={clearOnEdit} noValidate aria-busy={pending}>\n <FieldGroup>\n <Field data-invalid={!!errors.name}>\n <FieldLabel htmlFor=\"name\">Full name</FieldLabel>\n <Input {...field(\"name\")} autoComplete=\"name\" />\n {error(\"name\")}\n </Field>\n <Field data-invalid={!!errors.phone}>\n <FieldLabel htmlFor=\"phone\">Phone number</FieldLabel>\n <Input {...field(\"phone\")} type=\"tel\" autoComplete=\"tel\" inputMode=\"tel\" placeholder=\"0803 123 4567\" />\n {error(\"phone\")}\n </Field>\n <Field data-invalid={!!errors.street}>\n <FieldLabel htmlFor=\"street\">Street address</FieldLabel>\n <Input {...field(\"street\")} autoComplete=\"street-address\" />\n {error(\"street\")}\n </Field>\n <Field data-invalid={!!errors.city}>\n <FieldLabel htmlFor=\"city\">Town or city</FieldLabel>\n <Input {...field(\"city\")} autoComplete=\"address-level2\" />\n {error(\"city\")}\n </Field>\n <Field data-invalid={!!errors.lga}>\n <FieldLabel htmlFor=\"lga\">Local government area (optional)</FieldLabel>\n <Input {...field(\"lga\")} />\n {error(\"lga\")}\n </Field>\n <Field data-invalid={!!errors.state}>\n <FieldLabel htmlFor=\"state\">State</FieldLabel>\n <NativeSelect {...field(\"state\")} autoComplete=\"address-level1\" defaultValue=\"\" className=\"w-full\">\n <NativeSelectOption value=\"\" disabled>\n Choose a state\n </NativeSelectOption>\n {STATES.map((state) => (\n <NativeSelectOption key={state} value={state}>\n {state}\n </NativeSelectOption>\n ))}\n </NativeSelect>\n {error(\"state\")}\n </Field>\n <Field data-invalid={!!errors.postcode}>\n <FieldLabel htmlFor=\"postcode\">Postcode{postcodeRequired ? \"\" : \" (optional)\"}</FieldLabel>\n <Input\n {...field(\"postcode\")}\n autoComplete=\"postal-code\"\n autoCapitalize=\"characters\"\n autoCorrect=\"off\"\n spellCheck={false}\n maxLength={24}\n placeholder={EXAMPLE}\n onBlur={checkOnBlur}\n />\n <FieldDescription>\n The 11-character NIPOST postcode. Find yours at{\" \"}\n <a href=\"https://postcode.gov.ng/find\" target=\"_blank\" rel=\"noreferrer\">\n postcode.gov.ng\n </a>\n .\n </FieldDescription>\n {error(\"postcode\")}\n {suggestion && (\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"w-fit\" onClick={applySuggestion}>\n Use {suggestion}\n </Button>\n )}\n </Field>\n {failed && <FieldError>We could not send your address. Try again.</FieldError>}\n <Button type=\"submit\" disabled={!ready || pending}>\n {pending ? \"Sending…\" : submitLabel}\n </Button>\n </FieldGroup>\n </form>\n );\n}\n",
"type": "registry:component"
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<NigerianAddressForm onSubmit={vi.fn()} />);
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/);
});
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,26 @@ export function NigerianAddressForm({ onSubmit, postcodeRequired = false, submit

function checkOnBlur(event: FocusEvent<HTMLInputElement>) {
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<HTMLFormElement>) {
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() {
Expand Down Expand Up @@ -90,11 +106,19 @@ export function NigerianAddressForm({ onSubmit, postcodeRequired = false, submit
}

function error(name: Name) {
return errors[name] && <FieldError id={`${name}-error`}>{errors[name]}</FieldError>;
const message = errors[name];
if (!message) return null;
// Keep the example code on one line.
const [before, after] = message.split(EXAMPLE);
return (
<FieldError id={`${name}-error`}>
{after === undefined ? message : <>{before}<span className="whitespace-nowrap">{EXAMPLE}</span>{after}</>}
</FieldError>
);
}

return (
<form ref={form} onSubmit={submit} noValidate aria-busy={pending}>
<form ref={form} onSubmit={submit} onChange={clearOnEdit} noValidate aria-busy={pending}>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="name">Full name</FieldLabel>
Expand Down
Loading