diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31e1210..c4f4dfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,28 @@ jobs: - run: npm run check - run: npm test + template: + runs-on: ubuntu-latest + strategy: + matrix: + node: ["22", "24"] + defaults: + run: + working-directory: templates/nigerian-address-form + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: templates/nigerian-address-form/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: npm run build + - name: The published registry item matches the source + run: npm run registry && test -z "$(git status --porcelain ../../docs/r)" + java: runs-on: ubuntu-latest strategy: diff --git a/README.md b/README.md index 01af948..88e1709 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Two names are easy to get wrong. On npm the library is `ng-postcode-js`, because For online stores, [ng-postcode-woocommerce](https://github.com/Adeniyikayodee/ng-postcode-woocommerce) is a WooCommerce plugin built on the same rules and the same shared test cases: it checks and tidies the postcode at checkout, finds it from the customer's location, and makes shipping zones match on postcode prefixes. +For React and Next.js, [`templates/nigerian-address-form`](templates/nigerian-address-form) is a checkout address form built with shadcn/ui and `ng-postcode-js`. Add it with `npx shadcn@latest add https://adeniyikayodee.github.io/ng-postcode/r/nigerian-address-form.json`, or deploy the folder as an app. + ## Quick start Parsing and validation are offline and need no key. diff --git a/context7.json b/context7.json index fffc44f..e910240 100644 --- a/context7.json +++ b/context7.json @@ -13,6 +13,7 @@ "Never correct a mistyped code and then spend a paid lookup on the guess. Show the suggestion to the user first.", "To group or select records by state, LGA, district or area, cut each code to a prefix with truncate (ng_postcode_truncate in SQL). Do not slice the string by hand.", "In PostgreSQL use the ng_postcode domain from sql/postgres.sql. In DuckDB a check constraint must use IS NOT DISTINCT FROM, as = lets a malformed code through.", + "For a React or Next.js address form, run npx shadcn@latest add https://adeniyikayodee.github.io/ng-postcode/r/nigerian-address-form.json. It validates the postcode with ng-postcode-js.", "To pass an address between systems, send an address record: a postcode reference with its level, plus country NG. Put only a building-level code in another format's postcode field." ] } diff --git a/docs/index.md b/docs/index.md index 8190dd9..05241ff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,6 +52,14 @@ if (code instanceof Postcode) { `ng-postcode-js/api` and `ng-postcode-js/client` call the NIPOST API with `fetch`. +## Add a Nigerian address form in React + +```sh +npx shadcn@latest add https://adeniyikayodee.github.io/ng-postcode/r/nigerian-address-form.json +``` + +A checkout address form built with shadcn/ui and `ng-postcode-js`. It tidies the postcode as the customer types, offers a fix for look-alike characters, explains an old six-digit code, and works with browser autofill. The [template](https://github.com/Adeniyikayodee/ng-postcode/tree/main/templates/nigerian-address-form) is also a Next.js app you can deploy. + ## Validate a postcode in Rust ```rust diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 089e078..bcfc07f 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -31,6 +31,7 @@ Since 1 October 2026 a Nigerian building's postcode is this 11-character code. A - [ng_postcode for PostgreSQL](https://github.com/Adeniyikayodee/ng-postcode/tree/main/native/pg): a native extension for PostgreSQL 14 to 18. Types `ng_postcode` and `ng_postcode_prefix` take 8 bytes and support B-tree and hash indexes. Install a package from the repository's Releases, then `CREATE EXTENSION ng_postcode`. - [ng_postcode for DuckDB](https://github.com/Adeniyikayodee/ng-postcode-duckdb): a DuckDB extension in Rust with `ng_postcode_parse`, `ng_postcode_prefix_parse`, `ng_postcode_format`, `ng_postcode_truncate`, `ng_postcode_parent`, and `ng_postcode_within`. Install with `INSTALL ng_postcode FROM community; LOAD ng_postcode;` on DuckDB 1.5.6; other versions report the extension as not found. - Convert a CSV file: `pip install ng-postcode`, then `ng-postcode --column postcode customers.csv --out checked.csv`. It adds the compact code, a status of valid, suggested, invalid, or empty, and the reason for each invalid row. +- [Nigerian address form](https://github.com/Adeniyikayodee/ng-postcode/tree/main/templates/nigerian-address-form): a React checkout form built with shadcn/ui and `ng-postcode-js`. Add it with `npx shadcn@latest add https://adeniyikayodee.github.io/ng-postcode/r/nigerian-address-form.json`. It returns the postcode as `FC-02-A09-DB-09` and a phone number as `+234` and ten digits, and exports `addressSchema` (zod) for the server. - [Group records by place](https://adeniyikayodee.github.io/ng-postcode/group-by-place): count or select records by state, LGA, district, or area by cutting each code to a prefix with `truncate`. ## Docs @@ -90,6 +91,8 @@ Two names are easy to get wrong. On npm the library is `ng-postcode-js`, because For online stores, [ng-postcode-woocommerce](https://github.com/Adeniyikayodee/ng-postcode-woocommerce) is a WooCommerce plugin built on the same rules and the same shared test cases: it checks and tidies the postcode at checkout, finds it from the customer's location, and makes shipping zones match on postcode prefixes. +For React and Next.js, [`templates/nigerian-address-form`](https://github.com/Adeniyikayodee/ng-postcode/tree/main/templates/nigerian-address-form) is a checkout address form built with shadcn/ui and `ng-postcode-js`. Add it with `npx shadcn@latest add https://adeniyikayodee.github.io/ng-postcode/r/nigerian-address-form.json`, or deploy the folder as an app. + ## Quick start Parsing and validation are offline and need no key. @@ -675,6 +678,60 @@ MIT --- + + +# Nigerian address form + +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. + +[![Deploy with Vercel](https://vercel.com/button)](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) +[![Open in v0](https://v0.app/chat-static/button.svg)](https://v0.dev/chat/api/open?url=https%3A%2F%2Fadeniyikayodee.github.io%2Fng-postcode%2Fr%2Fnigerian-address-form.json) + +## Add it to your project + +In a project set up with shadcn/ui: + +```sh +npx shadcn@latest add https://adeniyikayodee.github.io/ng-postcode/r/nigerian-address-form.json +``` + +```tsx +import { NigerianAddressForm } from "@/components/nigerian-address-form"; + +; +``` + +`onSubmit` receives the address with the postcode as `FC-02-A09-DB-09` and the phone number as `+2348031234567`. Return field errors, such as `{ postcode: "..." }`, to show them on the form. + +Check the address again on the server, since the browser's check can be skipped: + +```ts +import { addressSchema, fieldErrors } from "@/lib/nigerian-address"; + +const result = addressSchema().safeParse(input); +if (!result.success) return fieldErrors(result.error); +``` + +## What it handles + +- Hyphens, spaces, no separator, and either case: the postcode is tidied to `FC-02-A09-DB-09` when the customer leaves the field. +- Look-alike characters, such as `O` for `0`, with a one-tap fix the customer confirms. +- An old six-digit code, with a message that shows the new format. +- Errors in plain words, linked to their field for screen readers, with focus moved to the first one. +- Browser autofill, through the standard `autocomplete` names and a native state picker. +- Optional confirmation with NIPOST: set `NG_POSTCODE_API_KEY` to a live key from the [NIPOST developer dashboard](https://dashboard.postcode.gov.ng), and the server refuses codes NIPOST has not assigned. If NIPOST is slow or unreachable, the order goes through on the offline check. Test keys answer "not found" for every code, so they are ignored. + +## Run it + +```sh +npm install +npm run dev +``` + +`npm test` runs the tests, and `npm run registry` rebuilds the published registry item in `docs/r`. + +--- + # ng-postcode for Java diff --git a/docs/llms.txt b/docs/llms.txt index 66587ef..6a163c3 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -29,6 +29,7 @@ Since 1 October 2026 a Nigerian building's postcode is this 11-character code. A - [ng_postcode for PostgreSQL](https://github.com/Adeniyikayodee/ng-postcode/tree/main/native/pg): a native extension for PostgreSQL 14 to 18. Types `ng_postcode` and `ng_postcode_prefix` take 8 bytes and support B-tree and hash indexes. Install a package from the repository's Releases, then `CREATE EXTENSION ng_postcode`. - [ng_postcode for DuckDB](https://github.com/Adeniyikayodee/ng-postcode-duckdb): a DuckDB extension in Rust with `ng_postcode_parse`, `ng_postcode_prefix_parse`, `ng_postcode_format`, `ng_postcode_truncate`, `ng_postcode_parent`, and `ng_postcode_within`. Install with `INSTALL ng_postcode FROM community; LOAD ng_postcode;` on DuckDB 1.5.6; other versions report the extension as not found. - Convert a CSV file: `pip install ng-postcode`, then `ng-postcode --column postcode customers.csv --out checked.csv`. It adds the compact code, a status of valid, suggested, invalid, or empty, and the reason for each invalid row. +- [Nigerian address form](https://github.com/Adeniyikayodee/ng-postcode/tree/main/templates/nigerian-address-form): a React checkout form built with shadcn/ui and `ng-postcode-js`. Add it with `npx shadcn@latest add https://adeniyikayodee.github.io/ng-postcode/r/nigerian-address-form.json`. It returns the postcode as `FC-02-A09-DB-09` and a phone number as `+234` and ten digits, and exports `addressSchema` (zod) for the server. - [Group records by place](https://adeniyikayodee.github.io/ng-postcode/group-by-place): count or select records by state, LGA, district, or area by cutting each code to a prefix with `truncate`. ## Docs diff --git a/docs/r/nigerian-address-form.json b/docs/r/nigerian-address-form.json new file mode 100644 index 0000000..3649299 --- /dev/null +++ b/docs/r/nigerian-address-form.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "nigerian-address-form", + "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" + ], + "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", + "type": "registry:component" + }, + { + "path": "lib/nigerian-address.ts", + "content": "import { InvalidSegment, parse, parseLenient, Postcode, type Segment, WrongLength } from \"ng-postcode-js\";\nimport { z } from \"zod\";\n\nexport const STATES = [\n \"Abia\", \"Adamawa\", \"Akwa Ibom\", \"Anambra\", \"Bauchi\", \"Bayelsa\", \"Benue\", \"Borno\",\n \"Cross River\", \"Delta\", \"Ebonyi\", \"Edo\", \"Ekiti\", \"Enugu\", \"Federal Capital Territory\",\n \"Gombe\", \"Imo\", \"Jigawa\", \"Kaduna\", \"Kano\", \"Katsina\", \"Kebbi\", \"Kogi\", \"Kwara\", \"Lagos\",\n \"Nasarawa\", \"Niger\", \"Ogun\", \"Ondo\", \"Osun\", \"Oyo\", \"Plateau\", \"Rivers\", \"Sokoto\",\n \"Taraba\", \"Yobe\", \"Zamfara\",\n] as const;\n\n/** NIPOST's own example code. */\nexport const EXAMPLE = \"FC-02-A09-DB-09\";\n\nconst SEGMENT_HINTS: Record = {\n state: \"The first two characters must be letters.\",\n lga: \"Characters 3 and 4 must be digits from 01 to 99.\",\n district: \"Characters 5 to 7 must be letters or digits.\",\n area: \"Characters 8 and 9 must be letters.\",\n unit: \"The last two characters must be digits from 01 to 99.\",\n};\n\nexport type PostcodeCheck =\n | { ok: true; postcode: string }\n | { ok: false; error: string; suggestion?: string };\n\n/** Check a typed postcode and say what is wrong in words a customer understands. */\nexport function checkPostcode(text: string): PostcodeCheck {\n const code = parse(text);\n if (code instanceof Postcode) return { ok: true, postcode: String(code) };\n if (/^\\s*\\d{6}\\s*$/.test(text)) {\n return { ok: false, error: `That is the old six-digit code. The new postcode looks like ${EXAMPLE}.` };\n }\n const fixed = parseLenient(text);\n if (\"postcode\" in fixed) {\n return { ok: false, error: \"Check your postcode.\", suggestion: String(fixed.postcode) };\n }\n if (code instanceof WrongLength) {\n return { ok: false, error: `A postcode has 11 letters and digits, such as ${EXAMPLE}.` };\n }\n if (code instanceof InvalidSegment) return { ok: false, error: SEGMENT_HINTS[code.segment] };\n return { ok: false, error: \"Use only letters, digits, spaces and hyphens.\" };\n}\n\n/** A Nigerian mobile number as +234 and ten digits, or null. */\nexport function normalisePhone(text: string): string | null {\n const match = /^(?:\\+?234|0)([789][01]\\d{8})$/.exec(text.replace(/[\\s().-]/g, \"\"));\n return match ? `+234${match[1]}` : null;\n}\n\nexport function addressSchema({ postcodeRequired = false } = {}) {\n return z.object({\n name: z.string().trim().min(2, \"Enter your full name.\").max(100),\n phone: z\n .string()\n .max(30)\n .transform((value, ctx) => {\n const phone = normalisePhone(value);\n if (phone) return phone;\n ctx.addIssue({ code: \"custom\", message: \"Enter a Nigerian mobile number, such as 0803 123 4567.\" });\n return z.NEVER;\n }),\n street: z.string().trim().min(3, \"Enter your street address.\").max(200),\n city: z.string().trim().min(2, \"Enter your town or city.\").max(100),\n lga: z.string().trim().max(100),\n state: z.enum(STATES, { error: \"Choose your state.\" }),\n postcode: z\n .string()\n .trim()\n .max(40)\n .transform((value, ctx) => {\n if (!value) {\n if (postcodeRequired) ctx.addIssue({ code: \"custom\", message: \"Enter your postcode.\" });\n return \"\";\n }\n const check = checkPostcode(value);\n if (check.ok) return check.postcode;\n ctx.addIssue({ code: \"custom\", message: check.error });\n return z.NEVER;\n }),\n });\n}\n\nexport type AddressSchema = ReturnType;\nexport type AddressInput = z.input;\nexport type Address = z.output;\nexport type AddressErrors = Partial>;\n\n/** The first error for each field. */\nexport function fieldErrors(error: z.ZodError): AddressErrors {\n const { fieldErrors } = z.flattenError(error);\n return Object.fromEntries(\n Object.entries(fieldErrors).map(([field, messages]) => [field, messages?.[0]]),\n );\n}\n", + "type": "registry:lib" + }, + { + "path": "lib/nipost.ts", + "content": "import { parse, Postcode } from \"ng-postcode-js\";\nimport { ApiError, lookup } from \"ng-postcode-js/api\";\nimport { Client, TransportError } from \"ng-postcode-js/client\";\n\n/**\n * A client for confirming codes, or null without a live key. Test keys answer\n * `not_found` for every code, so they would refuse every order.\n */\nexport function nipostClient(key = \"\"): Client | null {\n if (!key.trim()) return null;\n if (/^\\s*nipost_(pk_)?test_/.test(key)) {\n console.warn(\"NG_POSTCODE_API_KEY is a test key, so postcodes are checked offline only.\");\n return null;\n }\n // Checkout should not wait long on a third party.\n return new Client(key, { timeoutMs: 3000 });\n}\n\n/**\n * Whether NIPOST has assigned the code to a building, or null when it could not say.\n * A level 1 lookup, which uses no credits. Callers should accept the order on null.\n */\nexport async function isAssigned(postcode: string, client: Client): Promise {\n const code = parse(postcode);\n if (!(code instanceof Postcode)) return false;\n const found = await client.send(lookup(code, 1));\n if (found instanceof ApiError || found instanceof TransportError) {\n console.warn(`NIPOST lookup failed: ${found}`);\n return null;\n }\n if (found.valid) return true;\n return found.status === \"not_found\" || found.status === \"invalid\" ? false : null;\n}\n", + "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" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/docs/r/registry.json b/docs/r/registry.json new file mode 100644 index 0000000..739ef89 --- /dev/null +++ b/docs/r/registry.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "ng-postcode", + "homepage": "https://adeniyikayodee.github.io/ng-postcode/", + "items": [ + { + "name": "nigerian-address-form", + "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"], + "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" } + ], + "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"] + } + ] +} diff --git a/scripts/llms_full.py b/scripts/llms_full.py index 7f893f2..09a8bc9 100644 --- a/scripts/llms_full.py +++ b/scripts/llms_full.py @@ -24,6 +24,7 @@ "python/README.md", "CRATE.md", "js/packages/ng-postcode-js/README.md", + "templates/nigerian-address-form/README.md", "java/README.md", "mcp/README.md", "js/packages/ng-postcode-mcp/README.md", diff --git a/templates/nigerian-address-form/.gitignore b/templates/nigerian-address-form/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/templates/nigerian-address-form/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/templates/nigerian-address-form/AGENTS.md b/templates/nigerian-address-form/AGENTS.md new file mode 100644 index 0000000..af3b7c8 --- /dev/null +++ b/templates/nigerian-address-form/AGENTS.md @@ -0,0 +1,9 @@ + + +## This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/templates/nigerian-address-form/README.md b/templates/nigerian-address-form/README.md new file mode 100644 index 0000000..bddbd8a --- /dev/null +++ b/templates/nigerian-address-form/README.md @@ -0,0 +1,49 @@ +# Nigerian address form + +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. + +[![Deploy with Vercel](https://vercel.com/button)](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) +[![Open in v0](https://v0.app/chat-static/button.svg)](https://v0.dev/chat/api/open?url=https%3A%2F%2Fadeniyikayodee.github.io%2Fng-postcode%2Fr%2Fnigerian-address-form.json) + +## Add it to your project + +In a project set up with shadcn/ui: + +```sh +npx shadcn@latest add https://adeniyikayodee.github.io/ng-postcode/r/nigerian-address-form.json +``` + +```tsx +import { NigerianAddressForm } from "@/components/nigerian-address-form"; + +; +``` + +`onSubmit` receives the address with the postcode as `FC-02-A09-DB-09` and the phone number as `+2348031234567`. Return field errors, such as `{ postcode: "..." }`, to show them on the form. + +Check the address again on the server, since the browser's check can be skipped: + +```ts +import { addressSchema, fieldErrors } from "@/lib/nigerian-address"; + +const result = addressSchema().safeParse(input); +if (!result.success) return fieldErrors(result.error); +``` + +## What it handles + +- Hyphens, spaces, no separator, and either case: the postcode is tidied to `FC-02-A09-DB-09` when the customer leaves the field. +- Look-alike characters, such as `O` for `0`, with a one-tap fix the customer confirms. +- An old six-digit code, with a message that shows the new format. +- Errors in plain words, linked to their field for screen readers, with focus moved to the first one. +- Browser autofill, through the standard `autocomplete` names and a native state picker. +- Optional confirmation with NIPOST: set `NG_POSTCODE_API_KEY` to a live key from the [NIPOST developer dashboard](https://dashboard.postcode.gov.ng), and the server refuses codes NIPOST has not assigned. If NIPOST is slow or unreachable, the order goes through on the offline check. Test keys answer "not found" for every code, so they are ignored. + +## Run it + +```sh +npm install +npm run dev +``` + +`npm test` runs the tests, and `npm run registry` rebuilds the published registry item in `docs/r`. diff --git a/templates/nigerian-address-form/app/actions.ts b/templates/nigerian-address-form/app/actions.ts new file mode 100644 index 0000000..056dc5a --- /dev/null +++ b/templates/nigerian-address-form/app/actions.ts @@ -0,0 +1,19 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { type AddressErrors, addressSchema, fieldErrors } from "@/lib/nigerian-address"; +import { isAssigned, nipostClient } from "@/lib/nipost"; + +const nipost = nipostClient(process.env.NG_POSTCODE_API_KEY); + +/** The browser's check can be skipped, so the server checks again. */ +export async function placeOrder(input: unknown): Promise { + const result = addressSchema().safeParse(input); + if (!result.success) return fieldErrors(result.error); + const { postcode } = result.data; + if (postcode && nipost && (await isAssigned(postcode, nipost)) === false) { + return { postcode: "NIPOST has no building with this postcode. Check it at postcode.gov.ng/find." }; + } + // Save result.data with the order here. + redirect("/thanks"); +} diff --git a/templates/nigerian-address-form/app/favicon.ico b/templates/nigerian-address-form/app/favicon.ico new file mode 100644 index 0000000..af98450 Binary files /dev/null and b/templates/nigerian-address-form/app/favicon.ico differ diff --git a/templates/nigerian-address-form/app/globals.css b/templates/nigerian-address-form/app/globals.css new file mode 100644 index 0000000..1b7e96e --- /dev/null +++ b/templates/nigerian-address-form/app/globals.css @@ -0,0 +1,127 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/templates/nigerian-address-form/app/layout.tsx b/templates/nigerian-address-form/app/layout.tsx new file mode 100644 index 0000000..d12a4d3 --- /dev/null +++ b/templates/nigerian-address-form/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Nigerian address form", + description: "A checkout address form that accepts Nigeria's NIPOST digital postcode.", +}; + +export default function RootLayout({ children }: LayoutProps<"/">) { + return ( + + {children} + + ); +} diff --git a/templates/nigerian-address-form/app/page.tsx b/templates/nigerian-address-form/app/page.tsx new file mode 100644 index 0000000..6cb80fc --- /dev/null +++ b/templates/nigerian-address-form/app/page.tsx @@ -0,0 +1,11 @@ +import { NigerianAddressForm } from "@/components/nigerian-address-form"; +import { placeOrder } from "./actions"; + +export default function Home() { + return ( +
+

Delivery address

+ +
+ ); +} diff --git a/templates/nigerian-address-form/app/thanks/page.tsx b/templates/nigerian-address-form/app/thanks/page.tsx new file mode 100644 index 0000000..51a4bac --- /dev/null +++ b/templates/nigerian-address-form/app/thanks/page.tsx @@ -0,0 +1,7 @@ +export default function Thanks() { + return ( +
+

Order placed

+
+ ); +} diff --git a/templates/nigerian-address-form/components.json b/templates/nigerian-address-form/components.json new file mode 100644 index 0000000..f382eb7 --- /dev/null +++ b/templates/nigerian-address-form/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/templates/nigerian-address-form/components/nigerian-address-form.test.tsx b/templates/nigerian-address-form/components/nigerian-address-form.test.tsx new file mode 100644 index 0000000..5c5e460 --- /dev/null +++ b/templates/nigerian-address-form/components/nigerian-address-form.test.tsx @@ -0,0 +1,85 @@ +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"; + +afterEach(cleanup); + +async function fill(postcode: string) { + const user = userEvent.setup(); + await user.type(screen.getByLabelText("Full name"), "Ngozi Eze"); + await user.type(screen.getByLabelText("Phone number"), "0803 123 4567"); + await user.type(screen.getByLabelText("Street address"), "5 Aminu Kano Crescent"); + await user.type(screen.getByLabelText("Town or city"), "Wuse II"); + await user.selectOptions(screen.getByLabelText("State"), "Federal Capital Territory"); + if (postcode) await user.type(screen.getByLabelText(/^Postcode/), postcode); + return user; +} + +it("sends the address in canonical form", async () => { + const onSubmit = vi.fn(); + render(); + const user = await fill("fc 02 a09 db 09"); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(onSubmit).toHaveBeenCalledWith({ + name: "Ngozi Eze", + phone: "+2348031234567", + street: "5 Aminu Kano Crescent", + city: "Wuse II", + lga: "", + state: "Federal Capital Territory", + postcode: "FC-02-A09-DB-09", + }); +}); + +it("tidies the postcode when the customer leaves the field", async () => { + render(); + const user = userEvent.setup(); + await user.type(screen.getByLabelText(/^Postcode/), "fc02a09db09"); + await user.tab(); + expect(screen.getByLabelText(/^Postcode/)).toHaveProperty("value", "FC-02-A09-DB-09"); +}); + +it("offers a fix for look-alike characters", async () => { + render(); + const user = userEvent.setup(); + const input = screen.getByLabelText(/^Postcode/); + await user.type(input, "FC-O2-A09-DB-O9"); + await user.tab(); + expect(input.getAttribute("aria-invalid")).toBe("true"); + await user.click(screen.getByRole("button", { name: "Use FC-02-A09-DB-09" })); + expect(input).toHaveProperty("value", "FC-02-A09-DB-09"); + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(document.activeElement).toBe(input); +}); + +it("explains an old six-digit code and does not send it", async () => { + const onSubmit = vi.fn(); + render(); + const user = await fill("100271"); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(screen.getByRole("alert").textContent).toMatch(/old six-digit code/); + expect(document.activeElement).toBe(screen.getByLabelText(/^Postcode/)); + expect(onSubmit).not.toHaveBeenCalled(); +}); + +it("requires the postcode when asked to", async () => { + render(); + const user = await fill(""); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(screen.getByRole("alert").textContent).toBe("Enter your postcode."); +}); + +it("shows errors the server returns", async () => { + render( ({ postcode: "NIPOST has no building with this postcode." })} />); + const user = await fill("FC-02-A09-DB-09"); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect((await screen.findByRole("alert")).textContent).toBe("NIPOST has no building with this postcode."); +}); + +it("says so when sending fails", async () => { + render( Promise.reject(new Error("offline"))} />); + const user = await fill(""); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect((await screen.findByRole("alert")).textContent).toBe("We could not send your address. Try again."); +}); diff --git a/templates/nigerian-address-form/components/nigerian-address-form.tsx b/templates/nigerian-address-form/components/nigerian-address-form.tsx new file mode 100644 index 0000000..ddc5269 --- /dev/null +++ b/templates/nigerian-address-form/components/nigerian-address-form.tsx @@ -0,0 +1,171 @@ +"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 Address, + type AddressErrors, + type AddressInput, + addressSchema, + checkPostcode, + EXAMPLE, + fieldErrors, + STATES, +} 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; + postcodeRequired?: boolean; + submitLabel?: string; +}; + +type Name = keyof AddressInput; + +const noop = () => () => {}; + +export function NigerianAddressForm({ onSubmit, postcodeRequired = false, submitLabel = "Continue" }: Props) { + const form = useRef(null); + const [errors, setErrors] = useState({}); + const [suggestion, setSuggestion] = useState(); + const [failed, setFailed] = useState(false); + const [pending, startTransition] = useTransition(); + // Until React runs, a click would submit the address as a plain GET. + const ready = useSyncExternalStore(noop, () => true, () => false); + + function show(next: AddressErrors) { + setErrors(next); + const first = Object.keys(next)[0]; + if (first) form.current?.querySelector(`[name="${first}"]`)?.focus(); + } + + 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)); + } + + function applySuggestion() { + const input = form.current?.elements.namedItem("postcode"); + if (input instanceof HTMLInputElement && suggestion) { + input.value = suggestion; + // The button that had focus is about to go away. + input.focus(); + } + setSuggestion(undefined); + setErrors(({ postcode: _, ...rest }) => rest); + } + + function submit(event: FormEvent) { + event.preventDefault(); + setFailed(false); + const result = addressSchema({ postcodeRequired }).safeParse( + Object.fromEntries(new FormData(event.currentTarget)), + ); + if (!result.success) return show(fieldErrors(result.error)); + setErrors({}); + startTransition(async () => { + try { + const refused = await onSubmit(result.data); + if (refused) show(refused); + } catch { + setFailed(true); + } + }); + } + + function field(name: Name) { + const error = errors[name]; + return { + id: name, + name, + "aria-invalid": error ? true : undefined, + "aria-describedby": error ? `${name}-error` : undefined, + }; + } + + function error(name: Name) { + return errors[name] && {errors[name]}; + } + + return ( +
+ + + Full name + + {error("name")} + + + Phone number + + {error("phone")} + + + Street address + + {error("street")} + + + Town or city + + {error("city")} + + + Local government area (optional) + + {error("lga")} + + + State + + + Choose a state + + {STATES.map((state) => ( + + {state} + + ))} + + {error("state")} + + + Postcode{postcodeRequired ? "" : " (optional)"} + + + The 11-character NIPOST postcode. Find yours at{" "} + + postcode.gov.ng + + . + + {error("postcode")} + {suggestion && ( + + )} + + {failed && We could not send your address. Try again.} + + +
+ ); +} diff --git a/templates/nigerian-address-form/components/ui/button.tsx b/templates/nigerian-address-form/components/ui/button.tsx new file mode 100644 index 0000000..997e0c7 --- /dev/null +++ b/templates/nigerian-address-form/components/ui/button.tsx @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..8ae7f29 --- /dev/null +++ b/templates/nigerian-address-form/components/ui/field.tsx @@ -0,0 +1,238 @@ +"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 ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +