diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index bcfc07f..fea60af 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -685,7 +685,7 @@ MIT
A React checkout address form for Nigeria that accepts the 11-character NIPOST digital postcode (NDAPS), such as `FC-02-A09-DB-09`. It is built with [shadcn/ui](https://ui.shadcn.com) and [`ng-postcode-js`](https://www.npmjs.com/package/ng-postcode-js), and this folder is a Next.js app that uses it.
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FAdeniyikayodee%2Fng-postcode%2Ftree%2Fmain%2Ftemplates%2Fnigerian-address-form&project-name=nigerian-address-form)
-[](https://v0.dev/chat/api/open?url=https%3A%2F%2Fadeniyikayodee.github.io%2Fng-postcode%2Fr%2Fnigerian-address-form.json)
+[](https://v0.app/chat/api/open?url=https%3A%2F%2Fadeniyikayodee.github.io%2Fng-postcode%2Fr%2Fnigerian-address-form.json)
## Add it to your project
diff --git a/docs/r/nigerian-address-form.json b/docs/r/nigerian-address-form.json
index 93e85eb..5e6742b 100644
--- a/docs/r/nigerian-address-form.json
+++ b/docs/r/nigerian-address-form.json
@@ -7,16 +7,10 @@
"ng-postcode-js@^0.4.4",
"zod@^4.6.5"
],
- "registryDependencies": [
- "button",
- "field",
- "input",
- "native-select"
- ],
"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 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",
+ "content": "\"use client\";\n\nimport {\n type FocusEvent,\n type FormEvent,\n type ReactNode,\n useRef,\n useState,\n useSyncExternalStore,\n useTransition,\n} from \"react\";\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. Without it the form only validates. */\n onSubmit?: (address: Address) => Promise | AddressErrors | void;\n postcodeRequired?: boolean;\n submitLabel?: string;\n};\n\ntype Name = keyof AddressInput;\n\nconst noop = () => () => {};\n\n// Plain elements styled with the shadcn theme, so the form needs no other component\n// and installs the same in every shadcn style and in v0.\nconst control =\n \"h-9 w-full rounded-md border border-input bg-transparent px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30\";\nconst button =\n \"inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\";\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 function row(name: Name, label: string, input: ReactNode, extra?: ReactNode) {\n return (\n \n \n {input}\n {extra}\n {error(name)}\n
\n );\n }\n\n return (\n \n );\n}\n\nexport default NigerianAddressForm;\n",
"type": "registry:component"
},
{
diff --git a/docs/r/registry.json b/docs/r/registry.json
index 739ef89..67a3fe8 100644
--- a/docs/r/registry.json
+++ b/docs/r/registry.json
@@ -8,15 +8,30 @@
"type": "registry:block",
"title": "Nigerian address form",
"description": "Checkout address form for Nigeria that accepts the 11-character NIPOST digital postcode (NDAPS), such as FC-02-A09-DB-09, checked offline with ng-postcode-js.",
- "dependencies": ["ng-postcode-js@^0.4.4", "zod@^4.6.5"],
- "registryDependencies": ["button", "field", "input", "native-select"],
+ "dependencies": [
+ "ng-postcode-js@^0.4.4",
+ "zod@^4.6.5"
+ ],
"files": [
- { "path": "components/nigerian-address-form.tsx", "type": "registry:component" },
- { "path": "lib/nigerian-address.ts", "type": "registry:lib" },
- { "path": "lib/nipost.ts", "type": "registry:lib" }
+ {
+ "path": "components/nigerian-address-form.tsx",
+ "type": "registry:component"
+ },
+ {
+ "path": "lib/nigerian-address.ts",
+ "type": "registry:lib"
+ },
+ {
+ "path": "lib/nipost.ts",
+ "type": "registry:lib"
+ }
],
"docs": "Validate again on the server with addressSchema().safeParse. To confirm a code is assigned, call isAssigned from lib/nipost.ts with nipostClient(process.env.NG_POSTCODE_API_KEY).",
- "categories": ["forms", "checkout", "address"]
+ "categories": [
+ "forms",
+ "checkout",
+ "address"
+ ]
}
]
}
diff --git a/templates/nigerian-address-form/README.md b/templates/nigerian-address-form/README.md
index bddbd8a..fc10ad3 100644
--- a/templates/nigerian-address-form/README.md
+++ b/templates/nigerian-address-form/README.md
@@ -3,7 +3,7 @@
A React checkout address form for Nigeria that accepts the 11-character NIPOST digital postcode (NDAPS), such as `FC-02-A09-DB-09`. It is built with [shadcn/ui](https://ui.shadcn.com) and [`ng-postcode-js`](https://www.npmjs.com/package/ng-postcode-js), and this folder is a Next.js app that uses it.
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FAdeniyikayodee%2Fng-postcode%2Ftree%2Fmain%2Ftemplates%2Fnigerian-address-form&project-name=nigerian-address-form)
-[](https://v0.dev/chat/api/open?url=https%3A%2F%2Fadeniyikayodee.github.io%2Fng-postcode%2Fr%2Fnigerian-address-form.json)
+[](https://v0.app/chat/api/open?url=https%3A%2F%2Fadeniyikayodee.github.io%2Fng-postcode%2Fr%2Fnigerian-address-form.json)
## Add it to your project
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 9625721..1357eca 100644
--- a/templates/nigerian-address-form/components/nigerian-address-form.test.tsx
+++ b/templates/nigerian-address-form/components/nigerian-address-form.test.tsx
@@ -1,7 +1,7 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
-import { NigerianAddressForm } from "./nigerian-address-form";
+import Default, { NigerianAddressForm } from "./nigerian-address-form";
afterEach(cleanup);
@@ -96,3 +96,10 @@ it("clears an error once the customer edits the field", async () => {
await user.tab();
expect(screen.getByRole("alert").textContent).toMatch(/A postcode has 11/);
});
+
+it("works as a default import with no props", async () => {
+ render();
+ const user = await fill("FC-02-A09-DB-09");
+ await user.click(screen.getByRole("button", { name: "Continue" }));
+ expect(screen.queryByRole("alert")).toBeNull();
+});
diff --git a/templates/nigerian-address-form/components/nigerian-address-form.tsx b/templates/nigerian-address-form/components/nigerian-address-form.tsx
index 62a494e..ff1f916 100644
--- a/templates/nigerian-address-form/components/nigerian-address-form.tsx
+++ b/templates/nigerian-address-form/components/nigerian-address-form.tsx
@@ -1,10 +1,14 @@
"use client";
-import { type FocusEvent, type FormEvent, useRef, useState, useSyncExternalStore, useTransition } from "react";
-import { Button } from "@/components/ui/button";
-import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
-import { Input } from "@/components/ui/input";
-import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
+import {
+ type FocusEvent,
+ type FormEvent,
+ type ReactNode,
+ useRef,
+ useState,
+ useSyncExternalStore,
+ useTransition,
+} from "react";
import {
type Address,
type AddressErrors,
@@ -17,8 +21,8 @@ import {
} from "@/lib/nigerian-address";
type Props = {
- /** Return field errors to show them, such as a postcode the server refused. */
- onSubmit: (address: Address) => Promise | AddressErrors | void;
+ /** Return field errors to show them, such as a postcode the server refused. Without it the form only validates. */
+ onSubmit?: (address: Address) => Promise | AddressErrors | void;
postcodeRequired?: boolean;
submitLabel?: string;
};
@@ -27,6 +31,13 @@ type Name = keyof AddressInput;
const noop = () => () => {};
+// Plain elements styled with the shadcn theme, so the form needs no other component
+// and installs the same in every shadcn style and in v0.
+const control =
+ "h-9 w-full rounded-md border border-input bg-transparent px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30";
+const button =
+ "inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50";
+
export function NigerianAddressForm({ onSubmit, postcodeRequired = false, submitLabel = "Continue" }: Props) {
const form = useRef(null);
const [errors, setErrors] = useState({});
@@ -87,7 +98,7 @@ export function NigerianAddressForm({ onSubmit, postcodeRequired = false, submit
setErrors({});
startTransition(async () => {
try {
- const refused = await onSubmit(result.data);
+ const refused = await onSubmit?.(result.data);
if (refused) show(refused);
} catch {
setFailed(true);
@@ -111,85 +122,87 @@ export function NigerianAddressForm({ onSubmit, postcodeRequired = false, submit
// Keep the example code on one line.
const [before, after] = message.split(EXAMPLE);
return (
-
+
{after === undefined ? message : <>{before}{EXAMPLE}{after}>}
-
+
+ );
+ }
+
+ function row(name: Name, label: string, input: ReactNode, extra?: ReactNode) {
+ return (
+
+
+ {input}
+ {extra}
+ {error(name)}
+
);
}
return (
-
);
}
+
+export default NigerianAddressForm;
diff --git a/templates/nigerian-address-form/components/ui/button.tsx b/templates/nigerian-address-form/components/ui/button.tsx
deleted file mode 100644
index 997e0c7..0000000
--- a/templates/nigerian-address-form/components/ui/button.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Button as ButtonPrimitive } from "@base-ui/react/button"
-import { cva, type VariantProps } from "class-variance-authority"
-import { cn } from "cn"
-
-const buttonVariants = cva(
- "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
- {
- variants: {
- variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/80",
- outline:
- "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
- secondary:
- "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
- ghost:
- "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
- destructive:
- "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- default:
- "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
- sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
- lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- icon: "size-8",
- "icon-xs":
- "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
- "icon-sm":
- "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
- "icon-lg": "size-9",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-)
-
-function Button({
- className,
- variant = "default",
- size = "default",
- ...props
-}: ButtonPrimitive.Props & VariantProps) {
- return (
-
- )
-}
-
-export { Button, buttonVariants }
diff --git a/templates/nigerian-address-form/components/ui/field.tsx b/templates/nigerian-address-form/components/ui/field.tsx
deleted file mode 100644
index 8ae7f29..0000000
--- a/templates/nigerian-address-form/components/ui/field.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-"use client"
-
-import { useMemo } from "react"
-import { cva, type VariantProps } from "class-variance-authority"
-import { cn } from "cn"
-
-import { Label } from "@/components/ui/label"
-import { Separator } from "@/components/ui/separator"
-
-function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
- return (
-