diff --git a/.changeset/client-spec-0-3-0.md b/.changeset/client-spec-0-3-0.md
new file mode 100644
index 0000000..96f9dff
--- /dev/null
+++ b/.changeset/client-spec-0-3-0.md
@@ -0,0 +1,5 @@
+---
+'@gatepost/client': patch
+---
+
+The package implements spec 0.3.0. That version adds the postcode field and its messages, and leaves the client contract as it was, so every call behaves as before.
diff --git a/.changeset/core-spec-0-3-0.md b/.changeset/core-spec-0-3-0.md
new file mode 100644
index 0000000..1ee4996
--- /dev/null
+++ b/.changeset/core-spec-0-3-0.md
@@ -0,0 +1,5 @@
+---
+'@gatepost/core': patch
+---
+
+The package implements spec 0.3.0. That version adds the postcode field and its messages, and leaves the grammar as it was, so every function gives the same results as before.
diff --git a/.changeset/first-field-release.md b/.changeset/first-field-release.md
new file mode 100644
index 0000000..6ca9e11
--- /dev/null
+++ b/.changeset/first-field-release.md
@@ -0,0 +1,5 @@
+---
+'@gatepost/field': minor
+---
+
+Add the custom element `gatepost-postcode-field`. A plain HTML form submits the canonical form of the postcode. The field checks the format offline, offers the fix of a look-alike character, accepts or refuses a legacy postcode, and works with the keyboard alone. With a publishable key, it asks NIPOST's gateway about each whole postcode and names its place. A failed or negative answer never stops the form. The field refuses a secret key and sends no request with it. With `gps` and a key, it fills in the postcode of the user's location, or only the part that the accuracy supports, when the user presses its button.
diff --git a/.changeset/first-react-release.md b/.changeset/first-react-release.md
new file mode 100644
index 0000000..7662a25
--- /dev/null
+++ b/.changeset/first-react-release.md
@@ -0,0 +1,5 @@
+---
+'@gatepost/react': minor
+---
+
+Add the React package. Its component renders the postcode field of `@gatepost/field` on the server and in the browser, for React 18 and 19, and calls a prop for each event of the field.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9917feb..156b8c5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,6 +33,8 @@ jobs:
enable-cache: false
- run: corepack enable
- run: pnpm install --frozen-lockfile
+ # The tests of the field run in Chromium.
+ - run: pnpm exec playwright install --with-deps chromium
- run: pnpm check
node-22:
@@ -48,8 +50,42 @@ jobs:
node-version: 22
- run: corepack enable
- run: pnpm install --frozen-lockfile
+ - run: pnpm exec playwright install --with-deps chromium
- run: pnpm test
+ # pnpm check runs the journeys of the field in Chromium. This job runs them in the other two
+ # engines, so a page in Firefox or Safari gets the same field.
+ browsers:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ browser: [firefox, webkit]
+ env:
+ BROWSER: ${{ matrix.browser }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ submodules: true
+ persist-credentials: false
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 24
+ - run: corepack enable
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm exec playwright install --with-deps "$BROWSER"
+ - run: pnpm build
+ - run: pnpm --filter @gatepost/field exec playwright test --project="$BROWSER"
+ # A failure in an engine that nobody runs by hand needs its trace.
+ - if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: traces-${{ matrix.browser }}
+ path: packages/field/test-results/
+ if-no-files-found: ignore
+ retention-days: 7
+
commits:
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
diff --git a/.gitignore b/.gitignore
index 2ec3672..e04eda5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,5 +5,8 @@ dist/
coverage/
temp/
reports/
+test-results/
+playwright-report/
.stryker-tmp/
*.tsbuildinfo
+.vitest/
diff --git a/.prettierignore b/.prettierignore
index f53dad2..c4ee747 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -5,4 +5,5 @@ spec
**/temp
**/etc/*.api.md
**/spec-data.ts
+**/spec-messages.ts
**/CHANGELOG.md
diff --git a/AGENTS.md b/AGENTS.md
index 2f12651..aa4b4d8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# AGENTS.md
-This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the TypeScript packages. Today it has `@gatepost/core`, `@gatepost/client` and the private `@gatepost/mock-server`.
+This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the TypeScript packages. Today it has `@gatepost/core`, `@gatepost/client`, `@gatepost/field`, `@gatepost/react` and the private `@gatepost/mock-server`.
## Read first
@@ -10,7 +10,7 @@ This repo is part of Gatepost, unofficial open-source developer tools for Nigeri
## Rules that no config file shows
-- If the task needs a public symbol that the Interface section of `spec/grammar.md` does not list, add nothing, not even an internal helper. Stop, and say that a spec change must come first (API-1, CS-8).
+- If the task needs a public symbol that no Interface section of the spec lists, in `spec/grammar.md`, `spec/client.md` or `spec/field.md`, add nothing, not even an internal helper. Stop, and say that a spec change must come first (API-1, CS-8).
- Call only the documented NIPOST gateway endpoints.
- Commit only NIPOST's published test codes and synthetic values, such as `FC-01-Z99-ZZ-01`.
- Sign off each commit with `git commit -s`. The person who opens the pull request is the only author. Leave out co-author lines for AI tools.
diff --git a/CONTEXT.md b/CONTEXT.md
index fcc8182..4b82f5f 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -169,6 +169,18 @@ _Avoid_: input, widget, picker
To show the user what a postcode points to before the app stores it.
_Avoid_: verify, validate (for this step)
+**Check status**:
+What the WooCommerce plugin learned about the postcode of an order: `valid`, `invalid`, `unchecked` or `error`. The order meta `_gatepost_postcode_check` holds it.
+_Avoid_: verification status, validation result
+
+**Form value**:
+The text that a field gives its form when the form is sent: the canonical form of a postcode, the 6 digits of a legacy postcode, or the text without white space at the start and the end.
+_Avoid_: submitted value, output
+
+**Message catalogue**:
+A data file that holds the text of a UI in one language, one message for each key.
+_Avoid_: strings file, translations, locale file
+
**Tell**:
A habit that makes code look machine-written, such as very long lines.
_Avoid_: smell (smells are design problems), anti-pattern
diff --git a/README.md b/README.md
index e240310..13cbacd 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,8 @@
| ----------------------------------------------- | ----------------------------------------------- | -------------------------- |
| [`@gatepost/core`](packages/core) | Parse, check and format postcodes offline. | Alpha, not on npm yet |
| [`@gatepost/client`](packages/client) | Call NIPOST's gateway, with retries and limits. | Alpha, not on npm yet |
+| [`@gatepost/field`](packages/field) | A postcode field for any web form. | Alpha, not on npm yet |
+| [`@gatepost/react`](packages/react) | The postcode field as a React component. | Alpha, not on npm yet |
| [`@gatepost/mock-server`](packages/mock-server) | Mock NIPOST's gateway for client tests. | Private, never goes to npm |
## Example
diff --git a/docs/develop.md b/docs/develop.md
index a07c581..f231c62 100644
--- a/docs/develop.md
+++ b/docs/develop.md
@@ -8,8 +8,13 @@ You need Node 24, `corepack enable`, Python 3.11 or later and `uv`.
git clone --recurse-submodules https://github.com/gatepost-dev/js
pnpm install
+ pnpm exec playwright install chromium
pnpm check
+The tests of the field and of the React package run in Chromium, which Playwright installs once.
+`pnpm check` runs the journeys of the field too, with a test page on port 3000 and the mock server
+on port 4010, so both ports must be free.
+
`pnpm check` also builds the packages. Then it runs publint, attw, size-limit and API Extractor on
the result, and it starts each built package once. It checks the licence of each dependency and of
each file, and it checks the workflows with zizmor. It starts REUSE and zizmor with `uvx` at pinned
diff --git a/eslint.config.js b/eslint.config.js
index 31dec61..ee88aa3 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -28,6 +28,7 @@ export default defineConfig(
'**/.stryker-tmp/**',
'spec/**',
'**/spec-data.ts',
+ '**/spec-messages.ts',
],
},
eslint.configs.recommended,
@@ -90,6 +91,8 @@ export default defineConfig(
},
],
'jsdoc/tag-lines': ['error', 'any', { startLines: 1 }],
+ // A getter documents a property, so a @returns tag would only repeat its summary.
+ 'jsdoc/require-returns': ['error', { checkGetters: false }],
'jsdoc/check-tag-names': [
'error',
{ definedTags: ['packageDocumentation', 'internal', 'defaultValue'] },
diff --git a/package.json b/package.json
index a0fdd48..3ef4474 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"scripts": {
"check": "run-s check:source check:build check:policy",
"check:source": "run-s generate:check format:check lint typecheck test",
- "check:build": "run-s build package:check",
+ "check:build": "run-s build package:check test:browser",
"check:policy": "run-s tells context:check check:licences workflows:check",
"check:licences": "run-s licences:check licence-files:check reuse:check",
"format": "prettier --write .",
@@ -17,6 +17,7 @@
"lint": "eslint . --max-warnings 0",
"typecheck": "pnpm -r run typecheck",
"test": "pnpm -r run test && node --test scripts/*.test.mjs",
+ "test:browser": "pnpm -r run test:browser",
"build": "pnpm -r run build",
"package:check": "pnpm -r run package:check",
"tells": "python3 spec/scripts/check-tells",
@@ -25,27 +26,34 @@
"licence-files:check": "pnpm -r run licence-files:check",
"reuse:check": "uvx --with charset-normalizer==3.5.2 reuse@6.2.0 lint",
"workflows:check": "uvx zizmor@1.30.1 --offline .github/workflows",
- "generate": "node scripts/generate-spec-data.mjs",
- "generate:check": "node scripts/generate-spec-data.mjs --check"
+ "generate": "node scripts/generate-spec-data.mjs && node scripts/generate-messages.mjs",
+ "generate:check": "run-s generate:check:*",
+ "generate:check:data": "node scripts/generate-spec-data.mjs --check",
+ "generate:check:messages": "node scripts/generate-messages.mjs --check"
},
"packageManager": "pnpm@12.8.1",
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
+ "@axe-core/playwright": "^4.13.0",
"@changesets/cli": "^3.0.3",
"@commitlint/cli": "^21.2.3",
"@commitlint/config-conventional": "^21.2.3",
"@eslint-community/eslint-plugin-eslint-comments": "^4.8.1",
"@eslint/js": "^10.0.1",
"@microsoft/api-extractor": "^7.59.3",
+ "@playwright/test": "^1.63.0",
"@size-limit/preset-small-lib": "^14.1.0",
"@stryker-mutator/core": "^10.0.0",
"@types/node": "^22.20.4",
+ "@vitest/browser-playwright": "^5.0.3",
"@vitest/coverage-v8": "^5.0.3",
"ajv": "^8.20.0",
+ "axe-core": "^4.13.0",
"eslint": "^10.11.0",
"eslint-plugin-jsdoc": "^65.0.1",
"fast-check": "^4.10.2",
"npm-run-all2": "^9.0.3",
+ "playwright": "^1.63.0",
"prettier": "^3.9.9",
"publint": "^0.3.24",
"size-limit": "^14.1.0",
diff --git a/packages/client/README.md b/packages/client/README.md
index 873cff1..d7ed2f6 100644
--- a/packages/client/README.md
+++ b/packages/client/README.md
@@ -109,7 +109,7 @@ The client also takes a `transport` option, a function like `fetch`, and each ca
| ------------- | ---------------------------------------------- |
| Node | 22 or later, or a current browser with `fetch` |
| Module format | ESM only |
-| Gatepost spec | 0.2.0 |
+| Gatepost spec | 0.3.0 |
## Docs
diff --git a/packages/client/etc/client.api.md b/packages/client/etc/client.api.md
index 352b142..bd0c4bd 100644
--- a/packages/client/etc/client.api.md
+++ b/packages/client/etc/client.api.md
@@ -113,6 +113,6 @@ export interface ReverseUnit {
}
// @public
-export const SPEC_VERSION = "0.2.0";
+export const SPEC_VERSION = "0.3.0";
```
diff --git a/packages/client/src/version.ts b/packages/client/src/version.ts
index 6051b64..49cad0c 100644
--- a/packages/client/src/version.ts
+++ b/packages/client/src/version.ts
@@ -8,7 +8,7 @@
* ```ts
* import { SPEC_VERSION } from '@gatepost/client';
*
- * SPEC_VERSION; // '0.2.0'
+ * SPEC_VERSION; // '0.3.0'
* ```
*/
-export const SPEC_VERSION = '0.2.0';
+export const SPEC_VERSION = '0.3.0';
diff --git a/packages/core/README.md b/packages/core/README.md
index dc23b14..26766e0 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -107,7 +107,7 @@ The package checks the form of a postcode. It cannot tell whether a building has
| ------------- | ----------------------------------- |
| Node | 22 or later, or any current browser |
| Module format | ESM only |
-| Gatepost spec | 0.2.0 |
+| Gatepost spec | 0.3.0 |
## Docs
diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md
index ef7b226..6482ed6 100644
--- a/packages/core/etc/core.api.md
+++ b/packages/core/etc/core.api.md
@@ -68,7 +68,7 @@ export interface Segments {
}
// @public
-export const SPEC_VERSION = "0.2.0";
+export const SPEC_VERSION = "0.3.0";
// @public
export function stateName(stateCode: string): string | null;
diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts
index ab60c2f..df3a3ce 100644
--- a/packages/core/src/version.ts
+++ b/packages/core/src/version.ts
@@ -8,7 +8,7 @@
* ```ts
* import { SPEC_VERSION } from '@gatepost/core';
*
- * SPEC_VERSION; // '0.2.0'
+ * SPEC_VERSION; // '0.3.0'
* ```
*/
-export const SPEC_VERSION = '0.2.0';
+export const SPEC_VERSION = '0.3.0';
diff --git a/packages/field/LICENSE b/packages/field/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/packages/field/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/packages/field/NOTICE b/packages/field/NOTICE
new file mode 100644
index 0000000..bc94510
--- /dev/null
+++ b/packages/field/NOTICE
@@ -0,0 +1,5 @@
+Gatepost
+Copyright 2026 The Gatepost authors
+
+This product is unofficial. The Nigerian Postal Service (NIPOST) did not make it and does not endorse it.
+Gatepost uses the name NIPOST only to describe compatibility with NIPOST's National Digital Postcode.
diff --git a/packages/field/README.md b/packages/field/README.md
new file mode 100644
index 0000000..985b370
--- /dev/null
+++ b/packages/field/README.md
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+@gatepost/field
+
+A postcode field for any web form. It checks Nigeria's new postcode while the user types.
+
+
+
+
+
+
+
+
+ Docs
+ ·
+ Playground
+ ·
+ Field spec
+ ·
+ Contributing
+ ·
+ Discussions
+
+
+> Unofficial. Not made or endorsed by NIPOST.
+
+## Install
+
+```sh
+pnpm add @gatepost/field
+```
+
+The first alpha is not on npm yet, so this command and the CDN address below do not work today.
+
+Import `@gatepost/field` once in your app, and the page can use the element. A page with no build step can load the one-file build from a CDN instead, as the quickstart does. Once a release exists, pin its version in the CDN address of a production page.
+
+## Quickstart
+
+```html
+
+
+```
+
+The element is empty until the script runs, so the layout moves when it loads, and a page with no JavaScript sends no postcode. A `` outside the element cannot name the input inside it, so give the text with the `label` attribute.
+
+A user who types `fc 01 z99 zz 01` sends `postcode=FC-01-Z99-ZZ-01`. With no key, the field checks the format offline and sends no request.
+
+## Use
+
+### Check postcodes with NIPOST
+
+```html
+
+
+```
+
+Use a publishable key, which starts with `nipost_pk_`, and add your site to its allowed origins in NIPOST's dashboard. Anyone can read a key in a page, so the field refuses a secret key: it sends no request, and it logs one console error for you. A key holds a lookup level, and a call above it fails. The field then says that it could not check the postcode. See [`@gatepost/client`](https://github.com/gatepost-dev/js/tree/main/packages/client#readme).
+
+A postcode that the gateway does not know, or a failed request, never stops the form. The gateway's data is new, so check the postcode again on your server.
+
+| Attribute | Values | Default |
+| --------------------------------------- | ---------------------------- | ----------------------------------- |
+| `name`, `value`, `required`, `disabled` | as for a native input | none |
+| `label` | the visible label | `Postcode` |
+| `api-key` | a publishable key | none, so the field sends no request |
+| `base-url` | the gateway's address | `https://api.postcode.gov.ng` |
+| `confirm` | `none`, `level1` or `level2` | `level1` |
+| `gps` | present or absent | absent |
+| `legacy` | `accept` or `reject` | `accept` |
+
+### Listen for changes
+
+```html
+
+
+
+
+```
+
+The events bubble, so a page can listen on the form. At level 2, `gatepost-confirm` gives the recent house address of the postcode. The field never shows it, and your page should not show it to the person who typed the postcode, who can have typed any postcode.
+
+| Event | `detail` |
+| ------------------ | ----------------------------------------------------------------------------------------------- |
+| `gatepost-change` | `value`, `postcode`, `source` (`typed`, `pasted`, `suggestion` or `gps`) and `accuracyM` |
+| `gatepost-confirm` | `lookup`, the result of the lookup |
+| `gatepost-error` | `code`, an error code of `@gatepost/client`, or `secret_key`, `gps_denied` or `gps_unavailable` |
+
+### Change the words and the look
+
+```html
+
+
+
+
+```
+
+The `messages` property replaces messages by key, and the field keeps the English text for each key that you leave out. [`field_en.arb`](https://github.com/gatepost-dev/spec/blob/main/messages/field_en.arb) in the spec repo lists each key, with a note on when it shows.
+
+You can set the properties `messages` and `value` before the browser defines the element, for example when a framework renders the field before the script loads. The field takes them when it starts.
+
+The tokens are `--gatepost-text`, `--gatepost-muted`, `--gatepost-background`, `--gatepost-border`, `--gatepost-accent`, `--gatepost-error`, `--gatepost-warning`, `--gatepost-radius` and `--gatepost-font`. The defaults are for a light page: the label, the hint and the message take the page's own background, and the defaults meet a contrast of 4.5:1 on white. On a dark page, set the seven colour tokens, as below, and check your own colours on your page's background.
+
+On a dark page, add these rules to your style sheet:
+
+```css
+gatepost-postcode-field {
+ --gatepost-text: #f2f5f4;
+ --gatepost-muted: #b9c2be;
+ --gatepost-background: #1c2321;
+ --gatepost-border: #8f9b96;
+ --gatepost-accent: #5ad1bf;
+ --gatepost-error: #ff9d94;
+ --gatepost-warning: #ffb95c;
+}
+```
+
+The parts `field`, `label`, `hint`, `input`, `message` and `button` take `::part()` rules.
+
+## Features
+
+- Checks the format as the user types, and shows one plain message for each mistake. It waits until the user leaves the input, or until the text is long enough for a whole postcode.
+- Offers a fix for look-alike characters, such as the letter O in place of a zero, and uses it only when the user presses its button.
+- Accepts an old 6-digit postcode, or refuses it when you set `legacy="reject"`.
+- Submits the form on Enter, as a native input does.
+- With a publishable key, asks NIPOST's gateway about each whole postcode, and names its place.
+- With `gps`, puts the postcode of the user's location in the input, or only the part that the accuracy supports.
+- Works with the keyboard alone and with screen readers, and meets WCAG 2.2 AA on a light page. A dark page needs the colour tokens (see "Change the words and the look").
+- Stores nothing, sets no cookie and sends no telemetry.
+- Is one file of about 10 kB, minified and brotlied, with `@gatepost/core` and `@gatepost/client` inside.
+
+## Requirements
+
+| Requirement | Version |
+| ------------- | ---------------------------------------------------------- |
+| Browser | a current Chrome, Edge or Firefox, or Safari 16.4 or later |
+| Module format | ESM only |
+| Gatepost spec | 0.3.0 |
+
+The package exports `SPEC_VERSION`, the version of the Gatepost spec that it implements. A server can import the package: it defines the element only in a browser.
+
+## Docs
+
+The [docs site](https://gatepost-dev.github.io/docs/) and the [playground](https://gatepost-dev.github.io/docs/playground/) lead to the rest of Gatepost. The API report is in [`etc/field.api.md`](https://github.com/gatepost-dev/js/blob/main/packages/field/etc/field.api.md). [`field.md`](https://github.com/gatepost-dev/spec/blob/main/field.md) in the spec repo defines the settings, the events, the states and the rules of the field. React apps can use [`@gatepost/react`](https://github.com/gatepost-dev/js/tree/main/packages/react).
+
+## Support
+
+Ask questions in [GitHub Discussions](https://github.com/gatepost-dev/js/discussions). Report bugs in [GitHub Issues](https://github.com/gatepost-dev/js/issues). Report security problems through the [private reporting form](https://github.com/gatepost-dev/js/security/advisories/new).
+
+## Develop
+
+Read [`docs/develop.md`](https://github.com/gatepost-dev/js/blob/main/docs/develop.md) and the [contributing guide](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request.
+
+## Licence
+
+Apache-2.0. See [`LICENSE`](https://github.com/gatepost-dev/js/blob/main/LICENSE) and [`NOTICE`](https://github.com/gatepost-dev/js/blob/main/NOTICE).
diff --git a/packages/field/api-extractor.json b/packages/field/api-extractor.json
new file mode 100644
index 0000000..115cb66
--- /dev/null
+++ b/packages/field/api-extractor.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
+ "mainEntryPointFilePath": "/dist/index.d.ts",
+ "compiler": {
+ "overrideTsconfig": {
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": [] },
+ "files": ["dist/index.d.ts"]
+ }
+ },
+ "apiReport": {
+ "enabled": true,
+ "reportFolder": "/etc/",
+ "reportTempFolder": "/temp/"
+ },
+ "docModel": { "enabled": false },
+ "dtsRollup": { "enabled": false },
+ "tsdocMetadata": { "enabled": false },
+ "messages": {
+ "extractorMessageReporting": {
+ "ae-undocumented": { "logLevel": "error", "addToApiReportFile": false },
+ "ae-missing-release-tag": { "logLevel": "none" }
+ }
+ }
+}
diff --git a/packages/field/etc/field.api.md b/packages/field/etc/field.api.md
new file mode 100644
index 0000000..29e6c97
--- /dev/null
+++ b/packages/field/etc/field.api.md
@@ -0,0 +1,72 @@
+## API Report File for "@gatepost/field"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { LookupResult } from '@gatepost/client';
+import { Postcode } from '@gatepost/core';
+import { PostcodeErrorCode } from '@gatepost/client';
+
+// @public
+export interface ChangeDetail {
+ readonly accuracyM: number | null;
+ readonly postcode: Postcode | null;
+ readonly source: ChangeSource;
+ readonly value: string;
+}
+
+// @public
+export type ChangeSource = "typed" | "pasted" | "suggestion" | "gps";
+
+// @public
+export interface ConfirmDetail {
+ readonly lookup: LookupResult;
+}
+
+// @public
+export interface ErrorDetail {
+ readonly code: FieldErrorCode;
+}
+
+// @public
+export type FieldErrorCode = PostcodeErrorCode | "secret_key" | "gps_denied" | "gps_unavailable";
+
+// @public
+export type MessageKey = "label" | "hint" | "empty" | "bad_character" | "bad_length" | "unknown_state" | "bad_lga" | "bad_area" | "bad_unit" | "suggestion" | "use_suggestion" | "legacy_accepted" | "legacy_rejected" | "valid" | "checking" | "not_found" | "confirmed" | "confirmed_place" | "check_failed" | "secret_key" | "use_location" | "locating" | "gps_coarse" | "gps_not_found" | "gps_denied" | "gps_unavailable";
+
+// @public
+export type Messages = Readonly>;
+
+// @public
+export interface PostcodeFieldElement extends HTMLElement {
+ attributeChangedCallback(name: string): void;
+ checkValidity(): boolean;
+ connectedCallback(): void;
+ disconnectedCallback(): void;
+ readonly form: HTMLFormElement | null;
+ formDisabledCallback(disabled: boolean): void;
+ formResetCallback(): void;
+ formStateRestoreCallback(state: unknown): void;
+ get messages(): Messages;
+ set messages(messages: Partial);
+ reportValidity(): boolean;
+ readonly validationMessage: string;
+ readonly validity: ValidityState;
+ get value(): string;
+ set value(text: string);
+ readonly willValidate: boolean;
+}
+
+// @public
+export const PostcodeFieldElement: {
+ readonly prototype: PostcodeFieldElement;
+ new (): PostcodeFieldElement;
+ readonly formAssociated: true;
+ readonly observedAttributes: readonly string[];
+};
+
+// @public
+export const SPEC_VERSION = "0.3.0";
+
+```
diff --git a/packages/field/package.json b/packages/field/package.json
new file mode 100644
index 0000000..3ae6e21
--- /dev/null
+++ b/packages/field/package.json
@@ -0,0 +1,74 @@
+{
+ "name": "@gatepost/field",
+ "version": "0.0.0",
+ "description": "Unofficial. Not made or endorsed by NIPOST. A postcode field for any web form.",
+ "license": "Apache-2.0",
+ "homepage": "https://github.com/gatepost-dev/js/tree/main/packages/field#readme",
+ "bugs": "https://github.com/gatepost-dev/js/issues",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/gatepost-dev/js.git",
+ "directory": "packages/field"
+ },
+ "type": "module",
+ "sideEffects": [
+ "./dist/index.js",
+ "./dist/element.js"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./element": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/element.js"
+ }
+ },
+ "files": [
+ "dist",
+ "LICENSE",
+ "NOTICE"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "keywords": [
+ "nigeria",
+ "postcode",
+ "address",
+ "nipost",
+ "custom-element",
+ "form"
+ ],
+ "size-limit": [
+ {
+ "name": "module",
+ "path": "dist/index.js",
+ "limit": "20 KB"
+ },
+ {
+ "name": "element",
+ "path": "dist/element.js",
+ "limit": "20 KB"
+ }
+ ],
+ "dependencies": {
+ "@gatepost/client": "workspace:^",
+ "@gatepost/core": "workspace:^"
+ },
+ "scripts": {
+ "typecheck": "tsc -p tsconfig.json",
+ "test": "vitest run --coverage",
+ "test:browser": "playwright test --project=chromium",
+ "build": "tsdown",
+ "package:check": "run-s package:lint package:smoke",
+ "package:lint": "publint && attw --pack --profile esm-only && size-limit && api-extractor run",
+ "package:smoke": "node scripts/smoke.mjs",
+ "api:update": "api-extractor run --local",
+ "licence-files:check": "cmp ../../LICENSE LICENSE && cmp ../../NOTICE NOTICE"
+ },
+ "devDependencies": {
+ "@gatepost/mock-server": "workspace:*"
+ }
+}
diff --git a/packages/field/playwright.config.ts b/packages/field/playwright.config.ts
new file mode 100644
index 0000000..017e87d
--- /dev/null
+++ b/packages/field/playwright.config.ts
@@ -0,0 +1,29 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { defineConfig, devices } from '@playwright/test';
+
+// pnpm check runs Chromium. CI also runs Firefox and WebKit, the engine of Safari.
+export default defineConfig({
+ testDir: 'test/journeys',
+ testMatch: '**/*.test.ts',
+ forbidOnly: true,
+ reporter: 'list',
+ use: { baseURL: 'http://localhost:3000', trace: 'retain-on-failure' },
+ webServer: {
+ command: 'node test/journeys/serve.mjs',
+ url: 'http://localhost:3000/form',
+ reuseExistingServer: false,
+ },
+ projects: [
+ { name: 'chromium', use: devices['Desktop Chrome'] },
+ {
+ name: 'firefox',
+ use: {
+ ...devices['Desktop Firefox'],
+ // Firefox would hold the location prompt open. A user who refuses it gets this answer.
+ launchOptions: { firefoxUserPrefs: { 'permissions.default.geo': 2 } },
+ },
+ },
+ { name: 'webkit', use: devices['Desktop Safari'] },
+ ],
+});
diff --git a/packages/field/scripts/smoke.mjs b/packages/field/scripts/smoke.mjs
new file mode 100644
index 0000000..2970c6b
--- /dev/null
+++ b/packages/field/scripts/smoke.mjs
@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+// Starts the built package as a user does: by its name, through the exports map of package.json.
+// Node has no DOM, as on a server that renders a page, so this also shows that a server can
+// import either entry. The browser tests run the element itself.
+import * as field from '@gatepost/field';
+import * as element from '@gatepost/field/element';
+
+for (const [entry, exports] of [
+ ['@gatepost/field', field],
+ ['@gatepost/field/element', element],
+]) {
+ if (exports.SPEC_VERSION !== '0.3.0' || typeof exports.PostcodeFieldElement !== 'function') {
+ throw new Error(`${entry} gave ${Object.keys(exports).join(', ')} on a server.`);
+ }
+}
diff --git a/packages/field/src/element.ts b/packages/field/src/element.ts
new file mode 100644
index 0000000..b68e11f
--- /dev/null
+++ b/packages/field/src/element.ts
@@ -0,0 +1,664 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { PostcodeClient, PostcodeError } from '@gatepost/client';
+import type { Postcode } from '@gatepost/core';
+import { findFix } from './location.js';
+import { fill } from './messages.js';
+import { readText, type Reading } from './reading.js';
+import { Requests, type Asked } from './requests.js';
+import { ENGLISH, type Messages } from './spec-messages.js';
+import {
+ lookupOutcome,
+ readingNote,
+ readingState,
+ type Note,
+ type Outcome,
+ type State,
+} from './state.js';
+import type { ChangeDetail, ChangeSource, ConfirmDetail, ErrorDetail } from './types.js';
+import { buildView, renderView, type View } from './view.js';
+
+interface Events {
+ 'gatepost-change': ChangeDetail;
+ 'gatepost-confirm': ConfirmDetail;
+ 'gatepost-error': ErrorDetail;
+}
+
+const LEVELS = { level1: 1, level2: 2 } as const;
+
+// A change of these attributes changes the validity or the lookup. The others change the view.
+// A change of `base-url` alone makes a new client and starts no lookup.
+const CHECKED_ATTRIBUTES = new Set(['required', 'api-key', 'confirm', 'legacy']);
+
+// A server has no HTMLElement. Object stands in, so a server can import the module, and the
+// guard in index.ts keeps the server from defining the element.
+const ElementBase: typeof HTMLElement =
+ typeof HTMLElement === 'undefined' ? (Object as unknown as typeof HTMLElement) : HTMLElement;
+
+// The types of the input elements that block implicit submission when a form has no submit button.
+const BLOCKING_TYPES = new Set([
+ 'text',
+ 'search',
+ 'url',
+ 'tel',
+ 'email',
+ 'password',
+ 'date',
+ 'month',
+ 'week',
+ 'time',
+ 'datetime-local',
+ 'number',
+]);
+
+function isSubmitButton(element: Element): element is HTMLButtonElement | HTMLInputElement {
+ return (
+ (element instanceof HTMLButtonElement && element.type === 'submit') ||
+ (element instanceof HTMLInputElement && (element.type === 'submit' || element.type === 'image'))
+ );
+}
+
+/**
+ * The postcode field, ``. A plain HTML form submits its form value: the
+ * canonical form of the postcode. It checks the format offline. With a publishable key in
+ * `api-key`, it also asks the gateway about each whole postcode.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+export interface PostcodeFieldElement extends HTMLElement {
+ /**
+ * The form value: the canonical form, the digits of a legacy postcode, or the trimmed text. A
+ * new value goes into the input as typed text, and raises no `gatepost-change`.
+ */
+ get value(): string;
+ set value(text: string);
+
+ /**
+ * The messages that the field shows, from the English catalogue of the spec. A new object
+ * replaces messages by key, and a key that it leaves out keeps its English text.
+ */
+ get messages(): Messages;
+ set messages(messages: Partial);
+
+ /** The form that holds the field, or null. */
+ readonly form: HTMLFormElement | null;
+
+ /** The validity of the field, as for a native input. */
+ readonly validity: ValidityState;
+
+ /** The message that the browser shows for an invalid field, or an empty text. */
+ readonly validationMessage: string;
+
+ /** True when a form checks the field before it submits. */
+ readonly willValidate: boolean;
+
+ /**
+ * Checks the field as a form does before it submits.
+ *
+ * @returns True when the field is valid. Otherwise false, after an `invalid` event.
+ */
+ checkValidity(): boolean;
+
+ /**
+ * Checks the field, and shows its error to the user when it is invalid.
+ *
+ * @returns True when the field is valid.
+ */
+ reportValidity(): boolean;
+
+ /** Reads the settings and checks the text. The browser calls it on insertion. */
+ connectedCallback(): void;
+
+ /** Cancels any request and forgets its outcome. The browser calls it on removal. */
+ disconnectedCallback(): void;
+
+ /**
+ * Applies a changed attribute. The browser calls it.
+ *
+ * @param name - The name of the attribute.
+ */
+ attributeChangedCallback(name: string): void;
+
+ /** Puts the text of the `value` attribute back. The browser calls it when the form resets. */
+ formResetCallback(): void;
+
+ /**
+ * Turns the controls off or on with the form. The browser calls it.
+ *
+ * @param disabled - True when the field or its fieldset is disabled.
+ */
+ formDisabledCallback(disabled: boolean): void;
+
+ /**
+ * Puts back the text that the user had typed, for example after the back button. The
+ * browser calls it.
+ *
+ * @param state - The text that the field saved with its form value.
+ */
+ formStateRestoreCallback(state: unknown): void;
+}
+
+class FieldElement extends ElementBase implements PostcodeFieldElement {
+ /** Makes the element a form control, so a form submits its form value. */
+ static readonly formAssociated = true;
+
+ /** The attributes that change the field when a page sets them. */
+ static readonly observedAttributes: readonly string[] = [
+ 'label',
+ 'value',
+ 'required',
+ 'api-key',
+ 'base-url',
+ 'confirm',
+ 'gps',
+ 'legacy',
+ ];
+
+ readonly #internals: ElementInternals;
+ readonly #view: View;
+ #messages: Messages = ENGLISH;
+ #reading: Reading = { kind: 'empty' };
+ #value = '';
+ #connected = false;
+ #dirty = false;
+ #errorsShown = false;
+ readonly #requests = new Requests();
+ #client: PostcodeClient | null = null;
+ #keyRefused = false;
+ #lastText = '';
+
+ /** Builds the shadow tree. The browser calls it when it creates the element. */
+ constructor() {
+ super();
+ this.#internals = this.attachInternals();
+ this.#view = buildView(this.attachShadow({ mode: 'open', delegatesFocus: true }));
+ const { input, suggestion, location } = this.#view;
+ input.addEventListener('input', (event) => {
+ const pasted = event instanceof InputEvent && event.inputType === 'insertFromPaste';
+ this.#update(pasted ? 'pasted' : 'typed', null);
+ });
+ input.addEventListener('blur', () => {
+ // The input also gets a blur when the window loses focus, but it stays the active element.
+ if (this.shadowRoot?.activeElement !== input) {
+ this.#leave();
+ }
+ });
+ input.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' && !event.isComposing) {
+ this.#submit();
+ }
+ });
+ suggestion.addEventListener('click', () => {
+ this.#useSuggestion();
+ });
+ location.addEventListener('click', () => {
+ void this.#locate();
+ });
+ this.addEventListener('invalid', () => {
+ this.#errorsShown = true;
+ this.#render();
+ });
+ }
+
+ /**
+ * The form value: the canonical form, the digits of a legacy postcode, or the trimmed text. A
+ * new value goes into the input as typed text, and raises no `gatepost-change`.
+ */
+ get value(): string {
+ return this.#value;
+ }
+
+ set value(text: string) {
+ this.#dirty = true;
+ this.#view.input.value = text;
+ this.#refresh();
+ }
+
+ /**
+ * The messages that the field shows, from the English catalogue of the spec. A new object
+ * replaces messages by key, and a key that it leaves out keeps its English text.
+ */
+ get messages(): Messages {
+ return this.#messages;
+ }
+
+ set messages(messages: Partial) {
+ // An entry that is not text, such as undefined from a missing key, keeps its English text.
+ // Plain JavaScript can pass null or undefined, which count as no messages.
+ const given = messages as Partial | null | undefined;
+ const texts = Object.entries(given ?? {}).filter(([, text]) => typeof text === 'string');
+ this.#messages = { ...ENGLISH, ...Object.fromEntries(texts) };
+ this.#setValidity();
+ this.#render();
+ }
+
+ /** The form that holds the field, or null. */
+ get form(): HTMLFormElement | null {
+ return this.#internals.form;
+ }
+
+ /** The validity of the field, as for a native input. */
+ get validity(): ValidityState {
+ return this.#internals.validity;
+ }
+
+ /** The message that the browser shows for an invalid field, or an empty text. */
+ get validationMessage(): string {
+ return this.#internals.validationMessage;
+ }
+
+ /** True when a form checks the field before it submits. */
+ get willValidate(): boolean {
+ return this.#internals.willValidate;
+ }
+
+ /**
+ * Checks the field as a form does before it submits.
+ *
+ * @returns True when the field is valid. Otherwise false, after an `invalid` event.
+ */
+ checkValidity(): boolean {
+ return this.#internals.checkValidity();
+ }
+
+ /**
+ * Checks the field, and shows its error to the user when it is invalid.
+ *
+ * @returns True when the field is valid.
+ */
+ reportValidity(): boolean {
+ return this.#internals.reportValidity();
+ }
+
+ /** Reads the settings and checks the text. The browser calls it on insertion. */
+ connectedCallback(): void {
+ this.#takeEarlyProperties();
+ this.#connected = true;
+ this.#makeClient();
+ this.#refresh();
+ }
+
+ /** Cancels any request and forgets its outcome. The browser calls it on removal. */
+ disconnectedCallback(): void {
+ this.#connected = false;
+ this.#requests.cancel();
+ this.#render();
+ }
+
+ /**
+ * Applies a changed attribute. The browser calls it.
+ *
+ * @param name - The name of the attribute.
+ */
+ attributeChangedCallback(name: string): void {
+ const defaultText = name === 'value' && !this.#dirty;
+ if (defaultText) {
+ this.#view.input.value = this.getAttribute('value') ?? '';
+ }
+ if (!this.#connected) {
+ return;
+ }
+ if (name === 'api-key' || name === 'base-url') {
+ this.#makeClient();
+ }
+ if (defaultText || CHECKED_ATTRIBUTES.has(name)) {
+ this.#refresh();
+ } else {
+ this.#render();
+ }
+ }
+
+ /** Puts the text of the `value` attribute back. The browser calls it when the form resets. */
+ formResetCallback(): void {
+ this.#dirty = false;
+ this.#errorsShown = false;
+ this.#view.input.value = this.getAttribute('value') ?? '';
+ this.#refresh();
+ }
+
+ /**
+ * Turns the controls off or on with the form. The browser calls it.
+ *
+ * @param disabled - True when the field or its fieldset is disabled.
+ */
+ formDisabledCallback(disabled: boolean): void {
+ for (const control of [this.#view.input, this.#view.suggestion, this.#view.location]) {
+ control.disabled = disabled;
+ }
+ }
+
+ /**
+ * Puts back the text that the user had typed, for example after the back button. The
+ * browser calls it.
+ *
+ * @param state - The text that the field saved with its form value.
+ */
+ formStateRestoreCallback(state: unknown): void {
+ if (typeof state === 'string') {
+ this.#dirty = true;
+ this.#view.input.value = state;
+ this.#refresh();
+ }
+ }
+
+ // A page can set `messages` or `value` before the script defines the element. The property
+ // then sits on the element itself and hides the accessor, so later writes never reach it.
+ // Remove it and set it again through the accessor.
+ #takeEarlyProperties(): void {
+ for (const name of ['messages', 'value']) {
+ if (Object.hasOwn(this, name)) {
+ const early: unknown = Reflect.get(this, name);
+ Reflect.deleteProperty(this, name);
+ Reflect.set(this, name, early);
+ }
+ }
+ }
+
+ get #legacy(): 'accept' | 'reject' {
+ return this.getAttribute('legacy') === 'reject' ? 'reject' : 'accept';
+ }
+
+ get #level(): 1 | 2 | null {
+ const confirm = this.getAttribute('confirm') ?? 'level1';
+ return confirm === 'level1' || confirm === 'level2' ? LEVELS[confirm] : null;
+ }
+
+ // A browser drops white space around a header value, so a key with a space before a secret
+ // key would reach the gateway as that secret key. The field trims the key once, for every use.
+ get #key(): string {
+ return (this.getAttribute('api-key') ?? '').trim();
+ }
+
+ #makeClient(): void {
+ const apiKey = this.#key;
+ const baseUrl = this.getAttribute('base-url');
+ this.#client = null;
+ this.#keyRefused = false;
+ if (apiKey === '') {
+ return;
+ }
+ try {
+ this.#client = new PostcodeClient(baseUrl === null ? { apiKey } : { apiKey, baseUrl });
+ } catch (error) {
+ // The client throws a TypeError for a secret key in a web page (SEC-1).
+ if (!(error instanceof TypeError)) {
+ throw error;
+ }
+ this.#keyRefused = true;
+ // eslint-disable-next-line no-console -- spec/field.md asks for one error for the developer.
+ console.error('gatepost-postcode-field: api-key holds a secret key. Use a publishable key.');
+ }
+ }
+
+ // `settle` runs after the refresh and before the event, so a listener sees the final state.
+ #update(source: ChangeSource, accuracyM: number | null, settle?: () => void): void {
+ const before = this.#value;
+ this.#dirty = true;
+ this.#refresh();
+ settle?.();
+ if (this.#value !== before) {
+ const postcode = this.#reading.kind === 'postcode' ? this.#reading.postcode : null;
+ this.#raise('gatepost-change', { value: this.#value, postcode, source, accuracyM });
+ }
+ }
+
+ // The lookup that the text and the settings call for, or null when they call for none.
+ #wanted(reading: Reading): Asked | null {
+ const level = this.#level;
+ const key = this.#key;
+ if (reading.kind !== 'postcode' || !this.#connected || level === null || key === '') {
+ return null;
+ }
+ return { code: reading.postcode.canonical, level, key };
+ }
+
+ // The text or a setting changed: read the text again, and start a lookup when it parses.
+ #refresh(): void {
+ const text = this.#view.input.value;
+ const textChanged = text !== this.#lastText;
+ this.#lastText = text;
+ const reading = readText(text);
+ this.#reading = reading;
+ if (reading.kind === 'postcode') {
+ this.#value = reading.postcode.canonical;
+ } else if (reading.kind === 'legacy' && this.#legacy === 'accept') {
+ this.#value = reading.digits;
+ } else {
+ this.#value = text.trim();
+ }
+ this.#internals.setFormValue(this.#value, text);
+ this.#setValidity();
+ this.#startLookup(reading, textChanged);
+ this.#render();
+ }
+
+ // A change that the requests do not keep cancels them. Then a whole postcode gets a lookup.
+ #startLookup(reading: Reading, textChanged: boolean): void {
+ const wanted = this.#wanted(reading);
+ if (this.#requests.cancelUnlessKept(wanted, textChanged) && wanted !== null) {
+ if (reading.kind === 'postcode') {
+ void this.#lookUp(reading.postcode, wanted);
+ }
+ }
+ }
+
+ #setValidity(): void {
+ const reading = this.#reading;
+ const { input } = this.#view;
+ if (reading.kind === 'empty' && this.hasAttribute('required')) {
+ this.#internals.setValidity({ valueMissing: true }, this.#messages.empty, input);
+ } else if (reading.kind === 'empty' || reading.kind === 'postcode') {
+ this.#internals.setValidity({});
+ } else if (reading.kind === 'legacy' && this.#legacy === 'accept') {
+ this.#internals.setValidity({});
+ } else {
+ const note = readingNote(reading, this.#legacy);
+ this.#internals.setValidity({ customError: true }, this.#text(note), input);
+ }
+ }
+
+ #leave(): void {
+ this.#errorsShown = true;
+ if (this.#reading.kind === 'postcode') {
+ this.#view.input.value = this.#reading.postcode.display;
+ // The reformat is not a change by the user, so a later refresh must not read it as one.
+ this.#lastText = this.#view.input.value;
+ this.#internals.setFormValue(this.#value, this.#view.input.value);
+ }
+ this.#render();
+ }
+
+ // A native text input submits its form on Enter by way of the default button, which is the
+ // first submit button. Without one, it submits only when it is the one field that blocks this.
+ #submit(): void {
+ const form = this.#internals.form;
+ if (form === null) {
+ return;
+ }
+ const elements = Array.from(form.elements);
+ const button = elements.find(isSubmitButton);
+ if (button !== undefined) {
+ if (!button.matches(':disabled')) {
+ form.requestSubmit(button);
+ }
+ return;
+ }
+ const blocking = elements.filter(
+ (element) =>
+ element instanceof FieldElement ||
+ (element instanceof HTMLInputElement && BLOCKING_TYPES.has(element.type)),
+ );
+ if (blocking.length <= 1) {
+ form.requestSubmit();
+ }
+ }
+
+ #useSuggestion(): void {
+ const reading = this.#reading;
+ if (reading.kind === 'error' && reading.suggestion !== null) {
+ this.#view.input.value = reading.suggestion.display;
+ this.#update('suggestion', null);
+ this.#view.input.focus();
+ }
+ }
+
+ async #lookUp(postcode: Postcode, asked: Asked): Promise {
+ if (this.#keyRefused) {
+ this.#requests.outcome = { state: 'error', note: { key: 'secret_key' } };
+ // The event waits for the render and for the change that caused the lookup.
+ queueMicrotask(() => {
+ this.#raise('gatepost-error', { code: 'secret_key' });
+ });
+ return;
+ }
+ if (this.#client === null) {
+ return;
+ }
+ const work = this.#requests.start({ state: 'checking', note: { key: 'checking' } });
+ this.#requests.asked = asked;
+ try {
+ const lookup = await this.#client.lookup(postcode, {
+ level: asked.level,
+ signal: work.signal,
+ });
+ if (work.signal.aborted) {
+ return;
+ }
+ this.#requests.remembered = asked;
+ this.#finish(lookupOutcome(lookup));
+ if (lookup.valid) {
+ this.#raise('gatepost-confirm', { lookup });
+ }
+ } catch (error) {
+ if (work.signal.aborted) {
+ return;
+ }
+ // Any failure ends the request. An error that is not the client's counts as a network error.
+ this.#finish({ state: 'error', note: { key: 'check_failed' } });
+ this.#raise('gatepost-error', {
+ code: error instanceof PostcodeError ? error.code : 'network_error',
+ });
+ }
+ }
+
+ // A press of the button cancels the lookup in progress and forgets the remembered lookup, so
+ // the postcode of the location always gets a lookup.
+ async #locate(): Promise {
+ if (this.#client === null) {
+ return;
+ }
+ const work = this.#requests.startLocation({ state: 'locating', note: { key: 'locating' } });
+ this.#render();
+ try {
+ const fix = await findFix(() => this.#client, work.signal);
+ if (work.signal.aborted) {
+ return;
+ }
+ if (fix.kind === 'postcode') {
+ this.#placePostcode(fix.postcode, fix.accuracyM);
+ } else if (fix.kind === 'failed') {
+ this.#finish({ state: 'no-location', note: { key: fix.key } });
+ if (fix.code !== null) {
+ this.#raise('gatepost-error', { code: fix.code });
+ }
+ } else if (this.#keyRefused) {
+ // The key became a secret key while the field waited for the position.
+ this.#finish({ state: 'error', note: { key: 'secret_key' } });
+ this.#raise('gatepost-error', { code: 'secret_key' });
+ } else {
+ // The key is gone: nothing is sent, and the field shows the state that its text gives.
+ this.#requests.end();
+ this.#render();
+ }
+ } catch (error) {
+ if (!work.signal.aborted) {
+ throw error;
+ }
+ }
+ }
+
+ // The request ends before the update, so the update can start the lookup of a whole postcode.
+ // A partial postcode ends in a space, so the user can type the next segment at once.
+ #placePostcode(postcode: Postcode, accuracyM: number): void {
+ const { input } = this.#view;
+ const whole = postcode.precision === 'unit';
+ this.#requests.end();
+ input.value = whole ? postcode.display : `${postcode.display} `;
+ this.#update('gps', accuracyM, () => {
+ if (!whole) {
+ this.#finish({ state: 'coarse', note: { key: 'gps_coarse' } });
+ input.focus();
+ }
+ });
+ }
+
+ #finish(outcome: Outcome): void {
+ this.#requests.finish(outcome);
+ this.#render();
+ }
+
+ // The events bubble, so a page can listen on the form for each field in it.
+ #raise(type: Type, detail: Events[Type]): void {
+ this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true }));
+ }
+
+ #text(note: Note): string {
+ return fill(this.#messages[note.key], note.values ?? {});
+ }
+
+ #state(): State {
+ const required = this.hasAttribute('required');
+ return (
+ this.#requests.outcome?.state ?? readingState(this.#reading, this.#errorsShown, required)
+ );
+ }
+
+ #noteText(state: State): string {
+ if (state === 'idle' || state === 'typing') {
+ return '';
+ }
+ return this.#text(this.#requests.outcome?.note ?? readingNote(this.#reading, this.#legacy));
+ }
+
+ #suggestionText(state: State): string | null {
+ const reading = this.#reading;
+ if (state !== 'invalid' || reading.kind !== 'error' || reading.suggestion === null) {
+ return null;
+ }
+ return fill(this.#messages.suggestion, { postcode: reading.suggestion.display });
+ }
+
+ #render(): void {
+ const state = this.#state();
+ const rejected = state === 'legacy' && this.#legacy === 'reject';
+ const showLocation = this.hasAttribute('gps') && this.#client !== null;
+ renderView(this.#view, {
+ state,
+ invalid: state === 'invalid' || rejected,
+ rejected,
+ required: this.hasAttribute('required'),
+ label: this.getAttribute('label') ?? this.#messages.label,
+ hint: this.#messages.hint,
+ note: this.#noteText(state),
+ didYouMean: this.#suggestionText(state),
+ useSuggestion: this.#messages.use_suggestion,
+ useLocation: showLocation ? this.#messages.use_location : null,
+ });
+ }
+}
+
+/** The element ``. */
+export const PostcodeFieldElement: {
+ readonly prototype: PostcodeFieldElement;
+ new (): PostcodeFieldElement;
+ /** Makes the element a form control, so a form submits its form value. */
+ readonly formAssociated: true;
+ /** The attributes that change the field when a page sets them. */
+ readonly observedAttributes: readonly string[];
+} = FieldElement;
diff --git a/packages/field/src/index.ts b/packages/field/src/index.ts
new file mode 100644
index 0000000..740e649
--- /dev/null
+++ b/packages/field/src/index.ts
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { PostcodeFieldElement } from './element.js';
+
+/**
+ * A postcode field for any web form: the custom element `gatepost-postcode-field`. It checks the
+ * format offline, and with a publishable key it asks NIPOST's gateway about the postcode.
+ *
+ * Unofficial. Not made or endorsed by NIPOST.
+ *
+ * @packageDocumentation
+ */
+export { PostcodeFieldElement };
+export type { MessageKey, Messages } from './spec-messages.js';
+export type {
+ ChangeDetail,
+ ChangeSource,
+ ConfirmDetail,
+ ErrorDetail,
+ FieldErrorCode,
+} from './types.js';
+export { SPEC_VERSION } from './version.js';
+
+// The one global change of the package (PERF-2). A server has no registry, and a page that loads
+// two copies of the field keeps the first.
+if (
+ typeof customElements !== 'undefined' &&
+ customElements.get('gatepost-postcode-field') === undefined
+) {
+ customElements.define('gatepost-postcode-field', PostcodeFieldElement);
+}
diff --git a/packages/field/src/location.ts b/packages/field/src/location.ts
new file mode 100644
index 0000000..7e851f3
--- /dev/null
+++ b/packages/field/src/location.ts
@@ -0,0 +1,132 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { PostcodeError, type PostcodeClient, type ReverseResult } from '@gatepost/client';
+import { parse, precisionForAccuracy, truncate, type Postcode } from '@gatepost/core';
+import type { FieldErrorCode } from './types.js';
+
+// The gateway searches 25 m by default and accepts at most 250 m (spec/client.md).
+const MIN_RADIUS_M = 25;
+const MAX_RADIUS_M = 250;
+// A user can wait about this long for the device. After that, typing is quicker.
+const POSITION_TIMEOUT_MS = 15_000;
+
+/**
+ * Gives the radius of a `reverse` call for a location: the accuracy in whole metres, rounded
+ * up, from 25 to 250.
+ *
+ * @param accuracyM - The accuracy of the location in metres.
+ * @returns The radius in metres.
+ * @internal
+ */
+export function radiusFor(accuracyM: number): number {
+ return Math.min(MAX_RADIUS_M, Math.max(MIN_RADIUS_M, Math.ceil(accuracyM)));
+}
+
+function partial(text: string | null): Postcode | null {
+ if (text === null) {
+ return null;
+ }
+ const parsed = parse(text, { allowPartial: true });
+ return parsed.ok ? parsed.value : null;
+}
+
+/**
+ * Gives the postcode that a location supports: the unit's postcode, or else the area or the
+ * district, truncated to the precision of the accuracy.
+ *
+ * @param result - What `reverse` found at the location.
+ * @param accuracyM - The accuracy of the location in metres.
+ * @returns The postcode, which can be partial, or null when the result holds none.
+ * @internal
+ */
+export function postcodeAtFix(result: ReverseResult, accuracyM: number): Postcode | null {
+ const found = result.unit?.postcode ?? partial(result.area) ?? partial(result.district);
+ if (found === null) {
+ return null;
+ }
+ const precision = precisionForAccuracy(accuracyM);
+ // A segment of null means that the postcode stops before that precision, so it stays whole.
+ return found.segments[precision] === null ? found : truncate(found, precision);
+}
+
+// An extension or a WebView can reject with a plain object, so the code decides, not the type.
+function isRefusal(error: unknown): boolean {
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 1;
+}
+
+function currentPosition(): Promise {
+ return new Promise((resolve, reject) => {
+ navigator.geolocation.getCurrentPosition(resolve, reject, {
+ enableHighAccuracy: true,
+ maximumAge: 0,
+ timeout: POSITION_TIMEOUT_MS,
+ });
+ });
+}
+
+/**
+ * What a location request found: a postcode, which can be partial, the message of a failure
+ * with the error code for the event, or no client to send the `reverse` call with.
+ *
+ * @internal
+ */
+export type Fix =
+ | { readonly kind: 'postcode'; readonly postcode: Postcode; readonly accuracyM: number }
+ | {
+ readonly kind: 'failed';
+ readonly key: 'gps_denied' | 'gps_unavailable' | 'gps_not_found';
+ readonly code: FieldErrorCode | null;
+ }
+ | { readonly kind: 'unsent' };
+
+/**
+ * Asks the browser for the device's location once, and the gateway for the postcode there.
+ *
+ * @param currentClient - Gives the client that the field holds, or null when it holds none. It
+ * runs when the position arrives, so a change of the key during the wait counts.
+ * @param signal - Cancels the `reverse` call.
+ * @returns What the location request found. The kind `unsent` means that the field held no
+ * client when the position arrived.
+ * @throws The platform's cancellation error when the signal aborts.
+ * @internal
+ */
+export async function findFix(
+ currentClient: () => PostcodeClient | null,
+ signal: AbortSignal,
+): Promise {
+ // Some embedded browsers and old WebViews have no location API at all.
+ if (!('geolocation' in navigator)) {
+ return { kind: 'failed', key: 'gps_unavailable', code: 'gps_unavailable' };
+ }
+ let position: GeolocationPosition;
+ try {
+ position = await currentPosition();
+ } catch (error) {
+ const refused = isRefusal(error);
+ const key = refused ? 'gps_denied' : 'gps_unavailable';
+ return { kind: 'failed', key, code: key };
+ }
+ signal.throwIfAborted();
+ const client = currentClient();
+ if (client === null) {
+ return { kind: 'unsent' };
+ }
+ const { latitude, longitude, accuracy } = position.coords;
+ let result: ReverseResult;
+ try {
+ result = await client.reverse(latitude, longitude, {
+ maxDistanceM: radiusFor(accuracy),
+ signal,
+ });
+ } catch (error) {
+ // A cancelled call is the one failure that ends with no outcome.
+ signal.throwIfAborted();
+ const code = error instanceof PostcodeError ? error.code : 'network_error';
+ return { kind: 'failed', key: 'gps_unavailable', code };
+ }
+ const postcode = postcodeAtFix(result, accuracy);
+ if (postcode === null) {
+ return { kind: 'failed', key: 'gps_not_found', code: null };
+ }
+ return { kind: 'postcode', postcode, accuracyM: accuracy };
+}
diff --git a/packages/field/src/messages.ts b/packages/field/src/messages.ts
new file mode 100644
index 0000000..4bc87fa
--- /dev/null
+++ b/packages/field/src/messages.ts
@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import type { Precision } from '@gatepost/core';
+import type { Reading } from './reading.js';
+import type { MessageKey } from './spec-messages.js';
+
+const PLACEHOLDER = /\{(\w+)\}/g;
+
+// The grammar lets only the LGA, the area and the unit fail a segment check: the state fails as
+// an unknown state, and the district takes any letters and digits. Any other segment means that
+// the core changed, so the field fails loudly instead of showing a wrong message.
+function segmentKey(segment: Precision | null): MessageKey {
+ switch (segment) {
+ case 'lga':
+ return 'bad_lga';
+ case 'area':
+ return 'bad_area';
+ case 'unit':
+ return 'bad_unit';
+ default:
+ throw new RangeError(`The core reported a segment error for ${String(segment)}.`);
+ }
+}
+
+/**
+ * Gives the key of the message for a reading of the text.
+ *
+ * @param reading - The reading of the text.
+ * @param legacy - Whether the field accepts a legacy postcode.
+ * @returns The message key that `spec/field.md` names for the reading.
+ * @internal
+ */
+export function keyForReading(reading: Reading, legacy: 'accept' | 'reject'): MessageKey {
+ switch (reading.kind) {
+ case 'empty':
+ return 'empty';
+ case 'postcode':
+ return 'valid';
+ case 'legacy':
+ return legacy === 'accept' ? 'legacy_accepted' : 'legacy_rejected';
+ case 'error':
+ return reading.code === 'bad_segment' ? segmentKey(reading.segment) : reading.code;
+ }
+}
+
+/**
+ * Puts each value in place of its placeholder. A placeholder with no value stays as it is, so
+ * a translation with a wrong name shows the mistake on screen.
+ *
+ * @param template - A message with placeholders in braces, such as `{count}`.
+ * @param values - The value of each placeholder.
+ * @returns The message with the values in it.
+ * @internal
+ */
+export function fill(
+ template: string,
+ values: Readonly>>,
+): string {
+ return template.replace(PLACEHOLDER, (placeholder, name: string) => {
+ const value = Object.hasOwn(values, name) ? values[name] : undefined;
+ return value === undefined ? placeholder : String(value);
+ });
+}
diff --git a/packages/field/src/reading.ts b/packages/field/src/reading.ts
new file mode 100644
index 0000000..f4e7fb1
--- /dev/null
+++ b/packages/field/src/reading.ts
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { normalize, parse, type Postcode, type Precision } from '@gatepost/core';
+
+/**
+ * What the field reads in the text of its input: nothing, a whole postcode, a legacy postcode,
+ * or a parse error with its suggestion and the count of characters that the message shows.
+ *
+ * @internal
+ */
+export type Reading =
+ | { readonly kind: 'empty' }
+ | { readonly kind: 'postcode'; readonly postcode: Postcode }
+ | { readonly kind: 'legacy'; readonly digits: string }
+ | {
+ readonly kind: 'error';
+ readonly code: 'bad_character' | 'bad_length' | 'unknown_state' | 'bad_segment';
+ readonly segment: Precision | null;
+ readonly suggestion: Postcode | null;
+ readonly count: number;
+ };
+
+// The core reads at most 64 code points (maxInputCodePoints in spec/data/format.json), and
+// normalize has no limit, so the field never normalises a longer text. Such a text is no
+// postcode, and its own count is close enough for the message.
+const INPUT_LIMIT = 64;
+
+// The grammar counts code points, not UTF-16 units. A text above the limit is counted whole, but
+// the check stops at the first code point above the limit, so a long text is not split twice.
+function countOf(text: string): number {
+ const codePoints = text[Symbol.iterator]();
+ for (let seen = 0; seen <= INPUT_LIMIT; seen += 1) {
+ if (codePoints.next().done === true) {
+ return Array.from(normalize(text)).length;
+ }
+ }
+ return Array.from(text).length;
+}
+
+// A suggestion of the core is a canonical form that parses, so the field can show it in the
+// display form.
+function postcodeOf(suggestion: string | null): Postcode | null {
+ const parsed = suggestion === null ? null : parse(suggestion);
+ return parsed?.ok === true ? parsed.value : null;
+}
+
+/**
+ * Reads the text of the input with the core. Partial postcodes do not pass.
+ *
+ * @param text - The text as the user typed or pasted it.
+ * @returns The reading of the text.
+ * @internal
+ */
+export function readText(text: string): Reading {
+ const parsed = parse(text);
+ if (parsed.ok) {
+ return { kind: 'postcode', postcode: parsed.value };
+ }
+ const { code, segment, suggestion } = parsed.error;
+ if (code === 'empty') {
+ return { kind: 'empty' };
+ }
+ if (code === 'legacy_code') {
+ return { kind: 'legacy', digits: normalize(text) };
+ }
+ return { kind: 'error', code, segment, suggestion: postcodeOf(suggestion), count: countOf(text) };
+}
diff --git a/packages/field/src/requests.ts b/packages/field/src/requests.ts
new file mode 100644
index 0000000..8bfd53d
--- /dev/null
+++ b/packages/field/src/requests.ts
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import type { Outcome } from './state.js';
+
+/**
+ * The three values that identify a lookup: the postcode, the level and the key.
+ *
+ * @internal
+ */
+export interface Asked {
+ readonly code: string;
+ readonly level: 1 | 2;
+ readonly key: string;
+}
+
+function sameAsked(left: Asked | null, right: Asked | null): boolean {
+ return (
+ left !== null &&
+ right !== null &&
+ left.code === right.code &&
+ left.level === right.level &&
+ left.key === right.key
+ );
+}
+
+/**
+ * What the field remembers of its requests: the one in progress, the last lookup that the
+ * gateway answered, and the outcome that shows. It holds the rules of `spec/field.md` for when a
+ * change keeps a request and when it cancels one, and it never renders or sends anything.
+ *
+ * @internal
+ */
+export class Requests {
+ /** The outcome that shows until the text or a setting changes, or null. */
+ outcome: Outcome | null = null;
+ /** The lookup in progress, or null. */
+ asked: Asked | null = null;
+ /** The last lookup that the gateway answered, or null. */
+ remembered: Asked | null = null;
+ #work: AbortController | null = null;
+
+ /**
+ * Decides what a change of the text or of a setting does to the requests. A lookup that
+ * matches the one in progress or the remembered one stays. A location request and its
+ * outcome last until the text changes. Any other change cancels the request in progress and
+ * forgets the outcome.
+ *
+ * @param wanted - The lookup that the text and the settings call for, or null.
+ * @param textChanged - True when the text differs from the text of the last change.
+ * @returns True when it cancelled the requests, so the caller can start the wanted lookup.
+ * False when the requests stay.
+ */
+ cancelUnlessKept(wanted: Asked | null, textChanged: boolean): boolean {
+ if (!textChanged && this.#locationOutcome()) {
+ return false;
+ }
+ if (sameAsked(wanted, this.asked) || sameAsked(wanted, this.remembered)) {
+ return false;
+ }
+ this.cancel();
+ return true;
+ }
+
+ /** Cancels the request in progress and forgets the remembered lookup and the outcome. */
+ cancel(): void {
+ this.#work?.abort();
+ this.#work = null;
+ this.asked = null;
+ this.remembered = null;
+ this.outcome = null;
+ }
+
+ /**
+ * Starts a request, and cancels the one in progress.
+ *
+ * @param outcome - What shows while the request runs.
+ * @returns The controller that cancels the new request.
+ */
+ start(outcome: Outcome): AbortController {
+ this.#work?.abort();
+ const work = new AbortController();
+ this.#work = work;
+ this.outcome = outcome;
+ return work;
+ }
+
+ /**
+ * Starts a location request. It cancels the request in progress, and forgets the lookup in
+ * progress and the remembered lookup, so a later change never matches them.
+ *
+ * @param outcome - What shows while the request runs.
+ * @returns The controller that cancels the new request.
+ */
+ startLocation(outcome: Outcome): AbortController {
+ const work = this.start(outcome);
+ this.asked = null;
+ this.remembered = null;
+ return work;
+ }
+
+ /** Ends the request in progress, with no outcome. The remembered lookup stays. */
+ end(): void {
+ this.#work = null;
+ this.asked = null;
+ this.outcome = null;
+ }
+
+ /**
+ * Ends the request in progress with an outcome.
+ *
+ * @param outcome - What shows until the text or a setting changes.
+ */
+ finish(outcome: Outcome): void {
+ this.end();
+ this.outcome = outcome;
+ }
+
+ // True while a location request is in progress, and after it, until the text changes.
+ #locationOutcome(): boolean {
+ const state = this.outcome?.state;
+ return state === 'locating' || state === 'coarse' || state === 'no-location';
+ }
+}
diff --git a/packages/field/src/spec-messages.ts b/packages/field/src/spec-messages.ts
new file mode 100644
index 0000000..fa953fa
--- /dev/null
+++ b/packages/field/src/spec-messages.ts
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+// @generated by scripts/generate-messages.mjs from spec/messages. Do not edit this file.
+
+/**
+ * The key of each message that the field shows. `spec/field.md` says when each one shows.
+ */
+export type MessageKey =
+ | 'label'
+ | 'hint'
+ | 'empty'
+ | 'bad_character'
+ | 'bad_length'
+ | 'unknown_state'
+ | 'bad_lga'
+ | 'bad_area'
+ | 'bad_unit'
+ | 'suggestion'
+ | 'use_suggestion'
+ | 'legacy_accepted'
+ | 'legacy_rejected'
+ | 'valid'
+ | 'checking'
+ | 'not_found'
+ | 'confirmed'
+ | 'confirmed_place'
+ | 'check_failed'
+ | 'secret_key'
+ | 'use_location'
+ | 'locating'
+ | 'gps_coarse'
+ | 'gps_not_found'
+ | 'gps_denied'
+ | 'gps_unavailable';
+
+/**
+ * The text of the field in one language, one message for each key. A name in braces, such
+ * as `{count}`, is a placeholder that the field fills in.
+ */
+export type Messages = Readonly>;
+
+/**
+ * The British English messages of the spec.
+ *
+ * @internal
+ */
+export const ENGLISH: Messages = {
+ label: 'Postcode',
+ hint: '11 letters and numbers, for example EK 01 A03 FK 01',
+ empty: 'Enter your postcode.',
+ bad_character: 'Use only letters and numbers.',
+ bad_length: 'A postcode has 11 letters and numbers. You entered {count}.',
+ unknown_state: 'The first part must be a state code, such as LA or FC.',
+ bad_lga: 'The second part must be a number from 01 to 99.',
+ bad_area: 'The fourth part must be 2 letters.',
+ bad_unit: 'The last part must be a number from 01 to 99.',
+ suggestion: 'Did you mean {postcode}?',
+ use_suggestion: 'Use this postcode',
+ legacy_accepted: 'This is an old 6-digit postcode. If you know your new one, enter it instead.',
+ legacy_rejected: 'This is an old 6-digit postcode. Enter a new one, such as EK 01 A03 FK 01.',
+ valid: 'This postcode is in {state}.',
+ checking: 'Checking your postcode.',
+ not_found: 'We could not find this postcode. Check each part, or continue if you are sure.',
+ confirmed: 'We found this postcode in {state}.',
+ confirmed_place: 'We found this postcode: {locality}, {lga}, {state}.',
+ check_failed: 'We could not check this postcode just now. You can still continue.',
+ secret_key: 'We cannot check postcodes on this page. You can still continue.',
+ use_location: 'Use my location',
+ locating: 'Finding your location.',
+ gps_coarse: 'We added part of your postcode from your location. Type the rest.',
+ gps_not_found: 'We found no postcode at your location. Type your postcode.',
+ gps_denied: 'We cannot use your location. Type your postcode.',
+ gps_unavailable: 'We could not find your location. Type your postcode.',
+};
diff --git a/packages/field/src/state.ts b/packages/field/src/state.ts
new file mode 100644
index 0000000..42475e5
--- /dev/null
+++ b/packages/field/src/state.ts
@@ -0,0 +1,129 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import type { AdministrativeAddress, LookupResult } from '@gatepost/client';
+import { stateName, type Postcode } from '@gatepost/core';
+import { keyForReading } from './messages.js';
+import type { Reading } from './reading.js';
+import type { MessageKey } from './spec-messages.js';
+
+/**
+ * The states of `spec/field.md`, in the form that the style sheet reads.
+ *
+ * @internal
+ */
+export type State =
+ | 'idle'
+ | 'typing'
+ | 'invalid'
+ | 'legacy'
+ | 'checking'
+ | 'valid'
+ | 'not-found'
+ | 'confirmed'
+ | 'error'
+ | 'locating'
+ | 'coarse'
+ | 'no-location';
+
+/**
+ * A message key with the values of its placeholders.
+ *
+ * @internal
+ */
+export interface Note {
+ readonly key: MessageKey;
+ readonly values?: Readonly>;
+}
+
+/**
+ * What a lookup or a location request found. It shows until the text changes.
+ *
+ * @internal
+ */
+export interface Outcome {
+ readonly state: State;
+ readonly note: Note;
+}
+
+const WHOLE_LENGTH = 11;
+
+// parse accepts only the codes of known states, so the code itself never shows in practice.
+function nameOfState(postcode: Postcode): string {
+ return stateName(postcode.segments.state) ?? postcode.segments.state;
+}
+
+/**
+ * Gives the state for a reading of the text. A parse error shows only after the user leaves
+ * the input or submits the form, or when the text is long enough for a whole postcode.
+ *
+ * @param reading - The reading of the text.
+ * @param errorsShown - True after the user left the input or submitted the form.
+ * @param required - True when the field needs a value.
+ * @returns The state.
+ * @internal
+ */
+export function readingState(reading: Reading, errorsShown: boolean, required: boolean): State {
+ switch (reading.kind) {
+ case 'empty':
+ return errorsShown && required ? 'invalid' : 'idle';
+ case 'legacy':
+ return 'legacy';
+ case 'postcode':
+ return 'valid';
+ case 'error':
+ return errorsShown || reading.count >= WHOLE_LENGTH ? 'invalid' : 'typing';
+ }
+}
+
+/**
+ * Gives the message for a reading of the text, with the values of its placeholders.
+ *
+ * @param reading - The reading of the text.
+ * @param legacy - Whether the field accepts a legacy postcode.
+ * @returns The message.
+ * @internal
+ */
+export function readingNote(reading: Reading, legacy: 'accept' | 'reject'): Note {
+ const key = keyForReading(reading, legacy);
+ if (reading.kind === 'postcode') {
+ return { key, values: { state: nameOfState(reading.postcode) } };
+ }
+ return reading.kind === 'error' ? { key, values: { count: reading.count } } : { key };
+}
+
+interface Place {
+ readonly locality: string;
+ readonly lga: string;
+ readonly state: string;
+}
+
+function placeOf(address: AdministrativeAddress | null): Place | null {
+ if (address === null) {
+ return null;
+ }
+ const { localityName, lgaName, stateName: name } = address;
+ if (localityName === null || lgaName === null || name === null) {
+ return null;
+ }
+ return { locality: localityName, lga: lgaName, state: name };
+}
+
+/**
+ * Gives the outcome of a lookup. The names of the place show only when the lookup gave all
+ * three, and the house address never shows.
+ *
+ * @param lookup - The lookup result.
+ * @returns The state `confirmed` or `not-found`, with its message.
+ * @internal
+ */
+export function lookupOutcome(lookup: LookupResult): Outcome {
+ if (!lookup.valid) {
+ return { state: 'not-found', note: { key: 'not_found' } };
+ }
+ const place = placeOf(lookup.administrativeAddress);
+ if (place === null) {
+ const values = { state: nameOfState(lookup.postcode) };
+ return { state: 'confirmed', note: { key: 'confirmed', values } };
+ }
+ return { state: 'confirmed', note: { key: 'confirmed_place', values: { ...place } } };
+}
diff --git a/packages/field/src/styles.ts b/packages/field/src/styles.ts
new file mode 100644
index 0000000..e64d723
--- /dev/null
+++ b/packages/field/src/styles.ts
@@ -0,0 +1,136 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+
+// The theme tokens of UI-3. A page sets them on the element, and a rule of the page wins over
+// these defaults on :host. Each text colour has a contrast of 4.5:1 or more on the background
+// token, and the border has 3:1 or more. The host does not paint that background: only the input
+// has it. The label, the hint and the messages sit on the page, so the defaults meet WCAG 2.2 AA
+// on a light page only. A dark page sets all the colour tokens.
+const TOKENS = `
+:host {
+ --gatepost-text: #0e1513;
+ --gatepost-muted: #4d5753;
+ --gatepost-background: #ffffff;
+ --gatepost-border: #6b7672;
+ --gatepost-accent: #0b6e61;
+ --gatepost-error: #b42318;
+ --gatepost-warning: #8a4b00;
+ --gatepost-radius: 0.375rem;
+ --gatepost-font: inherit;
+}`;
+
+// Sizes are in rem and em, so the field grows with the user's text size (UI-4).
+const LAYOUT = `
+:host {
+ display: block;
+ color: var(--gatepost-text);
+ font: var(--gatepost-font);
+}
+:host([hidden]) {
+ display: none;
+}
+.field {
+ display: grid;
+ gap: 0.375rem;
+ justify-items: start;
+}
+label {
+ font-weight: 600;
+}
+p {
+ margin: 0;
+}
+.hint {
+ color: var(--gatepost-muted);
+}
+input {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 14em;
+ min-height: 2.75rem;
+ padding: 0.5rem 0.75rem;
+ border: 2px solid var(--gatepost-border);
+ border-radius: var(--gatepost-radius);
+ background: var(--gatepost-background);
+ color: var(--gatepost-text);
+ font: inherit;
+ font-size: max(1rem, 1em);
+ letter-spacing: 0.04em;
+}
+input:focus-visible,
+button:focus-visible {
+ outline: 3px solid var(--gatepost-accent);
+ outline-offset: 2px;
+}
+.link {
+ min-height: 2.75rem;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--gatepost-accent);
+ font: inherit;
+ text-decoration: underline;
+ text-underline-offset: 0.2em;
+ cursor: pointer;
+}
+.link:disabled {
+ color: var(--gatepost-muted);
+ cursor: default;
+}`;
+
+// Each state shows in the text of the message, and colour only repeats it (WCAG 1.4.1).
+const STATES = `
+[data-state='invalid'] input,
+[data-state='legacy'][data-rejected] input {
+ border-color: var(--gatepost-error);
+}
+[data-state='invalid'] .message,
+[data-state='legacy'][data-rejected] .message {
+ color: var(--gatepost-error);
+}
+[data-state='legacy'] .message,
+[data-state='not-found'] .message,
+[data-state='error'] .message,
+[data-state='coarse'] .message,
+[data-state='no-location'] .message {
+ color: var(--gatepost-warning);
+}
+[data-state='valid'] .message,
+[data-state='confirmed'] .message {
+ color: var(--gatepost-accent);
+}
+[data-state='checking'] #note::before,
+[data-state='locating'] #note::before {
+ content: '';
+ display: inline-block;
+ width: 0.8em;
+ height: 0.8em;
+ margin-inline-end: 0.5em;
+ border: 2px solid currentColor;
+ border-inline-end-color: transparent;
+ border-radius: 50%;
+ vertical-align: -0.1em;
+ animation: gatepost-spin 0.8s linear infinite;
+}
+@keyframes gatepost-spin {
+ to {
+ transform: rotate(1turn);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ [data-state] #note::before {
+ animation: none;
+ }
+}
+@media (forced-colors: active) {
+ input {
+ border-color: FieldText;
+ }
+}`;
+
+/**
+ * The style sheet of the field.
+ *
+ * @internal
+ */
+export const STYLES: string = TOKENS + LAYOUT + STATES;
diff --git a/packages/field/src/types.ts b/packages/field/src/types.ts
new file mode 100644
index 0000000..d741299
--- /dev/null
+++ b/packages/field/src/types.ts
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import type { LookupResult, PostcodeErrorCode } from '@gatepost/client';
+import type { Postcode } from '@gatepost/core';
+
+/**
+ * How the text of the field changed: typed, pasted, taken from the suggestion of a parse error,
+ * or filled in from the device's location.
+ */
+export type ChangeSource = 'typed' | 'pasted' | 'suggestion' | 'gps';
+
+/**
+ * The detail of the event `gatepost-change`, which the field raises when its form value changes
+ * through the user.
+ */
+export interface ChangeDetail {
+ /** The form value: the canonical form, the digits of a legacy postcode, or the trimmed text. */
+ readonly value: string;
+ /** The postcode when the text parses as a whole postcode, or null. */
+ readonly postcode: Postcode | null;
+ /** How the text changed. */
+ readonly source: ChangeSource;
+ /** The accuracy of the location in metres when `source` is `gps`, or null. */
+ readonly accuracyM: number | null;
+}
+
+/**
+ * The detail of the event `gatepost-confirm`, which the field raises when the gateway knows the
+ * postcode.
+ */
+export interface ConfirmDetail {
+ /** The lookup result, with every field that the key's level gave. */
+ readonly lookup: LookupResult;
+}
+
+/**
+ * Why the field raised `gatepost-error`: an error code of the client, a secret key in the page,
+ * or a location that the user refused or the device did not give.
+ */
+export type FieldErrorCode = PostcodeErrorCode | 'secret_key' | 'gps_denied' | 'gps_unavailable';
+
+/**
+ * The detail of the event `gatepost-error`.
+ */
+export interface ErrorDetail {
+ /** What failed. */
+ readonly code: FieldErrorCode;
+}
diff --git a/packages/field/src/version.ts b/packages/field/src/version.ts
new file mode 100644
index 0000000..6fcf3a0
--- /dev/null
+++ b/packages/field/src/version.ts
@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * The version of the Gatepost spec that this package implements.
+ *
+ * @example
+ * ```ts
+ * import { SPEC_VERSION } from '@gatepost/field';
+ *
+ * SPEC_VERSION; // '0.3.0'
+ * ```
+ */
+export const SPEC_VERSION = '0.3.0';
diff --git a/packages/field/src/view.ts b/packages/field/src/view.ts
new file mode 100644
index 0000000..c713360
--- /dev/null
+++ b/packages/field/src/view.ts
@@ -0,0 +1,153 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import type { State } from './state.js';
+import { STYLES } from './styles.js';
+
+/**
+ * The parts of the shadow tree that the element changes after it builds the tree.
+ *
+ * @internal
+ */
+export interface View {
+ readonly field: HTMLElement;
+ readonly label: HTMLLabelElement;
+ readonly hint: HTMLElement;
+ readonly input: HTMLInputElement;
+ readonly note: HTMLElement;
+ readonly didYouMean: HTMLElement;
+ readonly suggestion: HTMLButtonElement;
+ readonly location: HTMLButtonElement;
+}
+
+function make(
+ tag: Tag,
+ attributes: Readonly>,
+ ...children: readonly Node[]
+): HTMLElementTagNameMap[Tag] {
+ const element = document.createElement(tag);
+ for (const [name, value] of Object.entries(attributes)) {
+ element.setAttribute(name, value);
+ }
+ element.append(...children);
+ return element;
+}
+
+// The shadow tree is built with DOM calls and no HTML string, so a page with a Trusted Types
+// policy can show it. The DOM order is the visual order and the tab order: the input, the
+// suggestion button, then the location button. The live region holds the two messages, and not
+// the buttons.
+function buildTree(): HTMLElement {
+ const button = (id: string): HTMLButtonElement =>
+ make('button', { id, class: 'link', type: 'button', part: 'button', hidden: '' });
+ return make(
+ 'div',
+ { class: 'field', part: 'field', 'data-state': 'idle' },
+ make('label', { for: 'input', part: 'label' }),
+ make('p', { id: 'hint', class: 'hint', part: 'hint' }),
+ make('input', {
+ id: 'input',
+ part: 'input',
+ type: 'text',
+ autocomplete: 'postal-code',
+ autocapitalize: 'characters',
+ spellcheck: 'false',
+ enterkeyhint: 'done',
+ 'aria-describedby': 'hint message',
+ }),
+ make(
+ 'div',
+ { id: 'message', class: 'message', part: 'message', role: 'status' },
+ make('p', { id: 'note' }),
+ make('p', { id: 'did-you-mean', hidden: '' }),
+ ),
+ button('suggestion'),
+ button('location'),
+ );
+}
+
+let sheet: CSSStyleSheet | undefined;
+
+function find(root: ShadowRoot, selector: string, type: new () => T): T {
+ const element = root.querySelector(selector);
+ if (!(element instanceof type)) {
+ throw new TypeError(`The shadow tree has no ${type.name} at ${selector}.`);
+ }
+ return element;
+}
+
+/**
+ * Builds the shadow tree of one field. The fields share one style sheet, which the first field
+ * makes, so importing the module does no work (PERF-2).
+ *
+ * @param root - The shadow root of the field.
+ * @returns The parts that the field changes.
+ * @internal
+ */
+export function buildView(root: ShadowRoot): View {
+ if (sheet === undefined) {
+ sheet = new CSSStyleSheet();
+ sheet.replaceSync(STYLES);
+ }
+ root.adoptedStyleSheets = [sheet];
+ root.replaceChildren(buildTree());
+ return {
+ field: find(root, '.field', HTMLDivElement),
+ label: find(root, 'label', HTMLLabelElement),
+ hint: find(root, '#hint', HTMLParagraphElement),
+ input: find(root, '#input', HTMLInputElement),
+ note: find(root, '#note', HTMLParagraphElement),
+ didYouMean: find(root, '#did-you-mean', HTMLParagraphElement),
+ suggestion: find(root, '#suggestion', HTMLButtonElement),
+ location: find(root, '#location', HTMLButtonElement),
+ };
+}
+
+/**
+ * What the shadow tree shows. The element works it out, and `renderView` writes it.
+ *
+ * @internal
+ */
+export interface Display {
+ readonly state: State;
+ readonly invalid: boolean;
+ readonly required: boolean;
+ readonly rejected: boolean;
+ readonly label: string;
+ readonly hint: string;
+ readonly note: string;
+ readonly didYouMean: string | null;
+ readonly useSuggestion: string;
+ readonly useLocation: string | null;
+}
+
+// A write of the same text still replaces the text node, and a screen reader can read a live
+// region again for it. So the view writes a text only when it differs.
+function setText(element: HTMLElement, text: string): void {
+ if (element.textContent !== text) {
+ element.textContent = text;
+ }
+}
+
+/**
+ * Writes a display into the shadow tree. Text goes in as text, never as HTML, because a page
+ * can give its own messages.
+ *
+ * @param view - The parts of the shadow tree.
+ * @param display - What to show.
+ * @internal
+ */
+export function renderView(view: View, display: Display): void {
+ view.field.dataset['state'] = display.state;
+ view.field.toggleAttribute('data-rejected', display.rejected);
+ setText(view.label, display.label);
+ setText(view.hint, display.hint);
+ view.input.setAttribute('aria-invalid', String(display.invalid));
+ view.input.setAttribute('aria-required', String(display.required));
+ setText(view.note, display.note);
+ view.didYouMean.hidden = display.didYouMean === null;
+ setText(view.didYouMean, display.didYouMean ?? '');
+ view.suggestion.hidden = display.didYouMean === null;
+ setText(view.suggestion, display.useSuggestion);
+ view.location.hidden = display.useLocation === null;
+ setText(view.location, display.useLocation ?? '');
+}
diff --git a/packages/field/test/element.test.ts b/packages/field/test/element.test.ts
new file mode 100644
index 0000000..e9d34e5
--- /dev/null
+++ b/packages/field/test/element.test.ts
@@ -0,0 +1,490 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import { PostcodeFieldElement } from '../src/index.js';
+import { expectAccessible, mount } from './field.js';
+
+const FIELD = ' ';
+
+function formValue(form: HTMLFormElement): FormDataEntryValue | null {
+ return new FormData(form).get('postcode');
+}
+
+// Copies the text from a plain input and pastes it into the input of the field, as a user does.
+async function pasteInto(input: HTMLInputElement, text: string): Promise {
+ const source = document.createElement('input');
+ source.value = text;
+ document.body.append(source);
+ source.select();
+ await userEvent.copy();
+ source.remove();
+ input.focus();
+ await userEvent.paste();
+}
+
+describe('the field in a plain form', () => {
+ it('names its input with the label, and describes it with the hint', () => {
+ const { input, label } = mount(FIELD);
+ expect(Array.from(input.labels ?? [])).toEqual([label]);
+ expect(label.textContent).toBe('Postcode');
+ expect(input.getAttribute('aria-describedby')).toBe('hint message');
+ });
+
+ it('shows the label that the page gives', () => {
+ const { label } = mount(
+ ' ',
+ );
+ expect(label.textContent).toBe('Delivery postcode');
+ });
+
+ it('submits the canonical form of a typed postcode', async () => {
+ const { form, input } = mount(FIELD);
+ await userEvent.type(input, 'fc 01 z99 zz 01');
+ expect(formValue(form)).toBe('FC-01-Z99-ZZ-01');
+ });
+
+ it('shows the display form and the state when the user leaves the input', async () => {
+ const { input, message } = mount(FIELD);
+ await userEvent.type(input, 'fc01z99zz01');
+ await userEvent.tab();
+ expect(input.value).toBe('FC 01 Z99 ZZ 01');
+ expect(message()).toBe('This postcode is in Federal Capital Territory.');
+ });
+
+ it('shows no error while the user types a short text, and shows it on leaving', async () => {
+ const { input, message } = mount(FIELD);
+ await userEvent.type(input, 'FC01');
+ expect(message()).toBe('');
+ expect(input.getAttribute('aria-invalid')).toBe('false');
+ await userEvent.tab();
+ expect(message()).toBe('A postcode has 11 letters and numbers. You entered 4.');
+ expect(input.getAttribute('aria-invalid')).toBe('true');
+ });
+
+ it('shows an error at once for a text as long as a whole postcode', async () => {
+ const { input, message } = mount(FIELD);
+ await userEvent.type(input, 'FC01Z99ZZ001');
+ expect(message()).toBe('A postcode has 11 letters and numbers. You entered 12.');
+ });
+
+ it('submits the text as typed when it does not parse, and marks the field invalid', async () => {
+ const { form, field, input } = mount(FIELD);
+ await userEvent.type(input, ' fc01z99 ');
+ expect(field.value).toBe('fc01z99');
+ expect(field.checkValidity()).toBe(false);
+ expect(field.validationMessage).toBe('A postcode has 11 letters and numbers. You entered 7.');
+ form.noValidate = true;
+ expect(formValue(form)).toBe('fc01z99');
+ });
+
+ it('offers the suggestion of the core, and uses it only when the user asks', async () => {
+ const { form, field, input, suggestion, message, changes } = mount(FIELD);
+ await userEvent.type(input, 'FCO1Z99ZZ01');
+ expect(message()).toBe(
+ 'The second part must be a number from 01 to 99. Did you mean FC 01 Z99 ZZ 01?',
+ );
+ expect(formValue(form)).toBe('FCO1Z99ZZ01');
+ expect(suggestion.textContent).toBe('Use this postcode');
+ await userEvent.click(suggestion);
+ expect(input.value).toBe('FC 01 Z99 ZZ 01');
+ expect(field.value).toBe('FC-01-Z99-ZZ-01');
+ expect(changes.at(-1)?.source).toBe('suggestion');
+ expect(field.shadowRoot?.activeElement).toBe(input);
+ });
+
+ it('takes a pasted postcode with en dashes, no-break spaces and full-width letters', async () => {
+ const dash = String.fromCodePoint(0x2013);
+ const space = String.fromCodePoint(0xa0);
+ const minus = String.fromCodePoint(0x2212);
+ // U+FF26 and U+FF23 are the full-width letters F and C.
+ const wide = String.fromCodePoint(0xff26, 0xff23);
+ // The same text with a non-breaking hyphen (U+2011) and full-width digits and letters.
+ const mixed = String.fromCodePoint(
+ ...[0xff26, 0xff23, 0x2011, 0xff10, 0xff11, 0x20, 0xff3a, 0xff19, 0xff19, 0xa0],
+ ...[0xff3a, 0xff3a, 0x2013, 0xff10, 0xff11],
+ );
+ const { form, input, changes } = mount(FIELD);
+ for (const text of [
+ `FC${dash}01${dash}Z99${dash}ZZ${dash}01`,
+ `FC${minus}01${minus}Z99${minus}ZZ${minus}01`,
+ `FC${space}01 Z99 ZZ 01`,
+ `${wide}01Z99ZZ01`,
+ mixed,
+ ]) {
+ await userEvent.clear(input);
+ await pasteInto(input, text);
+ expect(formValue(form)).toBe('FC-01-Z99-ZZ-01');
+ expect(changes.at(-1)?.source).toBe('pasted');
+ }
+ });
+
+ it('keeps two fields in one form apart, as for a billing and a delivery address', async () => {
+ const { form } = mount(
+ '' +
+ ' ' +
+ '' +
+ ' ',
+ );
+ const [billing, delivery] = Array.from(form.querySelectorAll('gatepost-postcode-field'));
+ const parts = (field: Element | undefined) => ({
+ input: field!.shadowRoot!.querySelector('input')!,
+ label: field!.shadowRoot!.querySelector('label')!.textContent,
+ message: field!.shadowRoot!.querySelector('#message')!.innerText.trim(),
+ });
+ await userEvent.type(parts(billing).input, 'FC01Z99ZZ01');
+ await userEvent.type(parts(delivery).input, 'FC01');
+ await userEvent.tab();
+ expect(new FormData(form).get('billing')).toBe('FC-01-Z99-ZZ-01');
+ expect(new FormData(form).get('delivery')).toBe('FC01');
+ expect([parts(billing).label, parts(delivery).label]).toEqual([
+ 'Billing postcode',
+ 'Delivery postcode',
+ ]);
+ expect(parts(billing).message).toBe('This postcode is in Federal Capital Territory.');
+ expect(parts(delivery).message).toBe('A postcode has 11 letters and numbers. You entered 4.');
+ expect(parts(billing).input.getAttribute('aria-invalid')).toBe('false');
+ expect(parts(delivery).input.getAttribute('aria-invalid')).toBe('true');
+ });
+
+ it('raises gatepost-change with the source of each change of the form value', async () => {
+ const { input, changes } = mount(FIELD);
+ await userEvent.type(input, 'F');
+ input.value = 'FC-01-Z99-ZZ-01';
+ input.dispatchEvent(new InputEvent('input', { inputType: 'insertFromPaste' }));
+ // An autofill sends an input event with no event before it, so the paste must not linger.
+ input.value = 'FC-01-Z99-ZZ-02';
+ input.dispatchEvent(new InputEvent('input', { inputType: 'insertReplacementText' }));
+ expect(changes.map((change) => [change.value, change.source])).toEqual([
+ ['F', 'typed'],
+ ['FC-01-Z99-ZZ-01', 'pasted'],
+ ['FC-01-Z99-ZZ-02', 'typed'],
+ ]);
+ expect(changes.at(-1)?.postcode?.display).toBe('FC 01 Z99 ZZ 02');
+ expect(changes.at(-1)?.accuracyM).toBeNull();
+ });
+});
+
+describe('Enter in the input', () => {
+ function watch(form: HTMLFormElement): HTMLButtonElement[] {
+ const submitters: HTMLButtonElement[] = [];
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ submitters.push(event.submitter as HTMLButtonElement);
+ });
+ return submitters;
+ }
+
+ async function enter(input: HTMLInputElement): Promise {
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await userEvent.keyboard('{Enter}');
+ }
+
+ it('sends the name and value of the default button, as a native input does', async () => {
+ const { form, input } = mount(
+ `${FIELD}No ` +
+ 'Go Stop ',
+ );
+ const submitters = watch(form);
+ await enter(input);
+ expect(submitters.map((button) => button.name)).toEqual(['go']);
+ expect(new FormData(form, submitters[0]).get('go')).toBe('1');
+ expect(new FormData(form, submitters[0]).has('stop')).toBe(false);
+ });
+
+ it('also finds a default button that is an input of type submit', async () => {
+ const { form, input } = mount(`${FIELD} `);
+ const submitters = watch(form);
+ await enter(input);
+ expect(new FormData(form, submitters[0]).get('send')).toBe('Send');
+ });
+
+ it('does nothing when the default button is disabled, even if a later one is not', async () => {
+ const { form, input } = mount(
+ `${FIELD}Go Stop `,
+ );
+ const submitters = watch(form);
+ await enter(input);
+ expect(submitters).toEqual([]);
+ });
+
+ it('does nothing when the default button sits in a disabled fieldset', async () => {
+ const { form, input } = mount(
+ `${FIELD}Go `,
+ );
+ const submitters = watch(form);
+ await enter(input);
+ expect(submitters).toEqual([]);
+ });
+
+ it('submits a form with no submit button when the field is its only text field', async () => {
+ const { form, input } = mount(`${FIELD} `);
+ const submitters = watch(form);
+ await enter(input);
+ expect(submitters).toEqual([null]);
+ });
+
+ it('does nothing in a form with no submit button and a second text field', async () => {
+ const { form, input } = mount(`${FIELD} `);
+ const submitters = watch(form);
+ await enter(input);
+ expect(submitters).toEqual([]);
+ });
+
+ it('does nothing in a form with no submit button and a second postcode field', async () => {
+ const { form, field } = mount(`${FIELD}${FIELD.replace('"postcode"', '"other"')}`);
+ const submitters = watch(form);
+ await enter(field.shadowRoot!.querySelector('input')!);
+ expect(submitters).toEqual([]);
+ });
+});
+
+describe('legacy accept and reject', () => {
+ it('accepts an old 6-digit postcode by default, and says that a new one exists', async () => {
+ const { form, field, input, message } = mount(FIELD);
+ await userEvent.type(input, '900 108');
+ expect(formValue(form)).toBe('900108');
+ expect(field.checkValidity()).toBe(true);
+ expect(message()).toBe(
+ 'This is an old 6-digit postcode. If you know your new one, enter it instead.',
+ );
+ expect(input.getAttribute('aria-invalid')).toBe('false');
+ });
+
+ it('rejects an old 6-digit postcode when legacy is reject', async () => {
+ const { field, input, message } = mount(
+ ' ',
+ );
+ await userEvent.type(input, '900108');
+ expect(field.checkValidity()).toBe(false);
+ expect(message()).toBe(
+ 'This is an old 6-digit postcode. Enter a new one, such as EK 01 A03 FK 01.',
+ );
+ expect(input.getAttribute('aria-invalid')).toBe('true');
+ });
+});
+
+describe('the field and its form', () => {
+ it('needs a value when required, and shows why after a submit', async () => {
+ const { form, field, input, message } = mount(
+ ' ',
+ );
+ const submitted = vi.fn((event: Event) => {
+ event.preventDefault();
+ });
+ form.addEventListener('submit', submitted);
+ expect(message()).toBe('');
+ expect(field.validity.valueMissing).toBe(true);
+ input.focus();
+ await userEvent.keyboard('{Enter}');
+ expect(submitted).not.toHaveBeenCalled();
+ expect(message()).toBe('Enter your postcode.');
+ });
+
+ it('submits the form when the user presses Enter in the input', async () => {
+ const { form, input } = mount(FIELD);
+ const submitted = vi.fn((event: Event) => {
+ event.preventDefault();
+ });
+ form.addEventListener('submit', submitted);
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await userEvent.keyboard('{Enter}');
+ expect(submitted).toHaveBeenCalledOnce();
+ });
+
+ it('goes back to the value attribute when the form resets', async () => {
+ const { form, field, input } = mount(
+ ' ',
+ );
+ expect(field.value).toBe('FC-01-Z99-ZZ-01');
+ await userEvent.type(input, 'X');
+ form.reset();
+ expect(input.value).toBe('fc01z99zz01');
+ expect(field.value).toBe('FC-01-Z99-ZZ-01');
+ });
+
+ it('takes its text from the value property without a change event', () => {
+ const { field, input, changes } = mount(FIELD);
+ field.value = 'fc 01 z99 zz 01';
+ expect(input.value).toBe('fc 01 z99 zz 01');
+ expect(field.value).toBe('FC-01-Z99-ZZ-01');
+ expect(changes).toEqual([]);
+ });
+
+ it('turns its input off inside a disabled fieldset, and the form leaves it out', () => {
+ const { form, input } = mount(
+ '' +
+ ' ',
+ );
+ expect(input.disabled).toBe(true);
+ expect(formValue(form)).toBeNull();
+ });
+
+ it('shows the messages that the page gives, and English for the rest', async () => {
+ const { field, input, label, message } = mount(FIELD);
+ field.messages = { label: 'Your postcode', empty: 'Type a postcode.' };
+ expect(label.textContent).toBe('Your postcode');
+ expect(field.messages.hint).toBe('11 letters and numbers, for example EK 01 A03 FK 01');
+ await userEvent.type(input, 'FC01');
+ await userEvent.tab();
+ expect(message()).toBe('A postcode has 11 letters and numbers. You entered 4.');
+ });
+
+ it('shows a message that the page gives for the empty required field', async () => {
+ const { field, input, message } = mount(
+ ' ',
+ );
+ field.messages = { empty: 'Type a postcode.' };
+ expect(field.validationMessage).toBe('Type a postcode.');
+ input.focus();
+ await userEvent.tab();
+ expect(message()).toBe('Type a postcode.');
+ });
+
+ it('puts back the text that the browser saved, and keeps it over the value attribute', () => {
+ const { field, input } = mount(
+ ' ',
+ );
+ field.formStateRestoreCallback('fc 01 z99 zz 01');
+ expect(input.value).toBe('fc 01 z99 zz 01');
+ expect(field.value).toBe('FC-01-Z99-ZZ-01');
+ field.setAttribute('value', 'FC-01-Z99-ZZ-03');
+ expect(input.value).toBe('fc 01 z99 zz 01');
+ });
+
+ it('waits again before it shows an error after a form reset', async () => {
+ const { form, input, message } = mount(FIELD);
+ await userEvent.type(input, 'FC01');
+ await userEvent.tab();
+ expect(message()).not.toBe('');
+ form.reset();
+ expect(message()).toBe('');
+ input.focus();
+ await userEvent.keyboard('F');
+ expect(message()).toBe('');
+ });
+
+ it('does not show errors when the window loses focus and the input keeps it', async () => {
+ const { input, message } = mount(FIELD);
+ await userEvent.type(input, 'FC01');
+ input.dispatchEvent(new FocusEvent('blur'));
+ expect(message()).toBe('');
+ await userEvent.tab();
+ expect(message()).toBe('A postcode has 11 letters and numbers. You entered 4.');
+ });
+
+ it('marks the input as required for assistive technology, as the field is', () => {
+ const { field, input } = mount(
+ ' ',
+ );
+ expect(input.getAttribute('aria-required')).toBe('true');
+ field.removeAttribute('required');
+ expect(input.getAttribute('aria-required')).toBe('false');
+ });
+
+ it('changes the live region only when the message changes', async () => {
+ const { field, input, message } = mount(FIELD);
+ const region = field.shadowRoot!.querySelector('#message')!;
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ expect(message()).toBe('This postcode is in Federal Capital Territory.');
+ const writes: MutationRecord[] = [];
+ const observer = new MutationObserver((records) => writes.push(...records));
+ observer.observe(region, { childList: true, characterData: true, subtree: true });
+ await userEvent.type(input, ' ');
+ await userEvent.tab();
+ field.messages = { label: 'Your postcode' };
+ await new Promise((resolve) => setTimeout(resolve));
+ observer.disconnect();
+ expect(writes).toEqual([]);
+ });
+});
+
+describe('accessibility', () => {
+ it('has no WCAG 2.2 AA violation in the states idle, invalid, legacy and valid', async () => {
+ const { input } = mount(FIELD);
+ await expectAccessible();
+ await userEvent.type(input, 'FCO1Z99ZZ01');
+ await expectAccessible();
+ await userEvent.clear(input);
+ await userEvent.type(input, '900108');
+ await expectAccessible();
+ await userEvent.clear(input);
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await userEvent.tab();
+ await expectAccessible();
+ });
+
+ it('has no violation in the states legacy rejected and required but empty', async () => {
+ const { input } = mount(
+ ' ',
+ );
+ input.focus();
+ await userEvent.tab();
+ await expectAccessible();
+ await userEvent.type(input, '900108');
+ await expectAccessible();
+ });
+
+ it('reaches the input and then the suggestion button with the Tab key alone', async () => {
+ const { form, field, input, suggestion } = mount(`Back ${FIELD}`);
+ form.querySelector('button')?.focus();
+ await userEvent.tab();
+ expect(field.shadowRoot?.activeElement).toBe(input);
+ await userEvent.keyboard('FCO1Z99ZZ01');
+ await userEvent.tab();
+ expect(field.shadowRoot?.activeElement).toBe(suggestion);
+ await userEvent.keyboard('{Enter}');
+ expect(input.value).toBe('FC 01 Z99 ZZ 01');
+ });
+
+ describe('properties set before the element is defined', () => {
+ // An element of a document with no registry stays plain, so it holds own properties as a
+ // page does before its script defines the field. Adding it to this page upgrades it.
+ function early(properties: Record): PostcodeFieldElement {
+ const plain = document.implementation.createHTMLDocument('');
+ const field = plain.createElement('gatepost-postcode-field');
+ Object.assign(field, properties);
+ return field as PostcodeFieldElement;
+ }
+
+ it('takes the early messages, and a later assignment still reaches the field', () => {
+ const field = early({ messages: { label: 'Early' } });
+ const form = document.createElement('form');
+ document.body.append(form);
+ form.append(field);
+ expect(Object.hasOwn(field, 'messages')).toBe(false);
+ expect(field.shadowRoot?.querySelector('label')?.textContent).toBe('Early');
+ field.messages = { label: 'Later' };
+ expect(field.shadowRoot?.querySelector('label')?.textContent).toBe('Later');
+ expect(field.messages.label).toBe('Later');
+ });
+
+ it('takes the early value over the value attribute', () => {
+ const field = early({ value: 'fc01z99zz01' });
+ field.setAttribute('value', 'LA');
+ const form = document.createElement('form');
+ document.body.append(form);
+ form.append(field);
+ expect(Object.hasOwn(field, 'value')).toBe(false);
+ expect(field.value).toBe('FC-01-Z99-ZZ-01');
+ expect(field.shadowRoot?.querySelector('input')?.value).toBe('fc01z99zz01');
+ });
+ });
+
+ it('keeps the English text for a message that is not text', () => {
+ const { field, input, label, message } = mount(
+ ' ',
+ );
+ field.messages = { valid: undefined, label: undefined } as never;
+ input.value = 'FC01Z99ZZ01';
+ input.dispatchEvent(new InputEvent('input', { inputType: 'insertText', bubbles: true }));
+ expect(label.textContent).toBe('Postcode');
+ expect(message()).toBe('This postcode is in Federal Capital Territory.');
+ });
+
+ it.each([null, undefined])('takes %s as no messages', (none) => {
+ const { field, label } = mount(' ');
+ field.messages = none as never;
+ expect(label.textContent).toBe('Postcode');
+ });
+});
diff --git a/packages/field/test/field.ts b/packages/field/test/field.ts
new file mode 100644
index 0000000..a9ded81
--- /dev/null
+++ b/packages/field/test/field.ts
@@ -0,0 +1,72 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import axe from 'axe-core';
+import { afterEach, expect } from 'vitest';
+import { PostcodeFieldElement, type ChangeDetail } from '../src/index.js';
+
+/** A field in a form, with the parts of its shadow tree that a user sees. */
+export interface Mounted {
+ readonly form: HTMLFormElement;
+ readonly field: PostcodeFieldElement;
+ readonly input: HTMLInputElement;
+ readonly label: HTMLLabelElement;
+ readonly suggestion: HTMLButtonElement;
+ readonly location: HTMLButtonElement;
+ /** The text of the live region, as a screen reader reads it. */
+ readonly message: () => string;
+ /** The details of each `gatepost-change` so far. */
+ readonly changes: ChangeDetail[];
+}
+
+afterEach(() => {
+ document.body.replaceChildren();
+});
+
+function part(
+ field: PostcodeFieldElement,
+ selector: string,
+ type: new () => T,
+): T {
+ const element = field.shadowRoot!.querySelector(selector);
+ if (!(element instanceof type)) {
+ throw new Error(`The field has no ${type.name} at ${selector}.`);
+ }
+ return element;
+}
+
+/**
+ * Puts the HTML in a form in the page, and returns the first field in it.
+ */
+export function mount(html: string): Mounted {
+ const form = document.createElement('form');
+ form.innerHTML = html;
+ document.body.append(form);
+ const field = form.querySelector('gatepost-postcode-field');
+ if (!(field instanceof PostcodeFieldElement)) {
+ throw new Error('The HTML holds no gatepost-postcode-field.');
+ }
+ const changes: ChangeDetail[] = [];
+ field.addEventListener('gatepost-change', (event) => {
+ changes.push((event as CustomEvent).detail);
+ });
+ const message = part(field, '#message', HTMLDivElement);
+ return {
+ form,
+ field,
+ input: part(field, '#input', HTMLInputElement),
+ label: part(field, 'label', HTMLLabelElement),
+ suggestion: part(field, '#suggestion', HTMLButtonElement),
+ location: part(field, '#location', HTMLButtonElement),
+ message: () => message.innerText.replace(/\s+/g, ' ').trim(),
+ changes,
+ };
+}
+
+/**
+ * Checks the page with axe for the WCAG 2.2 A and AA rules, and expects no violation.
+ */
+export async function expectAccessible(): Promise {
+ const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
+ const results = await axe.run(document.body, { runOnly: { type: 'tag', values: tags } });
+ expect(results.violations.map((violation) => violation.id)).toEqual([]);
+}
diff --git a/packages/field/test/gateway.ts b/packages/field/test/gateway.ts
new file mode 100644
index 0000000..5332140
--- /dev/null
+++ b/packages/field/test/gateway.ts
@@ -0,0 +1,50 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { vi } from 'vitest';
+import { commands } from 'vitest/browser';
+
+/** One request that the field sent through fetch. */
+export interface SentRequest {
+ readonly url: URL;
+ readonly headers: Headers;
+ readonly signal: AbortSignal | null;
+}
+
+/**
+ * Reads a synthetic response of the spec's fixtures, such as `lookup/valid-level-1`.
+ */
+export async function fixture(name: string): Promise {
+ const file = JSON.parse(await commands.readFile(`../../spec/fixtures/${name}.json`)) as {
+ status: number;
+ body: unknown;
+ };
+ return Response.json(file.body, { status: file.status });
+}
+
+/**
+ * Puts a scripted gateway in place of fetch. Each request takes the next answer: a response, an
+ * error that fetch throws, or a promise that the test settles later.
+ */
+export function scriptGateway(...answers: readonly (Response | Error | Promise)[]): {
+ readonly requests: SentRequest[];
+} {
+ const requests: SentRequest[] = [];
+ const queue = [...answers];
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
+ const request = new Request(input, init);
+ requests.push({
+ url: new URL(request.url),
+ headers: request.headers,
+ signal: init?.signal ?? null,
+ });
+ const answer = queue.shift();
+ if (answer === undefined) {
+ throw new Error(`The test gave no answer for ${request.url}.`);
+ }
+ if (answer instanceof Error) {
+ throw answer;
+ }
+ return await answer;
+ });
+ return { requests };
+}
diff --git a/packages/field/test/journeys/fixtures.ts b/packages/field/test/journeys/fixtures.ts
new file mode 100644
index 0000000..860bb1f
--- /dev/null
+++ b/packages/field/test/journeys/fixtures.ts
@@ -0,0 +1,42 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import {
+ test as base,
+ expect,
+ type PlaywrightTestArgs,
+ type PlaywrightTestOptions,
+ type PlaywrightWorkerArgs,
+ type PlaywrightWorkerOptions,
+ type TestType,
+} from '@playwright/test';
+
+// The test page and the mock server are the only hosts that a journey may reach.
+const OWN_HOSTS = ['http://localhost:3000/', 'http://127.0.0.1:4010/'];
+
+// Every journey uses this test. A request that no route of the journey handles must go to the
+// test page or the mock server. Any other request is stopped and fails the journey, so no
+// journey can reach NIPOST or another outside host by mistake.
+export const test: TestType<
+ PlaywrightTestArgs & PlaywrightTestOptions & { outsideRequests: undefined },
+ PlaywrightWorkerArgs & PlaywrightWorkerOptions
+> = base.extend<{ outsideRequests: undefined }>({
+ outsideRequests: [
+ async ({ context }, use) => {
+ const stray: string[] = [];
+ await context.route('**/*', async (route) => {
+ const url = route.request().url();
+ if (OWN_HOSTS.some((host) => url.startsWith(host))) {
+ await route.fallback();
+ return;
+ }
+ stray.push(url);
+ await route.abort();
+ });
+ await use(undefined);
+ expect(stray, 'requests to outside hosts').toEqual([]);
+ },
+ { auto: true },
+ ],
+});
+
+export { expect };
diff --git a/packages/field/test/journeys/form.test.ts b/packages/field/test/journeys/form.test.ts
new file mode 100644
index 0000000..e380c17
--- /dev/null
+++ b/packages/field/test/journeys/form.test.ts
@@ -0,0 +1,296 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { AxeBuilder } from '@axe-core/playwright';
+import { type Page } from '@playwright/test';
+import { expect, test } from './fixtures.js';
+
+const MOCK = 'http://127.0.0.1:4010';
+const KEY = 'nipost_pk_test_mock';
+const LOOKUPS = `/form?api-key=${KEY}&base-url=${MOCK}`;
+
+async function expectAccessible(page: Page): Promise {
+ const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
+ const results = await new AxeBuilder({ page }).withTags(tags).analyze();
+ expect(results.violations.map((violation) => violation.id)).toEqual([]);
+}
+
+async function sent(page: Page): Promise {
+ return JSON.parse(await page.locator('#submitted').innerText());
+}
+
+// Counts the submissions of the form and stops each one, so the page stays. A submission is
+// synchronous with the key press, so the count is final when the press returns.
+async function countSubmissions(page: Page): Promise<() => Promise> {
+ await page.evaluate(() => {
+ const counter = { count: 0 };
+ Object.assign(window, { submissions: counter });
+ document.querySelector('form')?.addEventListener('submit', (event) => {
+ event.preventDefault();
+ counter.count += 1;
+ });
+ });
+ return () =>
+ page.evaluate(
+ () => (window as unknown as { submissions: { count: number } }).submissions.count,
+ );
+}
+
+test('a keyboard user types a postcode and sends the canonical form', async ({ page }) => {
+ await page.goto('/form?required');
+ await page.keyboard.press('Tab');
+ await expect(page.getByLabel('Postcode')).toBeFocused();
+ await page.keyboard.type('fc 01 z99 zz 01');
+ await page.keyboard.press('Enter');
+ await page.waitForURL('**/submitted?**');
+ expect(await sent(page)).toEqual({ postcode: 'FC-01-Z99-ZZ-01' });
+});
+
+test('a required field stops an empty form and says why', async ({ page }) => {
+ await page.goto('/form?required');
+ await page.getByRole('button', { name: 'Continue' }).click();
+ await expect(page.getByRole('status')).toHaveText('Enter your postcode.');
+ await expect(page).toHaveURL(/\/form/);
+ await expectAccessible(page);
+});
+
+test('legacy accept and reject', async ({ page }) => {
+ await page.goto('/form');
+ await page.getByLabel('Postcode').fill('900108');
+ await page.keyboard.press('Enter');
+ await page.waitForURL('**/submitted?**');
+ expect(await sent(page)).toEqual({ postcode: '900108' });
+ await page.goto('/form?legacy=reject');
+ await page.getByLabel('Postcode').fill('900108');
+ await page.keyboard.press('Enter');
+ await expect(page.getByRole('status')).toHaveText(
+ 'This is an old 6-digit postcode. Enter a new one, such as EK 01 A03 FK 01.',
+ );
+ await expect(page).toHaveURL(/\/form/);
+ await expectAccessible(page);
+});
+
+test('Enter does nothing while the default button is disabled', async ({ page }) => {
+ await page.goto('/form');
+ const submissions = await countSubmissions(page);
+ await page.getByLabel('Postcode').fill('FC01Z99ZZ01');
+ await page.getByRole('button', { name: 'Continue' }).evaluate((button) => {
+ (button as HTMLButtonElement).disabled = true;
+ });
+ await page.keyboard.press('Enter');
+ expect(await submissions()).toBe(0);
+ await page.getByRole('button', { name: 'Continue' }).evaluate((button) => {
+ (button as HTMLButtonElement).disabled = false;
+ });
+ await page.keyboard.press('Enter');
+ expect(await submissions()).toBe(1);
+});
+
+test('Enter does nothing while a disabled fieldset holds the default button', async ({ page }) => {
+ await page.goto('/form');
+ const submissions = await countSubmissions(page);
+ await page.getByLabel('Postcode').fill('FC01Z99ZZ01');
+ await page.getByRole('button', { name: 'Continue' }).evaluate((button) => {
+ const fieldset = document.createElement('fieldset');
+ fieldset.disabled = true;
+ button.replaceWith(fieldset);
+ fieldset.append(button);
+ });
+ await page.keyboard.press('Enter');
+ expect(await submissions()).toBe(0);
+});
+
+test('the window losing focus shows no error, and leaving the input does', async ({ page }) => {
+ await page.goto('/form');
+ const input = page.getByLabel('Postcode');
+ await input.fill('FC01');
+ // A window that loses focus gives the input a blur, but the input stays the active element.
+ await input.evaluate((element) => element.dispatchEvent(new FocusEvent('blur')));
+ await expect(page.getByRole('status')).toHaveText('');
+ await expect(input).toBeFocused();
+ await page.keyboard.press('Tab');
+ await expect(page.getByRole('status')).toHaveText(
+ 'A postcode has 11 letters and numbers. You entered 4.',
+ );
+});
+
+test('confirms a postcode with the mock server at level 2', async ({ page }) => {
+ await page.goto(`${LOOKUPS}&confirm=level2`);
+ await page.getByLabel('Postcode').fill('fc01z99zz01');
+ await expect(page.getByRole('status')).toHaveText(
+ 'We found this postcode: SYNTHETIC LOCALITY, SYNTHETIC LGA, FEDERAL CAPITAL TERRITORY.',
+ );
+ await expectAccessible(page);
+});
+
+test('offline format check: the form still sends a postcode that it could not check', async ({
+ page,
+}) => {
+ await page.route(`${MOCK}/**`, (route) => route.abort());
+ await page.goto(LOOKUPS);
+ await page.getByLabel('Postcode').fill('FC01Z99ZZ01');
+ await expect(page.getByRole('status')).toHaveText(
+ 'We could not check this postcode just now. You can still continue.',
+ );
+ await expectAccessible(page);
+ await page.keyboard.press('Enter');
+ await page.waitForURL('**/submitted?**');
+ expect(await sent(page)).toEqual({ postcode: 'FC-01-Z99-ZZ-01' });
+});
+
+test('fills in a whole postcode from a precise location', async ({ page, context }) => {
+ await context.grantPermissions(['geolocation']);
+ await context.setGeolocation({ latitude: 9, longitude: 7, accuracy: 5 });
+ await page.goto(`${LOOKUPS}&gps`);
+ await page.getByRole('button', { name: 'Use my location' }).click();
+ await expect(page.getByLabel('Postcode')).toHaveValue('FC 01 Z99 ZZ 01');
+ await expect(page.getByRole('status')).toHaveText(
+ 'We found this postcode in Federal Capital Territory.',
+ );
+});
+
+test('coarse GPS: fills in part of the postcode, and the user types the rest', async ({
+ page,
+ context,
+}) => {
+ await context.grantPermissions(['geolocation']);
+ await context.setGeolocation({ latitude: 9, longitude: 7, accuracy: 40 });
+ await page.goto(`${LOOKUPS}&gps`);
+ await page.getByRole('button', { name: 'Use my location' }).click();
+ const input = page.getByLabel('Postcode');
+ await expect(input).toHaveValue('FC 01 Z99 ');
+ await expect(input).toBeFocused();
+ await expectAccessible(page);
+ await page.keyboard.type('ZZ 01');
+ await expect(page.getByRole('status')).toHaveText(
+ 'We found this postcode in Federal Capital Territory.',
+ );
+});
+
+test('permission denied: says so, and the user types the postcode', async ({ page }) => {
+ await page.goto(`${LOOKUPS}&gps`);
+ await page.getByRole('button', { name: 'Use my location' }).click();
+ await expect(page.getByRole('status')).toHaveText(
+ 'We cannot use your location. Type your postcode.',
+ );
+ await expectAccessible(page);
+ // The button has the focus. Shift+Tab goes back to the input, and the user types at once.
+ await page.keyboard.press('Shift+Tab');
+ await expect(page.getByLabel('Postcode')).toBeFocused();
+ await page.keyboard.type('FC01Z99ZZ01');
+ await expect(page.getByRole('status')).toHaveText(
+ 'We found this postcode in Federal Capital Territory.',
+ );
+});
+
+test('refuses a secret key without a request, and logs one error', async ({ page }) => {
+ const errors: string[] = [];
+ const uncaught: string[] = [];
+ const requests: string[] = [];
+ page.on('console', (message) => {
+ if (message.type() === 'error') {
+ errors.push(message.text());
+ }
+ });
+ page.on('pageerror', (error) => uncaught.push(error.message));
+ page.on('request', (request) => requests.push(request.url()));
+ await page.goto(`/form?api-key=nipost_live_abc&base-url=${MOCK}`);
+ await page.getByLabel('Postcode').fill('FC01Z99ZZ01');
+ await expect(page.getByRole('status')).toHaveText(
+ 'We cannot check postcodes on this page. You can still continue.',
+ );
+ // The exact text also shows that the message never holds the key.
+ expect(errors).toEqual([
+ 'gatepost-postcode-field: api-key holds a secret key. Use a publishable key.',
+ ]);
+ expect(uncaught).toEqual([]);
+ expect(requests.filter((url) => !url.startsWith('http://localhost:3000/'))).toEqual([]);
+});
+
+test('fits a screen 320 CSS pixels wide without a sideways scroll', async ({ page }) => {
+ await page.setViewportSize({ width: 320, height: 640 });
+ await page.goto(`${LOOKUPS}&gps`);
+ await page.getByLabel('Postcode').fill('FCO1Z99ZZ01');
+ // The suggestion and the location button are the widest content that the field shows.
+ await expect(page.getByRole('button', { name: 'Use this postcode' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Use my location' })).toBeVisible();
+ const width = await page.evaluate(() => document.documentElement.scrollWidth);
+ expect(width).toBeLessThanOrEqual(320);
+});
+
+test('stops the spinner for a user who asks for reduced motion', async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.route(`${MOCK}/**`, () => undefined);
+ await page.goto(LOOKUPS);
+ await page.getByLabel('Postcode').fill('FC01Z99ZZ01');
+ await expect(page.getByRole('status')).toHaveText('Checking your postcode.');
+ const animation = await page
+ .locator('gatepost-postcode-field #note')
+ .evaluate((note) => getComputedStyle(note, '::before').animationName);
+ expect(animation).toBe('none');
+});
+
+test('a postcode that comes by paste raises a change with the source pasted', async ({ page }) => {
+ await page.goto('/form');
+ await page.evaluate(() => {
+ const sources: string[] = [];
+ Object.assign(window, { sources });
+ document.addEventListener('gatepost-change', (event) => {
+ sources.push((event as CustomEvent<{ source: string }>).detail.source);
+ });
+ });
+ // Playwright cannot paste in every engine, so the test sends the event that a paste raises.
+ const input = page.getByLabel('Postcode');
+ await input.focus();
+ await input.evaluate((element: HTMLInputElement) => {
+ element.value = 'fc 01 z99 zz 01';
+ element.dispatchEvent(
+ new InputEvent('input', { inputType: 'insertFromPaste', bubbles: true, composed: true }),
+ );
+ });
+ const sources = await page.evaluate(() => (window as unknown as { sources: string[] }).sources);
+ expect(sources).toEqual(['pasted']);
+ await page.keyboard.press('Enter');
+ await page.waitForURL('**/submitted?**');
+ expect(await sent(page)).toEqual({ postcode: 'FC-01-Z99-ZZ-01' });
+});
+
+test('a slow lookup does not stop the form', async ({ page }) => {
+ // The request never ends, so the field stays in the state checking.
+ await page.route(`${MOCK}/**`, () => undefined);
+ await page.goto(LOOKUPS);
+ await page.getByLabel('Postcode').fill('FC01Z99ZZ01');
+ await expect(page.getByRole('status')).toHaveText('Checking your postcode.');
+ await page.keyboard.press('Enter');
+ await page.waitForURL('**/submitted?**');
+ expect(await sent(page)).toEqual({ postcode: 'FC-01-Z99-ZZ-01' });
+});
+
+test('the suggestion button fixes a look-alike character from the keyboard', async ({ page }) => {
+ await page.goto('/form');
+ const input = page.getByLabel('Postcode');
+ await input.fill('FCO1Z99ZZ01');
+ await page.keyboard.press('Tab');
+ const button = page.getByRole('button', { name: 'Use this postcode' });
+ await expect(button).toBeFocused();
+ await page.keyboard.press('Enter');
+ await expect(input).toHaveValue('FC 01 Z99 ZZ 01');
+ await expect(input).toBeFocused();
+});
+
+test('the back button brings back the typed text', async ({ page }) => {
+ await page.goto('/form');
+ const input = page.getByLabel('Postcode');
+ await input.fill('FC01Z99ZZ01');
+ await page.keyboard.press('Enter');
+ await page.waitForURL('**/submitted?**');
+ await page.goBack();
+ await expect(page).toHaveURL(/\/form/);
+ await expect(input).toHaveValue('FC01Z99ZZ01');
+ await expect(page.getByRole('status')).toHaveText(
+ 'This postcode is in Federal Capital Territory.',
+ );
+ await input.focus();
+ await page.keyboard.press('Enter');
+ await page.waitForURL('**/submitted?**');
+ expect(await sent(page)).toEqual({ postcode: 'FC-01-Z99-ZZ-01' });
+});
diff --git a/packages/field/test/journeys/readme.test.ts b/packages/field/test/journeys/readme.test.ts
new file mode 100644
index 0000000..26f81b9
--- /dev/null
+++ b/packages/field/test/journeys/readme.test.ts
@@ -0,0 +1,105 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { existsSync, readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { type Page } from '@playwright/test';
+import { expect, test } from './fixtures.js';
+
+const README = readFileSync(new URL('../../README.md', import.meta.url), 'utf8');
+const EXAMPLES = [...README.matchAll(/```html\n([\s\S]*?)```/g)].map((match) => match[1] ?? '');
+const CDN = 'https://cdn.jsdelivr.net/npm/@gatepost/field/dist/element.js';
+const ELEMENT = fileURLToPath(new URL('../../dist/element.js', import.meta.url));
+const MOCK = 'http://127.0.0.1:4010';
+
+// Serves one example of the README as a page of the test server's origin, with the CDN file
+// in place of the built element. No request leaves the machine.
+async function open(page: Page, example: string): Promise {
+ await page.route(CDN, (route) =>
+ route.fulfill({ path: ELEMENT, contentType: 'text/javascript' }),
+ );
+ await page.route('http://localhost:3000/readme', (route) =>
+ route.fulfill({
+ contentType: 'text/html',
+ body: `README ${example} `,
+ }),
+ );
+ await page.goto('/readme');
+}
+
+test('the README holds the four HTML examples that these tests run', () => {
+ expect(EXAMPLES).toHaveLength(4);
+ expect(README).toContain('> Unofficial. Not made or endorsed by NIPOST.');
+ const version = readFileSync(new URL('../../../../spec/VERSION', import.meta.url), 'utf8').trim();
+ expect(README).toMatch(new RegExp(`Gatepost spec \\| ${version.replaceAll('.', '\\.')} +\\|`));
+ expect(README).toContain('The first alpha is not on npm yet');
+ // The CDN address names this file of the package, which the test replaces with the build.
+ expect(existsSync(ELEMENT)).toBe(true);
+});
+
+test('the quickstart sends the canonical form', async ({ page }) => {
+ await open(page, EXAMPLES[0]!);
+ await page.getByLabel('Postcode').fill('fc 01 z99 zz 01');
+ await page.keyboard.press('Enter');
+ // The test server has no /address page, so the test waits for the request, not for a load.
+ await page.waitForURL('**/address?postcode=FC-01-Z99-ZZ-01', { waitUntil: 'commit' });
+});
+
+test('the NIPOST example confirms the postcode, here through the mock server', async ({ page }) => {
+ // The mock server stands in for the gateway, with its own publishable key.
+ await page.route('https://api.postcode.gov.ng/**', async (route) => {
+ const url = new URL(route.request().url());
+ const headers = { ...route.request().headers(), 'x-api-key': 'nipost_pk_test_mock' };
+ const response = await route.fetch({ url: `${MOCK}${url.pathname}${url.search}`, headers });
+ await route.fulfill({ response });
+ });
+ await open(page, EXAMPLES[1]!);
+ await page.getByLabel('Postcode').fill('FC01Z99ZZ01');
+ await expect(page.getByRole('status')).toHaveText(
+ 'We found this postcode: SYNTHETIC LOCALITY, SYNTHETIC LGA, FEDERAL CAPITAL TERRITORY.',
+ );
+ await expect(page.getByRole('button', { name: 'Use my location' })).toBeVisible();
+});
+
+test('the change example shows each new form value', async ({ page }) => {
+ await open(page, EXAMPLES[2]!);
+ await page.getByLabel('Postcode').fill('fc01z99zz01');
+ await expect(page.locator('#chosen')).toHaveText('FC-01-Z99-ZZ-01');
+});
+
+test('the theming example changes the label, the messages and the accent colour', async ({
+ page,
+}) => {
+ await open(page, EXAMPLES[3]!);
+ const field = page.getByLabel('Delivery postcode');
+ await field.focus();
+ // The outline of the focused input is the field's own use of the accent token.
+ const outline = await field.evaluate((input) => getComputedStyle(input).outlineColor);
+ expect(outline).toBe('rgb(91, 42, 134)');
+ await field.blur();
+ await expect(page.getByRole('status')).toHaveText('Enter the postcode of the delivery.');
+});
+
+test('a property set before the element is defined reaches the field, now and later', async ({
+ page,
+}) => {
+ await page.route('http://localhost:3000/early', (route) =>
+ route.fulfill({
+ contentType: 'text/html',
+ body: [
+ 'Early ',
+ ' ',
+ '',
+ '',
+ ].join('\n'),
+ }),
+ );
+ await page.goto('/early');
+ await expect(page.getByLabel('Early')).toBeVisible();
+ await page.evaluate(() => {
+ const field = document.querySelector('gatepost-postcode-field');
+ Object.assign(field ?? {}, { messages: { label: 'Later' } });
+ });
+ await expect(page.getByLabel('Later')).toBeVisible();
+});
diff --git a/packages/field/test/journeys/serve.mjs b/packages/field/test/journeys/serve.mjs
new file mode 100644
index 0000000..52a0e0e
--- /dev/null
+++ b/packages/field/test/journeys/serve.mjs
@@ -0,0 +1,75 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+// Serves a plain HTML form with the built field on http://localhost:3000, the origin that the
+// mock server allows for its publishable key, and starts the mock server on port 4010.
+// The query of /form becomes the attributes of the field, and /submitted shows what the form
+// sent. Run `pnpm build` first.
+import { readFileSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { URL } from 'node:url';
+import { startMockServer } from '@gatepost/mock-server';
+
+const ELEMENT = new URL('../../dist/element.js', import.meta.url);
+const ATTRIBUTES = ['api-key', 'base-url', 'confirm', 'gps', 'label', 'legacy', 'required'];
+
+function escape(text) {
+ return text.replace(/[&<>"]/g, (character) => `${character.charCodeAt(0)};`);
+}
+
+function page(title, body) {
+ return [
+ '',
+ '',
+ ' ',
+ ' ',
+ `${title} `,
+ // An empty icon, so no browser asks for /favicon.ico and logs a 404.
+ ' ',
+ // The page's own button meets the target size of WCAG 2.2, so axe checks only the field.
+ '',
+ '',
+ `${body} `,
+ '',
+ ].join('\n');
+}
+
+function formPage(query) {
+ const attributes = ATTRIBUTES.filter((name) => query.has(name))
+ .map((name) => ` ${name}="${escape(query.get(name) ?? '')}"`)
+ .join('');
+ return page(
+ 'Delivery address',
+ [
+ 'Delivery address ',
+ '',
+ ].join('\n'),
+ );
+}
+
+function submittedPage(query) {
+ const sent = JSON.stringify(Object.fromEntries(query));
+ return page('Sent', `Sent ${escape(sent)} `);
+}
+
+const routes = {
+ '/form': (query) => ['text/html', formPage(query)],
+ '/submitted': (query) => ['text/html', submittedPage(query)],
+ '/element.js': () => ['text/javascript', readFileSync(ELEMENT)],
+};
+
+await startMockServer({ port: 4010 });
+createServer((request, response) => {
+ const url = new URL(request.url ?? '/', 'http://localhost:3000');
+ const route = routes[url.pathname];
+ if (route === undefined) {
+ response.writeHead(404).end();
+ return;
+ }
+ const [type, body] = route(url.searchParams);
+ response.writeHead(200, { 'Content-Type': `${type}; charset=utf-8` }).end(body);
+ // The tests reach the page as localhost. The server listens on the IPv4 address, which
+ // the browsers fall back to when localhost resolves to ::1 first.
+}).listen(3000, '127.0.0.1');
diff --git a/packages/field/test/location.test.ts b/packages/field/test/location.test.ts
new file mode 100644
index 0000000..7b938b3
--- /dev/null
+++ b/packages/field/test/location.test.ts
@@ -0,0 +1,639 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { PostcodeClient, type ReverseResult } from '@gatepost/client';
+import { parse, type Postcode } from '@gatepost/core';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import type { ErrorDetail } from '../src/index.js';
+import { postcodeAtFix, radiusFor } from '../src/location.js';
+import { expectAccessible, mount } from './field.js';
+import { fixture, scriptGateway } from './gateway.js';
+
+const KEY = 'nipost_pk_test_mock';
+const FIELD = ` `;
+const COARSE = 'We added part of your postcode from your location. Type the rest.';
+const UNAVAILABLE = 'We could not find your location. Type your postcode.';
+const NOT_FOUND = 'We found no postcode at your location. Type your postcode.';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+function postcode(text: string): Postcode {
+ const parsed = parse(text, { allowPartial: true });
+ if (!parsed.ok) {
+ throw new Error(`${text} does not parse.`);
+ }
+ return parsed.value;
+}
+
+const FOUND: ReverseResult = {
+ found: true,
+ radiusM: 25,
+ unit: {
+ postcode: postcode('FC-01-Z99-ZZ-01'),
+ distanceM: 4.2,
+ confidence: 'low',
+ stateName: null,
+ lgaName: null,
+ localityName: null,
+ address: null,
+ },
+ area: 'FC-01-Z99-ZZ',
+ district: 'FC-01-Z99',
+ state: 'FC',
+};
+
+// The browser's location, at the point of the synthetic unit, with the given accuracy.
+function placeDevice(accuracy: number): void {
+ vi.spyOn(navigator.geolocation, 'getCurrentPosition').mockImplementation((success) => {
+ success({ coords: { latitude: 9, longitude: 7, accuracy } } as GeolocationPosition);
+ });
+}
+
+// The browser's location, which the test gives later through the returned function.
+function holdDevice(): (accuracy: number) => void {
+ let place: (position: GeolocationPosition) => void = () => undefined;
+ vi.spyOn(navigator.geolocation, 'getCurrentPosition').mockImplementation((success) => {
+ place = success;
+ });
+ return (accuracy) => {
+ place({ coords: { latitude: 9, longitude: 7, accuracy } } as GeolocationPosition);
+ };
+}
+
+// GeolocationPositionError has no public constructor, so the stand-in borrows its prototype.
+function refuseLocation(code: number): void {
+ const error = Object.create(GeolocationPositionError.prototype, {
+ code: { value: code },
+ }) as GeolocationPositionError;
+ vi.spyOn(navigator.geolocation, 'getCurrentPosition').mockImplementation((_success, failure) => {
+ failure?.(error);
+ });
+}
+
+// Lets every pending promise and timer of the page run before a test checks that nothing happened.
+async function settle(): Promise {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+}
+
+function listen(target: EventTarget): ErrorDetail[] {
+ const details: ErrorDetail[] = [];
+ target.addEventListener('gatepost-error', (event) => {
+ details.push((event as CustomEvent).detail);
+ });
+ return details;
+}
+
+describe('radiusFor', () => {
+ it.each([
+ [3, 25],
+ [40.2, 41],
+ [900, 250],
+ ])('gives a radius of %d m a radius of %d m', (accuracy, radius) => {
+ expect(radiusFor(accuracy)).toBe(radius);
+ });
+});
+
+describe('postcodeAtFix', () => {
+ it.each([
+ [5, 'FC-01-Z99-ZZ-01'],
+ [15, 'FC-01-Z99-ZZ'],
+ [40, 'FC-01-Z99'],
+ [80, 'FC-01'],
+ ])('keeps the segments that an accuracy of %d m supports', (accuracy, canonical) => {
+ expect(postcodeAtFix(FOUND, accuracy)?.canonical).toBe(canonical);
+ });
+
+ it('uses the area when the result has no unit, and keeps it whole for a precise fix', () => {
+ expect(postcodeAtFix({ ...FOUND, unit: null }, 5)?.canonical).toBe('FC-01-Z99-ZZ');
+ });
+
+ it('truncates the area of a result with no unit, and the district with no area', () => {
+ expect(postcodeAtFix({ ...FOUND, unit: null }, 80)?.canonical).toBe('FC-01');
+ const district = { ...FOUND, unit: null, area: null };
+ expect(postcodeAtFix(district, 15)?.canonical).toBe('FC-01-Z99');
+ });
+
+ it('gives null when the result holds no postcode that parses', () => {
+ const empty = { ...FOUND, found: false, unit: null, area: null, district: 'not a code' };
+ expect(postcodeAtFix(empty, 5)).toBeNull();
+ });
+});
+
+describe('the location button', () => {
+ it('shows only with gps and a key', () => {
+ expect(mount(FIELD).location.hidden).toBe(false);
+ expect(mount(' ').location.hidden).toBe(
+ true,
+ );
+ expect(
+ mount(` `).location.hidden,
+ ).toBe(true);
+ });
+
+ it('shows no button for a secret key', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const html =
+ ' ';
+ expect(mount(html).location.hidden).toBe(true);
+ expect(error).toHaveBeenCalledTimes(1);
+ });
+
+ it('fills in a whole postcode from a precise location, and then confirms it', async () => {
+ placeDevice(5);
+ const { requests } = scriptGateway(
+ await fixture('reverse/unit'),
+ await fixture('lookup/valid-level-1'),
+ );
+ const { input, location, message, changes } = mount(FIELD);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ expect(input.value).toBe('FC 01 Z99 ZZ 01');
+ expect(changes).toEqual([
+ expect.objectContaining({ value: 'FC-01-Z99-ZZ-01', source: 'gps', accuracyM: 5 }),
+ ]);
+ expect(requests[0]?.url.pathname).toBe('/v1/search/reverse');
+ expect(requests[0]?.url.searchParams.get('max_distance_m')).toBe('25');
+ });
+
+ it('coarse GPS: fills in only the district, and asks the user to type the rest', async () => {
+ placeDevice(40);
+ const { requests } = scriptGateway(await fixture('reverse/unit'));
+ const { field, input, location, message } = mount(FIELD);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe(COARSE);
+ });
+ expect(input.value).toBe('FC 01 Z99 ');
+ expect(field.shadowRoot?.activeElement).toBe(input);
+ expect(field.checkValidity()).toBe(false);
+ expect(requests).toHaveLength(1);
+ expect(requests[0]?.url.searchParams.get('max_distance_m')).toBe('40');
+ await expectAccessible();
+ });
+
+ it('coarse GPS: a fix worse than 50 m fills in the state and the LGA only', async () => {
+ placeDevice(80);
+ scriptGateway(await fixture('reverse/unit'));
+ const { input, location, message } = mount(FIELD);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe(COARSE);
+ });
+ expect(input.value).toBe('FC 01 ');
+ });
+
+ it('fills in the area of a result with no unit, and sends no lookup for it', async () => {
+ placeDevice(5);
+ const { requests } = scriptGateway(await fixture('reverse/area'));
+ const { input, location, message } = mount(FIELD);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe(COARSE);
+ });
+ expect(input.value).toBe('FC 01 Z99 ZZ ');
+ expect(requests).toHaveLength(1);
+ });
+
+ it('never shows the address of the unit that reverse found', async () => {
+ placeDevice(5);
+ const unit = await fixture('reverse/unit');
+ const body = (await unit.json()) as { data: { unit: Record } };
+ body.data.unit['address'] = '12 SYNTHETIC STREET';
+ scriptGateway(Response.json(body), await fixture('lookup/valid-level-2'));
+ const { field, location, message } = mount(FIELD);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toContain('We found this postcode');
+ });
+ expect(field.shadowRoot?.textContent).not.toContain('SYNTHETIC STREET');
+ });
+
+ it('permission denied: says so, raises gps_denied, and keeps the input ready', async () => {
+ refuseLocation(1);
+ const fetch = vi.spyOn(globalThis, 'fetch');
+ const { form, input, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ expect(message()).toBe('We cannot use your location. Type your postcode.');
+ expect(errors).toEqual([{ code: 'gps_denied' }]);
+ expect(input.disabled).toBe(false);
+ expect(fetch).not.toHaveBeenCalled();
+ await expectAccessible();
+ });
+
+ it('says when the device gives no location', async () => {
+ refuseLocation(2);
+ const { form, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ expect(message()).toBe('We could not find your location. Type your postcode.');
+ expect(errors).toEqual([{ code: 'gps_unavailable' }]);
+ });
+
+ it('says when the device gives no location within the time limit', async () => {
+ refuseLocation(3);
+ const { form, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ expect(message()).toBe('We could not find your location. Type your postcode.');
+ expect(errors).toEqual([{ code: 'gps_unavailable' }]);
+ });
+
+ it('says that the location is unavailable in a browser with no location API', async () => {
+ const api = Object.getOwnPropertyDescriptor(Navigator.prototype, 'geolocation')!;
+ Reflect.deleteProperty(Navigator.prototype, 'geolocation');
+ try {
+ const { form, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ expect(message()).toBe('We could not find your location. Type your postcode.');
+ expect(errors).toEqual([{ code: 'gps_unavailable' }]);
+ } finally {
+ Object.defineProperty(Navigator.prototype, 'geolocation', api);
+ }
+ });
+
+ it('says when the gateway has no postcode at the location', async () => {
+ placeDevice(5);
+ scriptGateway(await fixture('reverse/not-found'));
+ const { form, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe(NOT_FOUND);
+ });
+ expect(errors).toEqual([]);
+ });
+
+ it('raises the error code of the client when reverse fails', async () => {
+ placeDevice(5);
+ scriptGateway(await fixture('errors/rate-limited'));
+ const { form, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(errors).toEqual([{ code: 'rate_limited' }]);
+ });
+ expect(message()).toBe('We could not find your location. Type your postcode.');
+ });
+
+ it('ends as a failed request when the base-url has no scheme', async () => {
+ placeDevice(5);
+ const { requests } = scriptGateway();
+ const { form, location, message } = mount(
+ `` +
+ ' ',
+ );
+ const errors = listen(form);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe(UNAVAILABLE);
+ });
+ expect(errors).toEqual([{ code: 'network_error' }]);
+ expect(requests).toEqual([]);
+ });
+
+ it('ends as a failed request when the client throws a plain error', async () => {
+ placeDevice(5);
+ vi.spyOn(PostcodeClient.prototype, 'reverse').mockRejectedValueOnce(new Error('It broke.'));
+ const { form, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe(UNAVAILABLE);
+ });
+ expect(errors).toEqual([{ code: 'network_error' }]);
+ });
+
+ it('ends as a failed request when the device call throws a plain error', async () => {
+ vi.spyOn(navigator.geolocation, 'getCurrentPosition').mockImplementation(() => {
+ throw new Error('It broke.');
+ });
+ const { form, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe(UNAVAILABLE);
+ });
+ expect(errors).toEqual([{ code: 'gps_unavailable' }]);
+ });
+
+ it('gives gps_denied to a refusal that is a plain object with the code 1', async () => {
+ vi.spyOn(navigator.geolocation, 'getCurrentPosition').mockImplementation(
+ (_success, failure) => {
+ failure?.({ code: 1, message: 'Blocked.' } as GeolocationPositionError);
+ },
+ );
+ const { form, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe('We cannot use your location. Type your postcode.');
+ });
+ expect(errors).toEqual([{ code: 'gps_denied' }]);
+ });
+
+ it('gives gps_unavailable to a device call that is missing', async () => {
+ vi.spyOn(navigator.geolocation, 'getCurrentPosition').mockImplementation(() => {
+ throw new TypeError('getCurrentPosition is not a function');
+ });
+ const { form, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe(UNAVAILABLE);
+ });
+ expect(errors).toEqual([{ code: 'gps_unavailable' }]);
+ });
+
+ it('shows the state GPS locating, and typing cancels it', async () => {
+ const place = holdDevice();
+ const fetch = vi.spyOn(globalThis, 'fetch');
+ const { input, location, message } = mount(FIELD);
+ await userEvent.click(location);
+ expect(message()).toBe('Finding your location.');
+ await expectAccessible();
+ await userEvent.type(input, 'FC');
+ place(5);
+ await settle();
+ expect(input.value).toBe('FC');
+ expect(message()).toBe('');
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('comes after the input in the tab order, and works with the Enter key', async () => {
+ placeDevice(5);
+ scriptGateway(await fixture('reverse/not-found'));
+ const { field, input, location, message } = mount(FIELD);
+ input.focus();
+ await userEvent.tab();
+ expect(field.shadowRoot?.activeElement).toBe(location);
+ await userEvent.keyboard('{Enter}');
+ await vi.waitFor(() => {
+ expect(message()).toBe(NOT_FOUND);
+ });
+ });
+
+ it('raises change after the field shows the coarse state', async () => {
+ placeDevice(40);
+ scriptGateway(await fixture('reverse/unit'));
+ const { form, location, message } = mount(FIELD);
+ const seen: string[] = [];
+ form.addEventListener('gatepost-change', () => {
+ seen.push(message());
+ });
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe(COARSE);
+ });
+ expect(seen).toEqual([COARSE]);
+ });
+
+ it('ends a location request when the field leaves the page', async () => {
+ const place = holdDevice();
+ const { requests } = scriptGateway();
+ const { form, field, input, location, message } = mount(FIELD);
+ await userEvent.click(location);
+ field.remove();
+ place(5);
+ await settle();
+ form.append(field);
+ expect(input.value).toBe('');
+ expect(message()).toBe('');
+ expect(requests).toEqual([]);
+ });
+
+ it('cancels the first request when the user presses the button again', async () => {
+ const places: ((position: GeolocationPosition) => void)[] = [];
+ vi.spyOn(navigator.geolocation, 'getCurrentPosition').mockImplementation((success) => {
+ places.push(success);
+ });
+ const { requests } = scriptGateway(await fixture('reverse/unit'));
+ const { input, location, message } = mount(FIELD);
+ await userEvent.click(location);
+ await userEvent.click(location);
+ expect(places).toHaveLength(2);
+ places[0]?.({ coords: { latitude: 9, longitude: 7, accuracy: 40 } } as GeolocationPosition);
+ await settle();
+ expect(input.value).toBe('');
+ expect(requests).toEqual([]);
+ places[1]?.({ coords: { latitude: 9, longitude: 7, accuracy: 40 } } as GeolocationPosition);
+ await vi.waitFor(() => {
+ expect(message()).toBe(COARSE);
+ });
+ expect(requests).toHaveLength(1);
+ });
+
+ it('keeps the coarse message when required changes', async () => {
+ placeDevice(40);
+ scriptGateway(await fixture('reverse/unit'));
+ const { field, location, message } = mount(FIELD);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe(COARSE);
+ });
+ field.setAttribute('required', '');
+ expect(message()).toBe(COARSE);
+ });
+
+ it('cancels the request when a rewrite keeps the postcode of a lookup in progress', async () => {
+ const place = holdDevice();
+ const never = new Promise(() => undefined);
+ const { requests } = scriptGateway(never, never);
+ const { field, input, location } = mount(FIELD);
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await userEvent.click(location);
+ field.value = 'fc01z99zz01';
+ place(5);
+ await settle();
+ expect(input.value).toBe('fc01z99zz01');
+ expect(requests.map((request) => request.url.pathname)).toEqual(['/v1/lookup', '/v1/lookup']);
+ });
+
+ it('keeps the location request when a setting changes after the blur reformat', async () => {
+ const place = holdDevice();
+ scriptGateway(
+ new Promise(() => undefined),
+ await fixture('reverse/unit'),
+ await fixture('lookup/valid-level-1'),
+ );
+ const { field, input, location, message } = mount(FIELD);
+ await userEvent.type(input, 'fc01z99zz01');
+ await userEvent.tab();
+ await userEvent.tab();
+ await userEvent.click(location);
+ expect(input.value).toBe('FC 01 Z99 ZZ 01');
+ field.setAttribute('required', '');
+ expect(message()).toBe('Finding your location.');
+ place(5);
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ });
+
+ it('keeps a refused location message when a setting changes after the reformat', async () => {
+ refuseLocation(1);
+ scriptGateway(new Promise(() => undefined));
+ const { field, input, location, message } = mount(FIELD);
+ await userEvent.type(input, 'fc01z99zz01');
+ await userEvent.tab();
+ await userEvent.click(location);
+ field.setAttribute('required', '');
+ expect(message()).toBe('We cannot use your location. Type your postcode.');
+ });
+
+ it('turns off with the form', () => {
+ const { location } = mount(`${FIELD} `);
+ expect(location.disabled).toBe(true);
+ });
+});
+
+describe('a location request and the settings', () => {
+ it('keeps the message of a refused location when required changes', async () => {
+ refuseLocation(1);
+ const { field, location, message } = mount(FIELD);
+ await userEvent.click(location);
+ field.setAttribute('required', '');
+ expect(message()).toBe('We cannot use your location. Type your postcode.');
+ });
+
+ it('keeps the location request when a setting changes', async () => {
+ const place = holdDevice();
+ scriptGateway(await fixture('reverse/unit'), await fixture('lookup/valid-level-1'));
+ const { field, input, location, message } = mount(FIELD);
+ await userEvent.click(location);
+ field.setAttribute('required', '');
+ expect(message()).toBe('Finding your location.');
+ place(5);
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ expect(input.value).toBe('FC 01 Z99 ZZ 01');
+ });
+
+ it('sends reverse with the key that the field holds when the position arrives', async () => {
+ const place = holdDevice();
+ const { requests } = scriptGateway(
+ await fixture('reverse/unit'),
+ await fixture('reverse/unit'),
+ );
+ const { field, location } = mount(FIELD);
+ await userEvent.click(location);
+ field.setAttribute('api-key', 'nipost_pk_test_other');
+ place(40);
+ await vi.waitFor(() => {
+ expect(requests).toHaveLength(1);
+ });
+ expect(requests[0]?.headers.get('X-API-Key')).toBe('nipost_pk_test_other');
+ });
+
+ it('sends nothing and says so when the key is a secret key as the position arrives', async () => {
+ const place = holdDevice();
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const fetch = vi.spyOn(globalThis, 'fetch');
+ const { form, field, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ field.setAttribute('api-key', 'nipost_live_abc');
+ place(5);
+ await vi.waitFor(() => {
+ expect(errors).toEqual([{ code: 'secret_key' }]);
+ });
+ expect(message()).toBe('We cannot check postcodes on this page. You can still continue.');
+ expect(fetch).not.toHaveBeenCalled();
+ expect(error).toHaveBeenCalledTimes(1);
+ });
+
+ it('sends nothing and shows the state of the text when the key is gone', async () => {
+ const place = holdDevice();
+ const fetch = vi.spyOn(globalThis, 'fetch');
+ const { form, field, location, message } = mount(FIELD);
+ const errors = listen(form);
+ await userEvent.click(location);
+ field.removeAttribute('api-key');
+ place(5);
+ await settle();
+ expect(message()).toBe('');
+ expect(errors).toEqual([]);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('lets a reverse call that was sent before a key change finish', async () => {
+ placeDevice(40);
+ let answer: (response: Response) => void = () => undefined;
+ const pending = new Promise((resolve) => {
+ answer = resolve;
+ });
+ const { requests } = scriptGateway(pending);
+ const { field, input, location, message } = mount(FIELD);
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(requests).toHaveLength(1);
+ });
+ field.setAttribute('api-key', 'nipost_pk_test_other');
+ answer(await fixture('reverse/unit'));
+ await vi.waitFor(() => {
+ expect(message()).toBe(COARSE);
+ });
+ expect(input.value).toBe('FC 01 Z99 ');
+ });
+
+ it('checks a location again, even after the same postcode was confirmed', async () => {
+ placeDevice(5);
+ const { requests } = scriptGateway(
+ await fixture('lookup/valid-level-1'),
+ await fixture('reverse/unit'),
+ await fixture('lookup/valid-level-1'),
+ );
+ const { input, location, message } = mount(FIELD);
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(requests).toHaveLength(3);
+ });
+ expect(requests.map((request) => request.url.pathname)).toEqual([
+ '/v1/lookup',
+ '/v1/search/reverse',
+ '/v1/lookup',
+ ]);
+ });
+
+ it('checks a location again, even after a lookup of it was cancelled', async () => {
+ placeDevice(5);
+ const never = new Promise(() => undefined);
+ const { requests } = scriptGateway(
+ never,
+ await fixture('reverse/unit'),
+ await fixture('lookup/valid-level-1'),
+ );
+ const { input, location, message } = mount(FIELD);
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ expect(requests).toHaveLength(3);
+ });
+
+ it('raises no change when a location fill keeps the form value', async () => {
+ placeDevice(40);
+ scriptGateway(await fixture('reverse/unit'));
+ const { input, location, message, changes } = mount(FIELD);
+ await userEvent.type(input, 'FC 01 Z99 ');
+ const before = changes.length;
+ await userEvent.click(location);
+ await vi.waitFor(() => {
+ expect(message()).toBe(COARSE);
+ });
+ expect(changes).toHaveLength(before);
+ });
+});
diff --git a/packages/field/test/lookup.test.ts b/packages/field/test/lookup.test.ts
new file mode 100644
index 0000000..bd980ac
--- /dev/null
+++ b/packages/field/test/lookup.test.ts
@@ -0,0 +1,423 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { PostcodeClient, type LookupResult } from '@gatepost/client';
+import { parse } from '@gatepost/core';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import type { ConfirmDetail, ErrorDetail } from '../src/index.js';
+import { expectAccessible, mount } from './field.js';
+import { fixture, scriptGateway } from './gateway.js';
+
+// The publishable key of the mock server. The tests answer each request themselves.
+const KEY = 'nipost_pk_test_mock';
+const FIELD =
+ `` + ' ';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+function listen(target: EventTarget, type: string): Detail[] {
+ const details: Detail[] = [];
+ target.addEventListener(type, (event) => {
+ details.push((event as CustomEvent).detail);
+ });
+ return details;
+}
+
+describe('lookups', () => {
+ it('checks a whole postcode at level 1 with the key, and names its state', async () => {
+ let answer: (response: Response) => void = () => undefined;
+ const pending = new Promise((resolve) => {
+ answer = resolve;
+ });
+ const { requests } = scriptGateway(pending);
+ const { form, input, message } = mount(FIELD);
+ const confirms = listen(form, 'gatepost-confirm');
+ await userEvent.type(input, 'fc01z99zz01');
+ expect(message()).toBe('Checking your postcode.');
+ answer(await fixture('lookup/valid-level-1'));
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ expect(requests).toHaveLength(1);
+ expect(requests[0]?.url.pathname).toBe('/v1/lookup');
+ expect(requests[0]?.url.searchParams.get('code')).toBe('FC-01-Z99-ZZ-01');
+ expect(requests[0]?.url.searchParams.get('level')).toBe('1');
+ expect(requests[0]?.headers.get('X-API-Key')).toBe(KEY);
+ expect(confirms.map((detail) => detail.lookup.valid)).toEqual([true]);
+ });
+
+ it('names the place at level 2, and never shows the house address', async () => {
+ const { requests } = scriptGateway(await fixture('lookup/valid-level-2'));
+ const { field, input, message } = mount(
+ ` `,
+ );
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await vi.waitFor(() => {
+ expect(message()).toBe(
+ 'We found this postcode: SYNTHETIC LOCALITY, SYNTHETIC LGA, FEDERAL CAPITAL TERRITORY.',
+ );
+ });
+ expect(requests[0]?.url.searchParams.get('level')).toBe('2');
+ expect(field.shadowRoot?.textContent).not.toContain('SYNTHETIC STREET');
+ });
+
+ it('says when the gateway does not know the postcode, and still submits it', async () => {
+ scriptGateway(await fixture('lookup/not-found'));
+ const { form, field, input, message } = mount(FIELD);
+ const confirms = listen(form, 'gatepost-confirm');
+ const errors = listen(form, 'gatepost-error');
+ await userEvent.type(input, 'FC01Z99ZZ02');
+ await vi.waitFor(() => {
+ expect(message()).toBe(
+ 'We could not find this postcode. Check each part, or continue if you are sure.',
+ );
+ });
+ expect(field.checkValidity()).toBe(true);
+ expect(new FormData(form).get('postcode')).toBe('FC-01-Z99-ZZ-02');
+ expect([confirms, errors]).toEqual([[], []]);
+ });
+
+ it('offline format check: keeps the check and the form when the network fails', async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance', 'Date'] });
+ const offline = new TypeError('Failed to fetch');
+ const { requests } = scriptGateway(offline, offline, offline);
+ const { form, field, input, message } = mount(FIELD);
+ const errors = listen(form, 'gatepost-error');
+ input.value = 'FC01Z99ZZ01';
+ input.dispatchEvent(new InputEvent('input'));
+ await vi.runAllTimersAsync();
+ expect(requests).toHaveLength(3);
+ expect(message()).toBe('We could not check this postcode just now. You can still continue.');
+ expect(errors).toEqual([{ code: 'network_error' }]);
+ expect(field.checkValidity()).toBe(true);
+ expect(new FormData(form).get('postcode')).toBe('FC-01-Z99-ZZ-01');
+ });
+
+ it('raises the error code of the client when the gateway refuses the call', async () => {
+ scriptGateway(await fixture('errors/rate-limited'));
+ const { form, input, message } = mount(FIELD);
+ const errors = listen(form, 'gatepost-error');
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await vi.waitFor(() => {
+ expect(errors).toEqual([{ code: 'rate_limited' }]);
+ });
+ expect(message()).toBe('We could not check this postcode just now. You can still continue.');
+ });
+
+ it('cancels the lookup of the previous text when the text changes', async () => {
+ const { requests } = scriptGateway(
+ new Promise(() => undefined),
+ await fixture('lookup/not-found'),
+ );
+ const { form, input, message } = mount(FIELD);
+ const confirms = listen(form, 'gatepost-confirm');
+ const errors = listen(form, 'gatepost-error');
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await vi.waitFor(() => {
+ expect(requests).toHaveLength(1);
+ });
+ await userEvent.type(input, '{Backspace}2');
+ await vi.waitFor(() => {
+ expect(message()).toContain('We could not find this postcode.');
+ });
+ expect(requests[0]?.signal?.aborted).toBe(true);
+ expect(requests[1]?.url.searchParams.get('code')).toBe('FC-01-Z99-ZZ-02');
+ expect(confirms).toEqual([]);
+ expect(errors).toEqual([]);
+ });
+
+ it('sends no request when it has no key, or an empty one', async () => {
+ const { requests } = scriptGateway();
+ for (const html of [
+ ' ',
+ ' ',
+ ]) {
+ const { input, message } = mount(html);
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await userEvent.tab();
+ expect(message()).toBe('This postcode is in Federal Capital Territory.');
+ }
+ expect(requests).toEqual([]);
+ });
+
+ it('sends no lookup when confirm is none, or for a legacy postcode', async () => {
+ const { requests } = scriptGateway();
+ const { input, message } = mount(
+ ` `,
+ );
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ expect(message()).toBe('This postcode is in Federal Capital Territory.');
+ const legacy = mount(FIELD);
+ await userEvent.type(legacy.input, '900108');
+ expect(requests).toEqual([]);
+ });
+
+ it('has no WCAG 2.2 AA violation while checking, confirmed, not found and failed', async () => {
+ scriptGateway(
+ new Promise(() => undefined),
+ await fixture('lookup/valid-level-1'),
+ await fixture('lookup/not-found'),
+ await fixture('errors/rate-limited'),
+ );
+ const { input, message } = mount(FIELD);
+ for (const [text, shown] of [
+ ['FC01Z99ZZ01', 'Checking'],
+ ['{Backspace}1', 'We found'],
+ ['{Backspace}2', 'We could not find'],
+ ['{Backspace}3', 'We could not check'],
+ ] as const) {
+ await userEvent.type(input, text);
+ await vi.waitFor(() => {
+ expect(message()).toContain(shown);
+ });
+ await expectAccessible();
+ }
+ });
+});
+
+describe('what the field remembers', () => {
+ it('sends no new lookup when the text changes but the canonical code stays', async () => {
+ const { requests } = scriptGateway(await fixture('lookup/valid-level-1'));
+ const { input, message } = mount(FIELD);
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ await userEvent.type(input, ' ');
+ expect(requests).toHaveLength(1);
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+
+ it('tries a postcode again after a failed lookup', async () => {
+ const { requests } = scriptGateway(
+ await fixture('errors/rate-limited'),
+ await fixture('lookup/valid-level-1'),
+ );
+ const { field, input, message } = mount(FIELD);
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await vi.waitFor(() => {
+ expect(message()).toContain('We could not check');
+ });
+ field.setAttribute('required', '');
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ expect(requests).toHaveLength(2);
+ });
+
+ it('starts the lookup again when the field leaves the page and comes back', async () => {
+ const { requests } = scriptGateway(
+ new Promise(() => undefined),
+ await fixture('lookup/valid-level-1'),
+ );
+ const { form, field, input, message } = mount(FIELD);
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await vi.waitFor(() => {
+ expect(requests).toHaveLength(1);
+ });
+ field.remove();
+ expect(requests[0]?.signal?.aborted).toBe(true);
+ form.append(field);
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ expect(requests).toHaveLength(2);
+ });
+
+ it('does not retry a failed lookup when only the gateway address changes', async () => {
+ const { requests } = scriptGateway(
+ await fixture('errors/rate-limited'),
+ await fixture('lookup/valid-level-1'),
+ );
+ const { field, input, message } = mount(FIELD);
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await vi.waitFor(() => {
+ expect(message()).toContain('We could not check');
+ });
+ field.setAttribute('base-url', 'http://127.0.0.1:4010');
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(requests).toHaveLength(1);
+ expect(message()).toContain('We could not check');
+ await userEvent.type(input, '{Backspace}1');
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ expect(requests[1]?.url.origin).toBe('http://127.0.0.1:4010');
+ });
+
+ it('keeps a lookup in progress when only the gateway address changes', async () => {
+ let answer: (response: Response) => void = () => undefined;
+ const pending = new Promise((resolve) => {
+ answer = resolve;
+ });
+ const { requests } = scriptGateway(pending);
+ const { field, input, message } = mount(FIELD);
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await vi.waitFor(() => {
+ expect(requests).toHaveLength(1);
+ });
+ field.setAttribute('base-url', 'http://127.0.0.1:4010');
+ expect(requests[0]?.signal?.aborted).toBe(false);
+ expect(message()).toBe('Checking your postcode.');
+ answer(await fixture('lookup/valid-level-1'));
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ expect(requests).toHaveLength(1);
+ });
+
+ it('ignores the answer of a cancelled lookup, even one that ignores the signal', async () => {
+ scriptGateway(await fixture('lookup/not-found'));
+ let answer: (lookup: LookupResult) => void = () => undefined;
+ const late = new Promise((resolve) => {
+ answer = resolve;
+ });
+ vi.spyOn(PostcodeClient.prototype, 'lookup').mockImplementationOnce(async () => await late);
+ const { form, input, message } = mount(FIELD);
+ const confirms = listen(form, 'gatepost-confirm');
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await userEvent.type(input, '{Backspace}2');
+ await vi.waitFor(() => {
+ expect(message()).toContain('We could not find this postcode.');
+ });
+ const parsed = parse('FC-01-Z99-ZZ-01');
+ if (!parsed.ok) {
+ throw new Error('The test postcode does not parse.');
+ }
+ answer({
+ postcode: parsed.value,
+ valid: true,
+ status: null,
+ levelRequested: 1,
+ levelReceived: 1,
+ administrativeAddress: null,
+ recentHouseAddress: null,
+ buildingUseStatus: null,
+ });
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(confirms).toEqual([]);
+ expect(message()).toContain('We could not find this postcode.');
+ });
+});
+
+describe('a call that fails', () => {
+ const FAILED = 'We could not check this postcode just now. You can still continue.';
+
+ it('ends as a failed lookup when the base-url has no scheme', async () => {
+ const { requests } = scriptGateway();
+ const { form, field, input, message } = mount(
+ `` +
+ ' ',
+ );
+ const confirms = listen(form, 'gatepost-confirm');
+ const errors = listen(form, 'gatepost-error');
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await vi.waitFor(() => {
+ expect(message()).toBe(FAILED);
+ });
+ expect(errors).toEqual([{ code: 'network_error' }]);
+ expect(confirms).toEqual([]);
+ expect(requests).toEqual([]);
+ expect(field.checkValidity()).toBe(true);
+ });
+
+ it('ends as a failed lookup when the client throws a plain error, and tries again', async () => {
+ const lookup = vi.spyOn(PostcodeClient.prototype, 'lookup');
+ lookup.mockRejectedValueOnce(new Error('The client broke.'));
+ const { requests } = scriptGateway(await fixture('lookup/valid-level-1'));
+ const { field, form, input, message } = mount(FIELD);
+ const errors = listen(form, 'gatepost-error');
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await vi.waitFor(() => {
+ expect(message()).toBe(FAILED);
+ });
+ expect(errors).toEqual([{ code: 'network_error' }]);
+ expect(field.checkValidity()).toBe(true);
+ field.setAttribute('required', '');
+ await vi.waitFor(() => {
+ expect(message()).toBe('We found this postcode in Federal Capital Territory.');
+ });
+ expect(requests).toHaveLength(1);
+ });
+});
+
+describe('a secret key in the page', () => {
+ const SECRET = 'We cannot check postcodes on this page. You can still continue.';
+
+ it('sends no request, shows the state error, logs one error and raises secret_key', async () => {
+ const { requests } = scriptGateway();
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const { form, field, input, message, changes } = mount(
+ ' ',
+ );
+ const order: string[] = [];
+ form.addEventListener('gatepost-error', () => {
+ order.push(`error: ${message()}`);
+ });
+ field.addEventListener('gatepost-change', () => {
+ order.push('change');
+ });
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ expect(message()).toBe(SECRET);
+ expect(order.slice(-2)).toEqual(['change', `error: ${SECRET}`]);
+ expect(changes).toHaveLength(11);
+ expect(consoleError).toHaveBeenCalledOnce();
+ expect(String(consoleError.mock.calls[0]?.[0])).not.toContain('nipost_live_abc');
+ expect(requests).toEqual([]);
+ });
+
+ it('raises secret_key for each lookup, and logs again when base-url changes', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const { form, field, input } = mount(
+ ' ',
+ );
+ const errors = listen(form, 'gatepost-error');
+ await userEvent.type(input, 'FC01Z99ZZ01');
+ await userEvent.type(input, '{Backspace}2');
+ expect(errors).toEqual([{ code: 'secret_key' }, { code: 'secret_key' }]);
+ field.setAttribute('base-url', 'http://127.0.0.1:4010');
+ expect(consoleError).toHaveBeenCalledTimes(2);
+ expect(errors).toHaveLength(2);
+ field.setAttribute('confirm', 'none');
+ await userEvent.type(input, '{Backspace}1');
+ expect(errors).toHaveLength(2);
+ for (const call of consoleError.mock.calls) {
+ expect(String(call[0])).not.toContain('nipost_live_abc');
+ }
+ });
+
+ it.each([' ', '\n', '\r\n\t '])(
+ 'refuses a secret key that has white space before it (%j)',
+ async (space) => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const { requests } = scriptGateway();
+ const { field, form } = mount(
+ `` +
+ ' ',
+ );
+ const errors = listen(form, 'gatepost-error');
+ field.setAttribute('value', 'FC-01-Z99-ZZ-02');
+ await vi.waitFor(() => {
+ expect(errors).not.toEqual([]);
+ });
+ expect(new Set(errors.map((detail) => detail.code))).toEqual(new Set(['secret_key']));
+ expect(requests).toEqual([]);
+ expect(error).toHaveBeenCalled();
+ },
+ );
+
+ it('counts a key of white space only as no key', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const { requests } = scriptGateway();
+ const { location } = mount(
+ '' +
+ ' ',
+ );
+ expect(requests).toEqual([]);
+ expect(location.hidden).toBe(true);
+ expect(error).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/field/test/messages.test.ts b/packages/field/test/messages.test.ts
new file mode 100644
index 0000000..5a448d7
--- /dev/null
+++ b/packages/field/test/messages.test.ts
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { describe, expect, it } from 'vitest';
+import { fill, keyForReading } from '../src/messages.js';
+import { readText, type Reading } from '../src/reading.js';
+
+describe('keyForReading', () => {
+ it.each([
+ ['', 'accept', 'empty'],
+ ['FC-01-Z99-ZZ-01', 'accept', 'valid'],
+ ['900108', 'accept', 'legacy_accepted'],
+ ['900108', 'reject', 'legacy_rejected'],
+ ['FC-01-Z99-ZZ-0!', 'accept', 'bad_character'],
+ ['FC-01-Z99', 'accept', 'bad_length'],
+ ['XX-01-Z99-ZZ-01', 'accept', 'unknown_state'],
+ ['FC-00-Z99-ZZ-01', 'accept', 'bad_lga'],
+ ['FC-01-Z99-Z1-01', 'accept', 'bad_area'],
+ ['FC-01-Z99-ZZ-00', 'accept', 'bad_unit'],
+ ] as const)('gives %j with legacy %s the message %s', (text, legacy, key) => {
+ expect(keyForReading(readText(text), legacy)).toBe(key);
+ });
+
+ it('refuses a segment error of a segment that the grammar never fails', () => {
+ const reading: Reading = {
+ kind: 'error',
+ code: 'bad_segment',
+ segment: 'district',
+ suggestion: null,
+ count: 11,
+ };
+ expect(() => keyForReading(reading, 'accept')).toThrow(RangeError);
+ });
+});
+
+describe('fill', () => {
+ it('puts each value in place of its placeholder', () => {
+ expect(fill('{lga}, {state}. {state}!', { lga: 'Z99', state: 'Kwara' })).toBe(
+ 'Z99, Kwara. Kwara!',
+ );
+ });
+
+ it('writes a number in its plain form', () => {
+ expect(fill('You entered {count}.', { count: 7 })).toBe('You entered 7.');
+ });
+
+ it('leaves a placeholder with no value in view, so a wrong translation shows', () => {
+ expect(fill('Hello {name}.', {})).toBe('Hello {name}.');
+ });
+
+ it('does not read a placeholder as the name of an inherited property', () => {
+ expect(fill('a {toString} b {constructor}', {})).toBe('a {toString} b {constructor}');
+ });
+});
diff --git a/packages/field/test/reading.test.ts b/packages/field/test/reading.test.ts
new file mode 100644
index 0000000..f5aa73c
--- /dev/null
+++ b/packages/field/test/reading.test.ts
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { describe, expect, it } from 'vitest';
+import { commands } from 'vitest/browser';
+import { readText } from '../src/reading.js';
+
+describe('readText', () => {
+ it('reads a whole postcode in any spacing and letter case', () => {
+ const reading = readText(' fc 01 z99-zz 01 ');
+ expect(reading.kind === 'postcode' && reading.postcode.canonical).toBe('FC-01-Z99-ZZ-01');
+ });
+
+ it('reads text of separators only as empty', () => {
+ expect(readText(' - ')).toEqual({ kind: 'empty' });
+ });
+
+ it('reads an old 6-digit postcode as legacy, with its digits', () => {
+ expect(readText('900 108')).toEqual({ kind: 'legacy', digits: '900108' });
+ });
+
+ it('reads a partial postcode as too short, and counts its characters without separators', () => {
+ expect(readText('FC-01-Z99')).toEqual({
+ kind: 'error',
+ code: 'bad_length',
+ segment: null,
+ suggestion: null,
+ count: 7,
+ });
+ });
+
+ it('keeps the suggestion of the core for a letter O in a digit segment', () => {
+ const reading = readText('FCO1Z99ZZ01');
+ expect(reading.kind === 'error' && reading.code).toBe('bad_segment');
+ expect(reading.kind === 'error' && reading.segment).toBe('lga');
+ expect(reading.kind === 'error' && reading.suggestion?.display).toBe('FC 01 Z99 ZZ 01');
+ });
+
+ it('counts the normalised text up to the input limit, and the raw text above it', async () => {
+ const format = JSON.parse(await commands.readFile('../../spec/data/format.json')) as {
+ maxInputCodePoints: number;
+ };
+ const limit = format.maxInputCodePoints;
+ const atLimit = readText('F' + ' '.repeat(limit - 1));
+ const overLimit = readText('F' + ' '.repeat(limit));
+ expect(atLimit.kind === 'error' && atLimit.count).toBe(1);
+ expect(overLimit.kind === 'error' && overLimit.count).toBe(limit + 1);
+ });
+
+ it('counts code points, not UTF-16 units, at the limit and above it', () => {
+ const emoji = String.fromCodePoint(0x1f600);
+ const inside = readText(emoji.repeat(33));
+ const above = readText(emoji.repeat(65));
+ expect(inside.kind === 'error' && inside.code).toBe('bad_character');
+ expect(inside.kind === 'error' && inside.count).toBe(33);
+ expect(above.kind === 'error' && above.count).toBe(65);
+ });
+
+ it('reads full-width digits of an old postcode as ASCII digits', () => {
+ const fullWidth = Array.from('900108', (digit) =>
+ String.fromCodePoint(digit.charCodeAt(0) + 0xfee0),
+ ).join('');
+ expect(readText(fullWidth)).toEqual({ kind: 'legacy', digits: '900108' });
+ });
+});
diff --git a/packages/field/test/requests.test.ts b/packages/field/test/requests.test.ts
new file mode 100644
index 0000000..0cbf85e
--- /dev/null
+++ b/packages/field/test/requests.test.ts
@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { describe, expect, it } from 'vitest';
+import { Requests, type Asked } from '../src/requests.js';
+
+const ASKED: Asked = { code: 'FC-01-Z99-ZZ-01', level: 1, key: 'nipost_pk_test_mock' };
+
+describe('Requests', () => {
+ it('forgets the lookup in progress when a location request starts', () => {
+ const requests = new Requests();
+ requests.start({ state: 'checking', note: { key: 'checking' } });
+ requests.asked = ASKED;
+ requests.startLocation({ state: 'locating', note: { key: 'locating' } });
+ expect(requests.asked).toBeNull();
+ expect(requests.remembered).toBeNull();
+ });
+
+ it('cancels the request in progress when a change does not match, and says so', () => {
+ const requests = new Requests();
+ const work = requests.start({ state: 'checking', note: { key: 'checking' } });
+ requests.asked = ASKED;
+ expect(requests.cancelUnlessKept(null, true)).toBe(true);
+ expect(work.signal.aborted).toBe(true);
+ expect(requests.outcome).toBeNull();
+ });
+
+ it('keeps a location request while the text stays', () => {
+ const requests = new Requests();
+ const work = requests.startLocation({ state: 'locating', note: { key: 'locating' } });
+ expect(requests.cancelUnlessKept(ASKED, false)).toBe(false);
+ expect(work.signal.aborted).toBe(false);
+ });
+});
diff --git a/packages/field/test/setup.ts b/packages/field/test/setup.ts
new file mode 100644
index 0000000..5685fd5
--- /dev/null
+++ b/packages/field/test/setup.ts
@@ -0,0 +1,23 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { afterEach, beforeEach, vi } from 'vitest';
+
+const unscripted: string[] = [];
+
+// A test that expects a request scripts fetch itself. Any other request would reach the
+// gateway of NIPOST, so it fails the test instead, even when the client hides the error.
+beforeEach(() => {
+ unscripted.length = 0;
+ vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
+ const url = input instanceof Request ? input.url : String(input);
+ unscripted.push(url);
+ return Promise.reject(new Error(`A test sent an unscripted request to ${url}.`));
+ });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ if (unscripted.length > 0) {
+ throw new Error(`A test sent unscripted requests: ${unscripted.join(', ')}.`);
+ }
+});
diff --git a/packages/field/test/styles.test.ts b/packages/field/test/styles.test.ts
new file mode 100644
index 0000000..d5a38df
--- /dev/null
+++ b/packages/field/test/styles.test.ts
@@ -0,0 +1,129 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import axe from 'axe-core';
+import { describe, expect, it, vi } from 'vitest';
+import { commands, userEvent } from 'vitest/browser';
+import { mount } from './field.js';
+
+// The relative luminance and the contrast ratio of WCAG 2.2, for colours written #rrggbb.
+function luminance(hex: string): number {
+ const channels = [1, 3, 5].map((start) => Number.parseInt(hex.slice(start, start + 2), 16) / 255);
+ const [red, green, blue] = channels.map((channel) =>
+ channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
+ );
+ return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!;
+}
+
+function contrast(first: string, second: string): number {
+ const [light, dark] = [luminance(first), luminance(second)].sort((a, b) => b - a);
+ return (light! + 0.05) / (dark! + 0.05);
+}
+
+function token(element: Element, name: string): string {
+ return getComputedStyle(element).getPropertyValue(`--gatepost-${name}`).trim();
+}
+
+describe('the theme tokens', () => {
+ it.each(['text', 'muted', 'accent', 'error', 'warning'])(
+ 'give the %s colour a contrast of 4.5:1 or more on the background',
+ (name) => {
+ const { field } = mount(' ');
+ expect(contrast(token(field, name), token(field, 'background'))).toBeGreaterThanOrEqual(4.5);
+ },
+ );
+
+ it('give the border a contrast of 3:1 or more on the background', () => {
+ const { field } = mount(' ');
+ expect(contrast(token(field, 'border'), token(field, 'background'))).toBeGreaterThanOrEqual(3);
+ });
+
+ it('take the value that the page sets on the element', () => {
+ const { field } = mount(
+ ' ',
+ );
+ expect(token(field.shadowRoot!.querySelector('button')!, 'accent')).toBe('#123456');
+ });
+});
+
+// The rules of the README's dark example, as the text of a style attribute. Reading the README
+// keeps the example and the test together.
+async function readmeDarkTokens(): Promise {
+ const readme = await commands.readFile('README.md');
+ const block = /```css\n([\s\S]*?)```/.exec(readme)?.[1];
+ const body = block === undefined ? undefined : /\{([\s\S]*)\}/.exec(block)?.[1];
+ if (body === undefined) {
+ throw new Error('The README holds no css example.');
+ }
+ return body.replaceAll(/\s+/g, ' ').trim();
+}
+
+// The ids of the axe rules that the field breaks, in each state with a colour of its own: quiet,
+// valid (accent), a legacy postcode (warning), and a mistake with a suggestion (error).
+async function violationsOnDarkPage(style: string): Promise {
+ document.body.style.background = '#111111';
+ try {
+ const { input, suggestion } = mount(
+ ` `,
+ );
+ const ids = new Set();
+ const collect = async (): Promise => {
+ const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
+ const results = await axe.run(document.body, { runOnly: { type: 'tag', values: tags } });
+ results.violations.forEach((violation) => ids.add(violation.id));
+ };
+ await collect();
+ for (const text of ['FC01Z99ZZ01', '123456', 'FCO1Z99ZZ01']) {
+ await userEvent.clear(input);
+ await userEvent.type(input, text);
+ await userEvent.tab();
+ await collect();
+ }
+ expect(suggestion.hidden).toBe(false);
+ return [...ids];
+ } finally {
+ document.body.style.background = '';
+ }
+}
+
+describe('a dark page', () => {
+ it('passes axe with the tokens of the README, in each coloured state', async () => {
+ expect(await violationsOnDarkPage(await readmeDarkTokens())).toEqual([]);
+ });
+
+ it('fails the colour contrast with no tokens, so the test above can fail', async () => {
+ expect(await violationsOnDarkPage('')).toContain('color-contrast');
+ });
+});
+
+describe('the shadow tree', () => {
+ it('is built with no HTML string, so a Trusted Types policy accepts it', () => {
+ const setter = vi.fn();
+ const property = Object.getOwnPropertyDescriptor(ShadowRoot.prototype, 'innerHTML')!;
+ Object.defineProperty(ShadowRoot.prototype, 'innerHTML', { set: setter, configurable: true });
+ try {
+ const { input } = mount(' ');
+ expect(input).toBeInstanceOf(HTMLInputElement);
+ expect(setter).not.toHaveBeenCalled();
+ } finally {
+ Object.defineProperty(ShadowRoot.prototype, 'innerHTML', property);
+ }
+ });
+});
+
+describe('the input', () => {
+ it('keeps a target of at least 24 by 24 pixels, and a text size of 16 pixels or more', () => {
+ const { input } = mount(' ');
+ const box = input.getBoundingClientRect();
+ expect(box.width).toBeGreaterThanOrEqual(24);
+ expect(box.height).toBeGreaterThanOrEqual(24);
+ expect(Number.parseFloat(getComputedStyle(input).fontSize)).toBeGreaterThanOrEqual(16);
+ });
+
+ it('keeps the same target size for the suggestion button', async () => {
+ const { input, suggestion } = mount(' ');
+ await userEvent.type(input, 'FCO1Z99ZZ01');
+ const box = suggestion.getBoundingClientRect();
+ expect(box.width).toBeGreaterThanOrEqual(24);
+ expect(box.height).toBeGreaterThanOrEqual(24);
+ });
+});
diff --git a/packages/field/test/tsdoc.test.ts b/packages/field/test/tsdoc.test.ts
new file mode 100644
index 0000000..3f315ac
--- /dev/null
+++ b/packages/field/test/tsdoc.test.ts
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { describe, expect, it } from 'vitest';
+import { commands } from 'vitest/browser';
+import { PostcodeFieldElement, SPEC_VERSION } from '../src/index.js';
+
+const BLOCK = /```(\w+)\n([\s\S]*?)```/g;
+
+// The code blocks of the @example tags of one source file, without the leading " * ".
+async function examples(file: string): Promise<{ language: string; code: string }[]> {
+ const source = await commands.readFile(`src/${file}`);
+ const comments = source.match(/\/\*\*[\s\S]*?\*\//g) ?? [];
+ const text = comments.map((comment) => comment.replace(/^\s*\* ?/gm, '')).join('\n');
+ return [...text.matchAll(BLOCK)].map(([, language, code]) => ({
+ language: language!,
+ code: code!,
+ }));
+}
+
+describe('TSDoc examples', () => {
+ it('run the HTML example of the element as a page would', async () => {
+ const [example] = await examples('element.ts');
+ expect(example?.language).toBe('html');
+ document.body.innerHTML = example!.code;
+ const field = document.querySelector('gatepost-postcode-field');
+ expect(field).toBeInstanceOf(PostcodeFieldElement);
+ expect(new FormData(document.querySelector('form')!).has('postcode')).toBe(true);
+ });
+
+ it('state the spec version that SPEC_VERSION holds', async () => {
+ const [example] = await examples('version.ts');
+ expect(example?.code).toContain(`SPEC_VERSION; // '${SPEC_VERSION}'`);
+ });
+});
diff --git a/packages/field/test/version.test.ts b/packages/field/test/version.test.ts
new file mode 100644
index 0000000..7dededb
--- /dev/null
+++ b/packages/field/test/version.test.ts
@@ -0,0 +1,12 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { describe, expect, it } from 'vitest';
+import { commands } from 'vitest/browser';
+import { SPEC_VERSION } from '../src/index.js';
+
+describe('SPEC_VERSION', () => {
+ it('matches the version of the spec submodule', async () => {
+ const specVersion = await commands.readFile('../../spec/VERSION');
+ expect(SPEC_VERSION).toBe(specVersion.trim());
+ });
+});
diff --git a/packages/field/tsconfig.json b/packages/field/tsconfig.json
new file mode 100644
index 0000000..581e1a4
--- /dev/null
+++ b/packages/field/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["node"],
+ "allowImportingTsExtensions": true,
+ "paths": {
+ "@gatepost/core": ["../core/src/index.ts"],
+ "@gatepost/client": ["../client/src/index.ts"]
+ }
+ },
+ "include": ["src", "test"]
+}
diff --git a/packages/field/tsdown.config.ts b/packages/field/tsdown.config.ts
new file mode 100644
index 0000000..0508406
--- /dev/null
+++ b/packages/field/tsdown.config.ts
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { readFileSync } from 'node:fs';
+import { defineConfig } from 'tsdown';
+
+// The bundler drops the package comment of src/index.ts from the declarations, and API Extractor
+// reads that comment from the top of dist/index.d.ts. So the build puts the comment back.
+function packageComment(): string {
+ const source = readFileSync(new URL('./src/index.ts', import.meta.url), 'utf8');
+ const comments = source.match(/\/\*\*[\s\S]*?\*\//g) ?? [];
+ const comment = comments.find((text) => text.includes('@packageDocumentation'));
+ if (comment === undefined) {
+ throw new Error('src/index.ts has no doc comment with the tag @packageDocumentation.');
+ }
+ return comment;
+}
+
+export default defineConfig([
+ {
+ entry: ['src/index.ts'],
+ format: 'esm',
+ platform: 'browser',
+ target: 'es2022',
+ dts: true,
+ banner: { dts: packageComment() },
+ clean: true,
+ },
+ {
+ // One file with the core and the client inside, for a page that loads the field with a
+ // plain script tag, and for the WordPress plugin, which cannot install npm packages.
+ entry: { element: 'src/index.ts' },
+ format: 'esm',
+ platform: 'browser',
+ target: 'es2022',
+ minify: true,
+ dts: false,
+ deps: { alwaysBundle: [/^@gatepost\//] },
+ },
+]);
diff --git a/packages/field/vitest.config.ts b/packages/field/vitest.config.ts
new file mode 100644
index 0000000..c522341
--- /dev/null
+++ b/packages/field/vitest.config.ts
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { fileURLToPath } from 'node:url';
+import { playwright } from '@vitest/browser-playwright';
+import { defineConfig } from 'vitest/config';
+
+const source = (path: string): string => fileURLToPath(new URL(path, import.meta.url));
+
+// The field is a custom element, so every test runs in Chromium, and the V8 coverage of those
+// tests counts for the floor of T-7. The tests read the source of the core and the client, so
+// they need no build of either.
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@gatepost/core': source('../core/src/index.ts'),
+ '@gatepost/client': source('../client/src/index.ts'),
+ },
+ },
+ test: {
+ include: ['test/**/*.test.ts'],
+ // Playwright runs the journeys against the built element, in three browser engines.
+ exclude: ['test/journeys/**'],
+ setupFiles: ['test/setup.ts'],
+ browser: {
+ enabled: true,
+ headless: true,
+ provider: playwright(),
+ instances: [{ browser: 'chromium' }],
+ },
+ coverage: {
+ provider: 'v8',
+ include: ['src/**/*.ts'],
+ exclude: ['src/spec-messages.ts'],
+ thresholds: { branches: 80, functions: 80, lines: 80, statements: 80 },
+ },
+ },
+});
diff --git a/packages/mock-server/README.md b/packages/mock-server/README.md
index 565bdb7..6259f00 100644
--- a/packages/mock-server/README.md
+++ b/packages/mock-server/README.md
@@ -81,7 +81,7 @@ await mock.close();
| Requirement | Version |
| ------------- | ------------------------------------------ |
| Node | 22.22.2 or later 22.x, or 24.15.0 or later |
-| Gatepost spec | 0.2.0 |
+| Gatepost spec | 0.3.0 |
## Docs
diff --git a/packages/react/LICENSE b/packages/react/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/packages/react/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/packages/react/NOTICE b/packages/react/NOTICE
new file mode 100644
index 0000000..bc94510
--- /dev/null
+++ b/packages/react/NOTICE
@@ -0,0 +1,5 @@
+Gatepost
+Copyright 2026 The Gatepost authors
+
+This product is unofficial. The Nigerian Postal Service (NIPOST) did not make it and does not endorse it.
+Gatepost uses the name NIPOST only to describe compatibility with NIPOST's National Digital Postcode.
diff --git a/packages/react/README.md b/packages/react/README.md
new file mode 100644
index 0000000..906c4b4
--- /dev/null
+++ b/packages/react/README.md
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+@gatepost/react
+
+The Gatepost postcode field as a React component. It renders on the server, and a plain form sends the postcode in its canonical form.
+
+
+
+
+
+
+
+
+ Docs
+ ·
+ Playground
+ ·
+ Field spec
+ ·
+ Contributing
+ ·
+ Discussions
+
+
+> Unofficial. Not made or endorsed by NIPOST.
+
+## Install
+
+```sh
+pnpm add @gatepost/react
+```
+
+The first alpha is not on npm yet, so this command does not work today.
+
+The package installs `@gatepost/field`, the custom element that does the work.
+
+## Quickstart
+
+```tsx
+'use client';
+import { useState, type ReactElement } from 'react';
+import { PostcodeField } from '@gatepost/react';
+
+export function AddressForm(): ReactElement {
+ const [postcode, setPostcode] = useState('');
+ return (
+
+ );
+}
+```
+
+A user who types `fc 01 z99 zz 01` sees `FC-01-Z99-ZZ-01`, and the form sends `postcode=FC-01-Z99-ZZ-01`.
+
+## Features
+
+- Renders `` with one attribute for each prop, so the HTML of a server render already holds the field's settings.
+- Calls `onChange`, `onConfirm` and `onError` with the detail of each event of the field.
+- Gives the element through `ref`, for `checkValidity()` and the other members of a form control.
+- Works with React 18 and React 19, and in the App Router of Next.js, because the module starts with `'use client'`.
+
+The field keeps its own text, as an uncontrolled input does. `defaultValue` sets the text at first, and a form reset returns to it. A new `defaultValue` later changes that first text, and never the text that the user typed. Read each new value in `onChange`, or read the form when it submits. On React 19, a field that the browser rendered, not hydrated, keeps its first `defaultValue` after a later change of the prop. Set a `key` on the component if you need a new first text.
+
+| Prop | Meaning |
+| ---------------------------------------------- | --------------------------------------------------------------------- |
+| `name`, `defaultValue`, `required`, `disabled` | as for a native input |
+| `label` | the visible label. The default is `Postcode` |
+| `apiKey` | a publishable key. Without one, the field sends no request |
+| `baseUrl` | the gateway's address |
+| `confirm` | `none`, `level1` or `level2`. The default is `level1` |
+| `gps` | true shows the location button, when the field has a key |
+| `legacy` | `accept` or `reject` an old 6-digit postcode. The default is `accept` |
+| `messages` | text that replaces the English messages, by key |
+
+The [README of `@gatepost/field`](https://github.com/gatepost-dev/js/tree/main/packages/field#readme) explains the key, the events, the messages and the theme tokens.
+
+## Requirements
+
+| Requirement | Version |
+| ------------- | ------------------------ |
+| React | 18 or 19 |
+| Browser | as for `@gatepost/field` |
+| Module format | ESM only |
+| Gatepost spec | 0.3.0 |
+
+The package exports `SPEC_VERSION`, the version of the Gatepost spec that it implements.
+
+## Docs
+
+The [docs site](https://gatepost-dev.github.io/docs/) and the [playground](https://gatepost-dev.github.io/docs/playground/) lead to the rest of Gatepost. The API report is in [`etc/react.api.md`](https://github.com/gatepost-dev/js/blob/main/packages/react/etc/react.api.md). [`field.md`](https://github.com/gatepost-dev/spec/blob/main/field.md) in the spec repo defines the field.
+
+## Support
+
+Ask questions in [GitHub Discussions](https://github.com/gatepost-dev/js/discussions). Report bugs in [GitHub Issues](https://github.com/gatepost-dev/js/issues). Report security problems through the [private reporting form](https://github.com/gatepost-dev/js/security/advisories/new).
+
+## Develop
+
+Read [`docs/develop.md`](https://github.com/gatepost-dev/js/blob/main/docs/develop.md) and the [contributing guide](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request.
+
+## Licence
+
+Apache-2.0. See [`LICENSE`](https://github.com/gatepost-dev/js/blob/main/LICENSE) and [`NOTICE`](https://github.com/gatepost-dev/js/blob/main/NOTICE).
diff --git a/packages/react/api-extractor.json b/packages/react/api-extractor.json
new file mode 100644
index 0000000..115cb66
--- /dev/null
+++ b/packages/react/api-extractor.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
+ "mainEntryPointFilePath": "/dist/index.d.ts",
+ "compiler": {
+ "overrideTsconfig": {
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": [] },
+ "files": ["dist/index.d.ts"]
+ }
+ },
+ "apiReport": {
+ "enabled": true,
+ "reportFolder": "/etc/",
+ "reportTempFolder": "/temp/"
+ },
+ "docModel": { "enabled": false },
+ "dtsRollup": { "enabled": false },
+ "tsdocMetadata": { "enabled": false },
+ "messages": {
+ "extractorMessageReporting": {
+ "ae-undocumented": { "logLevel": "error", "addToApiReportFile": false },
+ "ae-missing-release-tag": { "logLevel": "none" }
+ }
+ }
+}
diff --git a/packages/react/etc/react.api.md b/packages/react/etc/react.api.md
new file mode 100644
index 0000000..d9ad83e
--- /dev/null
+++ b/packages/react/etc/react.api.md
@@ -0,0 +1,39 @@
+## API Report File for "@gatepost/react"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { ChangeDetail } from '@gatepost/field';
+import { ConfirmDetail } from '@gatepost/field';
+import { ErrorDetail } from '@gatepost/field';
+import { ForwardRefExoticComponent } from 'react';
+import { Messages } from '@gatepost/field';
+import { PostcodeFieldElement } from '@gatepost/field';
+import { RefAttributes } from 'react';
+
+// @public
+export const PostcodeField: ForwardRefExoticComponent>;
+
+// @public
+export interface PostcodeFieldProps {
+ readonly apiKey?: string;
+ readonly baseUrl?: string;
+ readonly confirm?: "none" | "level1" | "level2";
+ readonly defaultValue?: string;
+ readonly disabled?: boolean;
+ readonly gps?: boolean;
+ readonly label?: string;
+ readonly legacy?: "accept" | "reject";
+ readonly messages?: Partial;
+ readonly name?: string;
+ readonly onChange?: (detail: ChangeDetail) => void;
+ readonly onConfirm?: (detail: ConfirmDetail) => void;
+ readonly onError?: (detail: ErrorDetail) => void;
+ readonly required?: boolean;
+}
+
+// @public
+export const SPEC_VERSION = "0.3.0";
+
+```
diff --git a/packages/react/package.json b/packages/react/package.json
new file mode 100644
index 0000000..fd077ea
--- /dev/null
+++ b/packages/react/package.json
@@ -0,0 +1,73 @@
+{
+ "name": "@gatepost/react",
+ "version": "0.0.0",
+ "description": "Unofficial. Not made or endorsed by NIPOST. A postcode field for React.",
+ "license": "Apache-2.0",
+ "homepage": "https://github.com/gatepost-dev/js/tree/main/packages/react#readme",
+ "bugs": "https://github.com/gatepost-dev/js/issues",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/gatepost-dev/js.git",
+ "directory": "packages/react"
+ },
+ "type": "module",
+ "sideEffects": [
+ "./dist/index.js"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "LICENSE",
+ "NOTICE"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "keywords": [
+ "nigeria",
+ "postcode",
+ "address",
+ "nipost",
+ "react",
+ "form"
+ ],
+ "size-limit": [
+ {
+ "path": "dist/index.js",
+ "limit": "1 KB",
+ "ignore": [
+ "react",
+ "@gatepost/field"
+ ]
+ }
+ ],
+ "dependencies": {
+ "@gatepost/field": "workspace:^"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "react": "^19.3.0",
+ "react-18": "npm:react@^18.3.1",
+ "react-dom": "^19.3.0",
+ "react-dom-18": "npm:react-dom@^18.3.1"
+ },
+ "scripts": {
+ "typecheck": "tsc -p tsconfig.json",
+ "test": "vitest run --coverage",
+ "build": "tsdown",
+ "package:check": "run-s package:lint package:smoke",
+ "package:lint": "publint && attw --pack --profile esm-only && size-limit && api-extractor run",
+ "package:smoke": "node scripts/smoke.mjs",
+ "api:update": "api-extractor run --local",
+ "licence-files:check": "cmp ../../LICENSE LICENSE && cmp ../../NOTICE NOTICE"
+ }
+}
diff --git a/packages/react/scripts/smoke.mjs b/packages/react/scripts/smoke.mjs
new file mode 100644
index 0000000..a20a5c5
--- /dev/null
+++ b/packages/react/scripts/smoke.mjs
@@ -0,0 +1,18 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+// Starts the built package as a user does, on a server with no DOM, and renders the component
+// to HTML, as a server render of a Next.js page does.
+import { readFileSync } from 'node:fs';
+import { URL } from 'node:url';
+import { PostcodeField } from '@gatepost/react';
+import { createElement } from 'react';
+import { renderToString } from 'react-dom/server';
+
+const first = readFileSync(new URL('../dist/index.js', import.meta.url), 'utf8').split('\n')[0];
+if (first !== "'use client';") {
+ throw new Error(`dist/index.js starts with ${first}, not the directive 'use client'.`);
+}
+const html = renderToString(createElement(PostcodeField, { name: 'postcode', required: true }));
+if (html !== ' ') {
+ throw new Error(`The server rendered ${html}.`);
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
new file mode 100644
index 0000000..36edb27
--- /dev/null
+++ b/packages/react/src/index.ts
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * The postcode field of `@gatepost/field` as a React component, for React 18 and 19. It renders
+ * on the server, and a plain form submits the canonical form of the postcode.
+ *
+ * Unofficial. Not made or endorsed by NIPOST.
+ *
+ * @packageDocumentation
+ */
+export { PostcodeField, type PostcodeFieldProps } from './postcode-field.js';
+export { SPEC_VERSION } from './version.js';
diff --git a/packages/react/src/postcode-field.ts b/packages/react/src/postcode-field.ts
new file mode 100644
index 0000000..d466ea0
--- /dev/null
+++ b/packages/react/src/postcode-field.ts
@@ -0,0 +1,215 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import '@gatepost/field';
+import type {
+ ChangeDetail,
+ ConfirmDetail,
+ ErrorDetail,
+ Messages,
+ PostcodeFieldElement,
+} from '@gatepost/field';
+import {
+ createElement,
+ forwardRef,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ForwardedRef,
+ type ForwardRefExoticComponent,
+ type ReactElement,
+ type RefAttributes,
+} from 'react';
+
+/**
+ * The props of `PostcodeField`. Each one sets the attribute of the same name on the element,
+ * and `spec/field.md` defines each one.
+ */
+export interface PostcodeFieldProps {
+ /** The name of the form value. */
+ readonly name?: string;
+ /** The text in the input at first. The user changes it from then on. */
+ readonly defaultValue?: string;
+ /** True when the form needs a postcode. */
+ readonly required?: boolean;
+ /** True turns the field off, and the form leaves it out. */
+ readonly disabled?: boolean;
+ /** The visible label. The default is `Postcode`. */
+ readonly label?: string;
+ /** A publishable key. Without a key, the field sends no request. */
+ readonly apiKey?: string;
+ /** The gateway's address. The default is NIPOST's gateway. */
+ readonly baseUrl?: string;
+ /** The lookup level of the check: `none`, `level1` or `level2`. The default is `level1`. */
+ readonly confirm?: 'none' | 'level1' | 'level2';
+ /** True shows the location button, when the field has a key. */
+ readonly gps?: boolean;
+ /** Whether an old 6-digit postcode is a form value. The default is `accept`. */
+ readonly legacy?: 'accept' | 'reject';
+ /** Messages that replace the English ones, by key. */
+ readonly messages?: Partial;
+ /** Called when the form value changes through the user. */
+ readonly onChange?: (detail: ChangeDetail) => void;
+ /** Called when the gateway knows the postcode. */
+ readonly onConfirm?: (detail: ConfirmDetail) => void;
+ /** Called when a request or the location fails, or for a secret key. */
+ readonly onError?: (detail: ErrorDetail) => void;
+}
+
+// React 18 writes `false` to a custom element as the text "false", which still counts as a
+// present attribute. So a boolean becomes an empty attribute or no attribute at all.
+function flag(on: boolean | undefined): '' | undefined {
+ return on === true ? '' : undefined;
+}
+
+// A layout effect runs before the browser paints, so the first paint shows the right text. A
+// server runs no effect, and React 18 warns about a layout effect there.
+const useBeforePaint = typeof document === 'undefined' ? useEffect : useLayoutEffect;
+
+// Gives the element to a ref of the caller. A callback ref of React 19 may return a cleanup, which
+// React then runs in place of a call with null.
+function forward(
+ ref: ForwardedRef,
+ element: PostcodeFieldElement,
+): (() => void) | undefined {
+ if (typeof ref === 'function') {
+ const cleanup = (ref as (attached: PostcodeFieldElement) => unknown)(element);
+ return typeof cleanup === 'function' ? (cleanup as () => void) : undefined;
+ }
+ if (ref !== null) {
+ ref.current = element;
+ }
+ return undefined;
+}
+
+function sameMessages(
+ left: Partial | undefined,
+ right: Partial | undefined,
+): boolean {
+ if (left === right) {
+ return true;
+ }
+ if (left === undefined || right === undefined) {
+ return false;
+ }
+ const keys = Object.keys(left) as (keyof Messages)[];
+ return keys.length === Object.keys(right).length && keys.every((key) => left[key] === right[key]);
+}
+
+interface Details {
+ 'gatepost-change': ChangeDetail;
+ 'gatepost-confirm': ConfirmDetail;
+ 'gatepost-error': ErrorDetail;
+}
+
+function useEvent(
+ element: PostcodeFieldElement | null,
+ type: Type,
+ handler: ((detail: Details[Type]) => void) | undefined,
+): void {
+ useEffect(() => {
+ if (element === null || handler === undefined) {
+ return undefined;
+ }
+ const listener = (event: Event): void => {
+ handler((event as CustomEvent).detail);
+ };
+ element.addEventListener(type, listener);
+ return () => {
+ element.removeEventListener(type, listener);
+ };
+ }, [element, type, handler]);
+}
+
+function render(props: PostcodeFieldProps, ref: ForwardedRef): ReactElement {
+ const [element, setElement] = useState(null);
+ const attach = useCallback(
+ (attached: PostcodeFieldElement | null): (() => void) | undefined => {
+ setElement(attached);
+ if (attached !== null) {
+ const cleanup = forward(ref, attached);
+ return cleanup === undefined
+ ? undefined
+ : () => {
+ cleanup();
+ setElement(null);
+ };
+ }
+ if (typeof ref === 'function') {
+ ref(null);
+ } else if (ref !== null) {
+ ref.current = null;
+ }
+ return undefined;
+ },
+ [ref],
+ );
+ // The element keeps the messages that it holds until a new object replaces them, so the effect
+ // sends an object only when its keys or texts changed, and an empty object when it went away.
+ const { messages, defaultValue } = props;
+ const [first] = useState(defaultValue);
+ const applied = useRef<{
+ element: PostcodeFieldElement | null;
+ messages: Partial | undefined;
+ }>({ element: null, messages: undefined });
+ useBeforePaint(() => {
+ if (element === null) {
+ return;
+ }
+ if (applied.current.element !== element) {
+ applied.current = { element, messages: undefined };
+ }
+ if (!sameMessages(applied.current.messages, messages)) {
+ applied.current.messages = messages;
+ element.messages = messages ?? {};
+ }
+ }, [element, messages]);
+ // `defaultValue` is the attribute `value`: the first text, which a form reset restores. The
+ // server writes it as an attribute, and so does React 18. React 19 sets a prop of a custom
+ // element as a property when the element has one of that name, and the property `value` is
+ // the current text. So the prop keeps the first value for ever, and this effect writes each
+ // value as the attribute. A later value then changes the first text, never the typed text.
+ useBeforePaint(() => {
+ if (element === null) {
+ return;
+ }
+ if (defaultValue === undefined) {
+ element.removeAttribute('value');
+ } else if (element.getAttribute('value') !== defaultValue) {
+ element.setAttribute('value', defaultValue);
+ }
+ }, [element, defaultValue]);
+ useEvent(element, 'gatepost-change', props.onChange);
+ useEvent(element, 'gatepost-confirm', props.onConfirm);
+ useEvent(element, 'gatepost-error', props.onError);
+ return createElement('gatepost-postcode-field', {
+ ref: attach,
+ name: props.name,
+ value: first,
+ label: props.label,
+ 'api-key': props.apiKey,
+ 'base-url': props.baseUrl,
+ confirm: props.confirm,
+ legacy: props.legacy,
+ required: flag(props.required),
+ disabled: flag(props.disabled),
+ gps: flag(props.gps),
+ });
+}
+
+/**
+ * The postcode field of `@gatepost/field` as a React component. The server renders the element
+ * with its attributes, and the browser upgrades it. A plain form submits its form value, so the
+ * component needs no state of its own. The ref gives the element.
+ *
+ * @example
+ * ```tsx
+ * import { PostcodeField } from '@gatepost/react';
+ *
+ * detail.value} />;
+ * ```
+ */
+export const PostcodeField: ForwardRefExoticComponent<
+ PostcodeFieldProps & RefAttributes
+> = forwardRef(render);
diff --git a/packages/react/src/version.ts b/packages/react/src/version.ts
new file mode 100644
index 0000000..cd83187
--- /dev/null
+++ b/packages/react/src/version.ts
@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * The version of the Gatepost spec that this package implements.
+ *
+ * @example
+ * ```ts
+ * import { SPEC_VERSION } from '@gatepost/react';
+ *
+ * SPEC_VERSION; // '0.3.0'
+ * ```
+ */
+export const SPEC_VERSION = '0.3.0';
diff --git a/packages/react/test/browser.test.ts b/packages/react/test/browser.test.ts
new file mode 100644
index 0000000..6ea3b80
--- /dev/null
+++ b/packages/react/test/browser.test.ts
@@ -0,0 +1,326 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import type { ChangeDetail, ErrorDetail, PostcodeFieldElement } from '@gatepost/field';
+import { StrictMode, act, createElement, createRef, version } from 'react';
+import { version as domVersion } from 'react-dom';
+import { createRoot, hydrateRoot, type Root } from 'react-dom/client';
+import { renderToString } from 'react-dom/server';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import { PostcodeField, type PostcodeFieldProps } from '../src/index.js';
+
+// act() warns unless the test says that it runs in a test environment.
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const roots: Root[] = [];
+
+afterEach(() => {
+ act(() => {
+ for (const root of roots.splice(0)) {
+ root.unmount();
+ }
+ });
+ document.body.replaceChildren();
+ vi.restoreAllMocks();
+});
+
+function container(): HTMLFormElement {
+ const form = document.createElement('form');
+ document.body.append(form);
+ return form;
+}
+
+function show(props: PostcodeFieldProps, ref = createRef()): HTMLFormElement {
+ const form = container();
+ const root = createRoot(form);
+ roots.push(root);
+ act(() => {
+ root.render(createElement(PostcodeField, { ...props, ref }));
+ });
+ return form;
+}
+
+function input(form: HTMLFormElement): HTMLInputElement {
+ return form.querySelector('gatepost-postcode-field')!.shadowRoot!.querySelector('input')!;
+}
+
+describe(`PostcodeField on React ${version}`, () => {
+ it('runs on the React version of its test project', () => {
+ expect(version).toMatch(/^1[89]\./);
+ expect(domVersion.split('.')[0]).toBe(version.split('.')[0]);
+ });
+
+ it('submits the canonical form with a plain form', async () => {
+ const form = show({ name: 'postcode', required: true });
+ await userEvent.type(input(form), 'fc 01 z99 zz 01');
+ expect(new FormData(form).get('postcode')).toBe('FC-01-Z99-ZZ-01');
+ });
+
+ it('calls onChange with the detail of each change', async () => {
+ const onChange = vi.fn<(detail: ChangeDetail) => void>();
+ const form = show({ onChange });
+ await userEvent.type(input(form), 'FC01Z99ZZ01');
+ expect(onChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({ value: 'FC-01-Z99-ZZ-01', source: 'typed' }),
+ );
+ });
+
+ it('calls a new onChange once for each change after a render with new props', async () => {
+ const form = container();
+ const root = createRoot(form);
+ roots.push(root);
+ const first = vi.fn<(detail: ChangeDetail) => void>();
+ const second = vi.fn<(detail: ChangeDetail) => void>();
+ act(() => {
+ root.render(createElement(PostcodeField, { onChange: first, messages: { label: 'One' } }));
+ });
+ act(() => {
+ root.render(createElement(PostcodeField, { onChange: second, messages: { label: 'Two' } }));
+ });
+ await userEvent.type(input(form), 'F');
+ expect(first).not.toHaveBeenCalled();
+ expect(second).toHaveBeenCalledOnce();
+ });
+
+ it('calls onError for a secret key, and the field sends no request', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const fetch = vi.spyOn(globalThis, 'fetch');
+ const onError = vi.fn<(detail: ErrorDetail) => void>();
+ const form = show({ apiKey: 'nipost_live_abc', onError });
+ await userEvent.type(input(form), 'FC01Z99ZZ01');
+ expect(onError).toHaveBeenCalledWith({ code: 'secret_key' });
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('gives the element through the ref, and passes the messages to it', () => {
+ const ref = createRef();
+ const form = show({ messages: { label: 'Delivery postcode' } }, ref);
+ expect(ref.current).toBe(form.querySelector('gatepost-postcode-field'));
+ expect(ref.current?.shadowRoot?.querySelector('label')?.textContent).toBe('Delivery postcode');
+ });
+
+ it('takes messages that were set before the element was defined', () => {
+ // A document with no registry keeps the element plain, so the effect of the component sets
+ // `messages` as an own property, as it does when the script that defines the field loads late.
+ const plain = document.implementation.createHTMLDocument('');
+ const container = plain.createElement('form');
+ const root = createRoot(container);
+ roots.push(root);
+ act(() => {
+ root.render(createElement(PostcodeField, { messages: { label: 'Early' } }));
+ });
+ const field = container.querySelector('gatepost-postcode-field')!;
+ expect(Object.hasOwn(field, 'messages')).toBe(true);
+ expect(field.shadowRoot).toBeNull();
+ document.body.append(container);
+ expect(Object.hasOwn(field, 'messages')).toBe(false);
+ expect(field.shadowRoot?.querySelector('label')?.textContent).toBe('Early');
+ act(() => {
+ root.render(createElement(PostcodeField, { messages: { label: 'Later' } }));
+ });
+ expect(field.shadowRoot?.querySelector('label')?.textContent).toBe('Later');
+ });
+
+ it('turns the field off, and back on, with the disabled prop', () => {
+ const form = container();
+ const root = createRoot(form);
+ roots.push(root);
+ act(() => {
+ root.render(createElement(PostcodeField, { name: 'postcode', disabled: true }));
+ });
+ expect(input(form).disabled).toBe(true);
+ act(() => {
+ root.render(createElement(PostcodeField, { name: 'postcode', disabled: false }));
+ });
+ expect(input(form).disabled).toBe(false);
+ });
+
+ it('gives defaultValue as the first text, and a reset returns to it', async () => {
+ const form = show({ name: 'postcode', defaultValue: 'fc01z99zz01' });
+ const field = form.querySelector('gatepost-postcode-field')!;
+ expect(field.getAttribute('value')).toBe('fc01z99zz01');
+ expect(input(form).value).toBe('fc01z99zz01');
+ await userEvent.clear(input(form));
+ await userEvent.type(input(form), 'LA');
+ expect(input(form).value).toBe('LA');
+ act(() => {
+ form.reset();
+ });
+ expect(input(form).value).toBe('fc01z99zz01');
+ });
+
+ it('keeps the typed text when defaultValue changes, and a reset uses the new one', async () => {
+ const form = container();
+ const root = createRoot(form);
+ roots.push(root);
+ act(() => {
+ root.render(createElement(PostcodeField, { defaultValue: 'fc01z99zz01' }));
+ });
+ await userEvent.clear(input(form));
+ await userEvent.type(input(form), 'LA');
+ act(() => {
+ root.render(createElement(PostcodeField, { defaultValue: 'fc01z99zz02' }));
+ });
+ expect(input(form).value).toBe('LA');
+ act(() => {
+ form.reset();
+ });
+ expect(input(form).value).toBe('fc01z99zz02');
+ });
+
+ it('goes back to the English messages when the messages prop goes away', () => {
+ const form = container();
+ const root = createRoot(form);
+ roots.push(root);
+ act(() => {
+ root.render(createElement(PostcodeField, { messages: { label: 'One' } }));
+ });
+ const label = (): string | null | undefined =>
+ form.querySelector('gatepost-postcode-field')?.shadowRoot?.querySelector('label')
+ ?.textContent;
+ expect(label()).toBe('One');
+ act(() => {
+ root.render(createElement(PostcodeField, {}));
+ });
+ expect(label()).toBe('Postcode');
+ });
+
+ it('sets the messages of an inline object once, not after every render', () => {
+ const form = container();
+ const root = createRoot(form);
+ roots.push(root);
+ act(() => {
+ root.render(createElement(PostcodeField, { messages: { label: 'One' } }));
+ });
+ const field = form.querySelector('gatepost-postcode-field')!;
+ const set = vi.spyOn(field, 'messages', 'set');
+ act(() => {
+ root.render(createElement(PostcodeField, { messages: { label: 'One' } }));
+ });
+ expect(set).not.toHaveBeenCalled();
+ });
+
+ it('calls one handler once for one change inside StrictMode', async () => {
+ const form = container();
+ const root = createRoot(form);
+ roots.push(root);
+ const onChange = vi.fn<(detail: ChangeDetail) => void>();
+ act(() => {
+ root.render(createElement(StrictMode, null, createElement(PostcodeField, { onChange })));
+ });
+ await userEvent.type(input(form), 'F');
+ expect(onChange).toHaveBeenCalledOnce();
+ });
+
+ it('calls a new onError and a new onConfirm once after a render with new props', () => {
+ const form = container();
+ const root = createRoot(form);
+ roots.push(root);
+ const first = { onError: vi.fn(), onConfirm: vi.fn() };
+ const second = { onError: vi.fn(), onConfirm: vi.fn() };
+ act(() => {
+ root.render(createElement(PostcodeField, first));
+ });
+ act(() => {
+ root.render(createElement(PostcodeField, second));
+ });
+ const field = form.querySelector('gatepost-postcode-field')!;
+ act(() => {
+ field.dispatchEvent(new CustomEvent('gatepost-error', { detail: { code: 'secret_key' } }));
+ field.dispatchEvent(new CustomEvent('gatepost-confirm', { detail: { lookup: {} } }));
+ });
+ expect(first.onError).not.toHaveBeenCalled();
+ expect(first.onConfirm).not.toHaveBeenCalled();
+ expect(second.onError).toHaveBeenCalledOnce();
+ expect(second.onConfirm).toHaveBeenCalledOnce();
+ });
+
+ it('leaves no listener when a handler goes away or the component unmounts', () => {
+ const form = container();
+ const root = createRoot(form);
+ const onChange = vi.fn();
+ act(() => {
+ root.render(createElement(PostcodeField, { onChange }));
+ });
+ const field = form.querySelector('gatepost-postcode-field')!;
+ const send = (): void => {
+ field.dispatchEvent(new CustomEvent('gatepost-change', { detail: { value: 'x' } }));
+ };
+ send();
+ expect(onChange).toHaveBeenCalledTimes(1);
+ act(() => {
+ root.render(createElement(PostcodeField, {}));
+ });
+ send();
+ expect(onChange).toHaveBeenCalledTimes(1);
+ act(() => {
+ root.render(createElement(PostcodeField, { onChange }));
+ });
+ send();
+ expect(onChange).toHaveBeenCalledTimes(2);
+ act(() => {
+ root.unmount();
+ });
+ send();
+ expect(onChange).toHaveBeenCalledTimes(2);
+ });
+
+ it('renders the HTML of every prop on the server with this version of React', () => {
+ // The test browser has a document, so React 18 warns about the layout effect. See the
+ // hydration test below.
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const html = renderToString(
+ createElement(PostcodeField, {
+ name: 'postcode',
+ defaultValue: 'FC-01-Z99-ZZ-01',
+ required: true,
+ disabled: false,
+ gps: false,
+ legacy: 'reject',
+ }),
+ );
+ expect(html).toBe(
+ ' ',
+ );
+ });
+
+ it('runs the cleanup that a callback ref returns, on React 19', () => {
+ const cleanup = vi.fn();
+ const ref = vi.fn<(element: PostcodeFieldElement | null) => () => void>(() => cleanup);
+ const form = container();
+ const root = createRoot(form);
+ act(() => {
+ root.render(createElement(PostcodeField, { ref }));
+ });
+ const field = form.querySelector('gatepost-postcode-field');
+ act(() => {
+ root.unmount();
+ });
+ if (version.startsWith('19.')) {
+ expect(cleanup).toHaveBeenCalledOnce();
+ expect(ref).toHaveBeenCalledOnce();
+ expect(ref).toHaveBeenCalledWith(field);
+ } else {
+ expect(ref).toHaveBeenLastCalledWith(null);
+ }
+ });
+
+ it('hydrates the HTML of a server render with no warning', () => {
+ const element = createElement(PostcodeField, { name: 'postcode', defaultValue: 'fc01z99zz01' });
+ const form = container();
+ // This browser has a document, so React 18 warns that a layout effect does nothing on the
+ // server. A real server has no document, and the component then uses a passive effect. So the
+ // spy starts after the render to a string.
+ form.innerHTML = renderToString(element);
+ const consoleError = vi.spyOn(console, 'error');
+ act(() => {
+ roots.push(hydrateRoot(form, element));
+ });
+ expect(consoleError.mock.calls).toEqual([]);
+ expect(new FormData(form).get('postcode')).toBe('FC-01-Z99-ZZ-01');
+ expect(form.querySelector('gatepost-postcode-field')?.getAttribute('value')).toBe(
+ 'fc01z99zz01',
+ );
+ });
+});
diff --git a/packages/react/test/readme-example.tsx b/packages/react/test/readme-example.tsx
new file mode 100644
index 0000000..21d73ec
--- /dev/null
+++ b/packages/react/test/readme-example.tsx
@@ -0,0 +1,22 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+'use client';
+import { useState, type ReactElement } from 'react';
+import { PostcodeField } from '@gatepost/react';
+
+export function AddressForm(): ReactElement {
+ const [postcode, setPostcode] = useState('');
+ return (
+
+ );
+}
diff --git a/packages/react/test/readme.test.ts b/packages/react/test/readme.test.ts
new file mode 100644
index 0000000..0e8bb30
--- /dev/null
+++ b/packages/react/test/readme.test.ts
@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { act, createElement } from 'react';
+import { createRoot } from 'react-dom/client';
+import { describe, expect, it } from 'vitest';
+import { commands, userEvent } from 'vitest/browser';
+import { SPEC_VERSION } from '../src/index.js';
+import { AddressForm } from './readme-example.tsx';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe('README', () => {
+ it('holds the quickstart that this test runs, line for line', async () => {
+ const readme = await commands.readFile('README.md');
+ const example = await commands.readFile('test/readme-example.tsx');
+ const quickstart = /## Quickstart\n\n```tsx\n([\s\S]*?)```/.exec(readme)?.[1];
+ expect(quickstart).toBe(example.split('\n').slice(2).join('\n'));
+ expect(readme).toContain('> Unofficial. Not made or endorsed by NIPOST.');
+ expect(readme).toMatch(new RegExp(`Gatepost spec \\| ${SPEC_VERSION.replaceAll('.', '\\.')} `));
+ });
+
+ it('says that the first alpha is not on npm yet', async () => {
+ const readme = await commands.readFile('README.md');
+ expect(readme).toContain('The first alpha is not on npm yet');
+ });
+
+ it('shows the canonical form of a typed postcode, as the quickstart says', async () => {
+ const container = document.createElement('div');
+ document.body.append(container);
+ const root = createRoot(container);
+ act(() => {
+ root.render(createElement(AddressForm));
+ });
+ const field = container.querySelector('gatepost-postcode-field');
+ await userEvent.type(field!.shadowRoot!.querySelector('input')!, 'fc 01 z99 zz 01');
+ expect(container.querySelector('p')?.textContent).toBe('FC-01-Z99-ZZ-01');
+ act(() => {
+ root.unmount();
+ });
+ });
+});
diff --git a/packages/react/test/server.test.ts b/packages/react/test/server.test.ts
new file mode 100644
index 0000000..bc56755
--- /dev/null
+++ b/packages/react/test/server.test.ts
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { createElement } from 'react';
+import { renderToString } from 'react-dom/server';
+import { describe, expect, it } from 'vitest';
+import { PostcodeField } from '../src/index.js';
+
+describe('a server render', () => {
+ it('runs with no DOM, as on a server', () => {
+ expect(typeof document).toBe('undefined');
+ expect(typeof customElements).toBe('undefined');
+ });
+
+ it('writes the element with each setting as an attribute', () => {
+ const html = renderToString(
+ createElement(PostcodeField, {
+ name: 'postcode',
+ defaultValue: 'FC-01-Z99-ZZ-01',
+ required: true,
+ label: 'Delivery postcode',
+ apiKey: 'nipost_pk_test_mock',
+ baseUrl: 'http://127.0.0.1:4010',
+ confirm: 'level2',
+ gps: true,
+ legacy: 'reject',
+ }),
+ );
+ expect(html).toBe(
+ '' +
+ ' ',
+ );
+ });
+
+ it('leaves out a setting that is false or missing', () => {
+ const html = renderToString(createElement(PostcodeField, { required: false, gps: false }));
+ expect(html).toBe(' ');
+ });
+});
diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json
new file mode 100644
index 0000000..aafe8e7
--- /dev/null
+++ b/packages/react/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["node"],
+ "allowImportingTsExtensions": true,
+ "jsx": "react-jsx",
+ "paths": {
+ "@gatepost/core": ["../core/src/index.ts"],
+ "@gatepost/client": ["../client/src/index.ts"],
+ "@gatepost/field": ["../field/src/index.ts"],
+ "@gatepost/react": ["./src/index.ts"]
+ }
+ },
+ "include": ["src", "test"]
+}
diff --git a/packages/react/tsdown.config.ts b/packages/react/tsdown.config.ts
new file mode 100644
index 0000000..c1506c0
--- /dev/null
+++ b/packages/react/tsdown.config.ts
@@ -0,0 +1,28 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { readFileSync } from 'node:fs';
+import { defineConfig } from 'tsdown';
+
+// The bundler drops the package comment of src/index.ts from the declarations, and API Extractor
+// reads that comment from the top of dist/index.d.ts. So the build puts the comment back.
+function packageComment(): string {
+ const source = readFileSync(new URL('./src/index.ts', import.meta.url), 'utf8');
+ const comments = source.match(/\/\*\*[\s\S]*?\*\//g) ?? [];
+ const comment = comments.find((text) => text.includes('@packageDocumentation'));
+ if (comment === undefined) {
+ throw new Error('src/index.ts has no doc comment with the tag @packageDocumentation.');
+ }
+ return comment;
+}
+
+export default defineConfig({
+ entry: ['src/index.ts'],
+ format: 'esm',
+ platform: 'neutral',
+ target: 'es2022',
+ dts: true,
+ // A bundler drops the directive of a module that it joins with others, and Next.js needs it
+ // at the top of the file that it imports.
+ banner: { js: "'use client';", dts: packageComment() },
+ clean: true,
+});
diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts
new file mode 100644
index 0000000..771e14f
--- /dev/null
+++ b/packages/react/vitest.config.ts
@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { fileURLToPath } from 'node:url';
+import { playwright } from '@vitest/browser-playwright';
+import { defineConfig } from 'vitest/config';
+
+const source = (path: string): string => fileURLToPath(new URL(path, import.meta.url));
+const BROWSER_TESTS = ['test/browser.test.ts', 'test/readme.test.ts'];
+// Each project gets its own copy, because Vitest names the browser instance after the project.
+const chromium = () => ({
+ enabled: true,
+ headless: true,
+ provider: playwright(),
+ instances: [{ browser: 'chromium' as const }],
+});
+
+// The component supports React 18 and React 19 (TS-19). The browser tests run once with each,
+// and React 18 comes in through the aliases react-18 and react-dom-18. The server test renders
+// in Node, with no DOM, as a server render of a page does.
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@gatepost/core': source('../core/src/index.ts'),
+ '@gatepost/client': source('../client/src/index.ts'),
+ '@gatepost/field': source('../field/src/index.ts'),
+ '@gatepost/react': source('src/index.ts'),
+ },
+ },
+ test: {
+ coverage: {
+ provider: 'v8',
+ include: ['src/**/*.ts'],
+ thresholds: { branches: 80, functions: 80, lines: 80, statements: 80 },
+ },
+ projects: [
+ {
+ extends: true,
+ test: { name: 'server', include: ['test/server.test.ts'], environment: 'node' },
+ },
+ {
+ extends: true,
+ test: { name: 'react-19', include: BROWSER_TESTS, browser: chromium() },
+ },
+ {
+ extends: true,
+ resolve: {
+ alias: [
+ { find: /^react$/, replacement: 'react-18' },
+ { find: /^react\/(.*)$/, replacement: 'react-18/$1' },
+ { find: /^react-dom$/, replacement: 'react-dom-18' },
+ { find: /^react-dom\/(.*)$/, replacement: 'react-dom-18/$1' },
+ ],
+ },
+ test: { name: 'react-18', include: BROWSER_TESTS, browser: chromium() },
+ },
+ ],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4c4d020..8adabd5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -169,6 +169,9 @@ importers:
'@arethetypeswrong/cli':
specifier: ^0.18.5
version: 0.18.5
+ '@axe-core/playwright':
+ specifier: ^4.13.0
+ version: 4.13.0(playwright-core@1.63.0)
'@changesets/cli':
specifier: ^3.0.3
version: 3.0.3
@@ -187,6 +190,9 @@ importers:
'@microsoft/api-extractor':
specifier: ^7.59.3
version: 7.59.3(@types/node@22.20.4)
+ '@playwright/test':
+ specifier: ^1.63.0
+ version: 1.63.0
'@size-limit/preset-small-lib':
specifier: ^14.1.0
version: 14.1.0(size-limit@14.1.0)
@@ -196,12 +202,18 @@ importers:
'@types/node':
specifier: ^22.20.4
version: 22.20.4
+ '@vitest/browser-playwright':
+ specifier: ^5.0.3
+ version: 5.0.3(playwright@1.63.0)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.3)
'@vitest/coverage-v8':
specifier: ^5.0.3
- version: 5.0.3(vitest@5.0.3)
+ version: 5.0.3(@vitest/browser@5.0.3)(vitest@5.0.3)
ajv:
specifier: ^8.20.0
version: 8.20.0
+ axe-core:
+ specifier: ^4.13.0
+ version: 4.13.0
eslint:
specifier: ^10.11.0
version: 10.11.0(jiti@2.6.1)(supports-color@8.1.1)
@@ -214,6 +226,9 @@ importers:
npm-run-all2:
specifier: ^9.0.3
version: 9.0.3
+ playwright:
+ specifier: ^1.63.0
+ version: 1.63.0
prettier:
specifier: ^3.9.9
version: 3.9.9
@@ -234,7 +249,7 @@ importers:
version: 8.71.0(eslint@10.11.0(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
vitest:
specifier: ^5.0.3
- version: 5.0.3(@types/node@22.20.4)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))
+ version: 5.0.3(@types/node@22.20.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))
yaml:
specifier: ^2.9.1
version: 2.9.1
@@ -251,8 +266,46 @@ importers:
packages/core: {}
+ packages/field:
+ dependencies:
+ '@gatepost/client':
+ specifier: workspace:^
+ version: link:../client
+ '@gatepost/core':
+ specifier: workspace:^
+ version: link:../core
+ devDependencies:
+ '@gatepost/mock-server':
+ specifier: workspace:*
+ version: link:../mock-server
+
packages/mock-server: {}
+ packages/react:
+ dependencies:
+ '@gatepost/field':
+ specifier: workspace:^
+ version: link:../field
+ devDependencies:
+ '@types/react':
+ specifier: ^19.3.0
+ version: 19.3.0
+ '@types/react-dom':
+ specifier: ^19.3.0
+ version: 19.3.0(@types/react@19.3.0)
+ react:
+ specifier: ^19.3.0
+ version: 19.3.0
+ react-18:
+ specifier: npm:react@^18.3.1
+ version: react@18.3.1
+ react-dom:
+ specifier: ^19.3.0
+ version: 19.3.0(react@19.3.0)
+ react-dom-18:
+ specifier: npm:react-dom@^18.3.1
+ version: react-dom@18.3.1(react@19.3.0)
+
packages:
'@andrewbranch/untar.js@1.0.4':
@@ -267,6 +320,11 @@ packages:
resolution: {integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==}
engines: {node: '>=20'}
+ '@axe-core/playwright@4.13.0':
+ resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==}
+ peerDependencies:
+ playwright-core: '>= 1.0.0'
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -477,6 +535,9 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
+ '@blazediff/core@1.10.0':
+ resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==}
+
'@braidai/lang@1.1.2':
resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==}
@@ -906,10 +967,18 @@ packages:
'@oxc-project/types@0.152.0':
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
+ '@playwright/test@1.63.0':
+ resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
+ engines: {node: '>=20'}
+ hasBin: true
+
'@pnpm/deps.graph-sequencer@1100.0.1':
resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==}
engines: {node: '>=22.13'}
+ '@polka/url@1.0.0-next.29':
+ resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+
'@publint/pack@0.1.7':
resolution: {integrity: sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==}
engines: {node: '>=18'}
@@ -1135,6 +1204,14 @@ packages:
'@types/node@26.6.3':
resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==}
+ '@types/react-dom@19.3.0':
+ resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
+ peerDependencies:
+ '@types/react': ^19.3.0
+
+ '@types/react@19.3.0':
+ resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
+
'@typescript-eslint/eslint-plugin@8.71.0':
resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1194,6 +1271,17 @@ packages:
resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@vitest/browser-playwright@5.0.3':
+ resolution: {integrity: sha512-rYBHwIFL/ZtI6dl0rbrAatScXCrvX+TL2BX2AGOigkC1Y6u3rIWw8KMQJJMS4drSa1ksMCmZU2wpgwdE7kqu3g==}
+ peerDependencies:
+ playwright: '*'
+ vitest: 5.0.3
+
+ '@vitest/browser@5.0.3':
+ resolution: {integrity: sha512-7KHIkVA/gTRxQV9qPa9qLK+g5INZG6iRrlwXSFG5EKsOomW3UzzW7wsdt1Yp5PufzQ43lniuONlb4lUVCC2qSg==}
+ peerDependencies:
+ vitest: 5.0.3
+
'@vitest/coverage-v8@5.0.3':
resolution: {integrity: sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==}
peerDependencies:
@@ -1222,9 +1310,20 @@ packages:
vite:
optional: true
+ '@vitest/pretty-format@5.0.3':
+ resolution: {integrity: sha512-3hr5eV5z2ff0l2Q1vaKQbB4j3M3tElw8stDokEOTqXJB3Es5VrT8gBRIMY59gGuFp8Y61sWlWFK1aNUH5dHDYw==}
+
'@vitest/spy@5.0.3':
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
+ '@vitest/ui@5.0.3':
+ resolution: {integrity: sha512-9dfvqDBvSbHM6y9YVXxAypkl2z7mrWozK/JFo237qI0cj/3M/kzDKiVZqzi3TE54Ler7Jditud7OMBZCyUvRHA==}
+ peerDependencies:
+ vitest: 5.0.3
+
+ '@vitest/utils@5.0.3':
+ resolution: {integrity: sha512-77fDTZv8eu1CzlIfgPU2yrl5MXSUaUAOvgdZEeVm3X3WIxUubIX2SforqS6176lT32HCF6uBVMYVqfHxw7o6eg==}
+
'@yuku-codegen/binding-android-arm64@0.10.2':
resolution: {integrity: sha512-Oc2KInVkPfUjEB4PeV6X0NvIoyYTzDe/WmqFrTwBqKs6YKnz4A7XwWQvJTb7M0rLz9a6WgpUUiiS5QKaCi4O/g==}
cpu: [arm64]
@@ -1447,6 +1546,10 @@ packages:
ast-v8-to-istanbul@1.0.7:
resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==}
+ axe-core@4.13.0:
+ resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
+ engines: {node: '>=4'}
+
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
@@ -1585,6 +1688,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -2132,6 +2238,10 @@ packages:
lodash.groupby@4.6.0:
resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==}
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
lru-cache@11.5.3:
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
engines: {node: 20 || >=22}
@@ -2176,6 +2286,10 @@ packages:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
+ mrmime@2.0.1:
+ resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
+ engines: {node: '>=10'}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2307,6 +2421,9 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2319,6 +2436,20 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ playwright-core@1.63.0:
+ resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.63.0:
+ resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ pngjs@7.0.0:
+ resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
+ engines: {node: '>=14.19.0'}
+
postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
@@ -2363,6 +2494,24 @@ packages:
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
+ react-dom@18.3.1:
+ resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
+ peerDependencies:
+ react: ^18.3.1
+
+ react-dom@19.3.0:
+ resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==}
+ peerDependencies:
+ react: ^19.3.0
+
+ react@18.3.1:
+ resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
+ engines: {node: '>=0.10.0'}
+
+ react@19.3.0:
+ resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
+ engines: {node: '>=0.10.0'}
+
read-package-json-fast@6.0.0:
resolution: {integrity: sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
@@ -2432,6 +2581,12 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ scheduler@0.23.2:
+ resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
+
+ scheduler@0.28.0:
+ resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
+
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
@@ -2474,6 +2629,10 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
+ sirv@3.0.2:
+ resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
+ engines: {node: '>=18'}
+
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -2584,6 +2743,10 @@ packages:
resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==}
engines: {node: '>=20'}
+ totalist@3.0.1:
+ resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
+ engines: {node: '>=6'}
+
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -2828,6 +2991,18 @@ packages:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
+ ws@8.22.0:
+ resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -2898,6 +3073,11 @@ snapshots:
typescript: 5.6.1-rc
validate-npm-package-name: 5.0.1
+ '@axe-core/playwright@4.13.0(playwright-core@1.63.0)':
+ dependencies:
+ axe-core: 4.13.0
+ playwright-core: 1.63.0
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -3143,6 +3323,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
+ '@blazediff/core@1.10.0': {}
+
'@braidai/lang@1.1.2': {}
'@cacheable/memory@2.2.0':
@@ -3658,8 +3840,14 @@ snapshots:
'@oxc-project/types@0.152.0': {}
+ '@playwright/test@1.63.0':
+ dependencies:
+ playwright: 1.63.0
+
'@pnpm/deps.graph-sequencer@1100.0.1': {}
+ '@polka/url@1.0.0-next.29': {}
+
'@publint/pack@0.1.7':
dependencies:
tinyexec: 1.3.1
@@ -3867,6 +4055,14 @@ snapshots:
dependencies:
undici-types: 8.9.0
+ '@types/react-dom@19.3.0(@types/react@19.3.0)':
+ dependencies:
+ '@types/react': 19.3.0
+
+ '@types/react@19.3.0':
+ dependencies:
+ csstype: 3.2.3
+
'@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.11.0(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.11.0(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -3958,7 +4154,38 @@ snapshots:
'@typescript-eslint/types': 8.71.0
eslint-visitor-keys: 5.0.1
- '@vitest/coverage-v8@5.0.3(vitest@5.0.3)':
+ '@vitest/browser-playwright@5.0.3(playwright@1.63.0)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.3)':
+ dependencies:
+ '@vitest/browser': 5.0.3(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.3)
+ '@vitest/mocker': 5.0.3(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))
+ playwright: 1.63.0
+ tinyrainbow: 3.2.0
+ vitest: 5.0.3(@types/node@22.20.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))
+ transitivePeerDependencies:
+ - bufferutil
+ - msw
+ - utf-8-validate
+ - vite
+
+ '@vitest/browser@5.0.3(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.3)':
+ dependencies:
+ '@blazediff/core': 1.10.0
+ '@vitest/mocker': 5.0.3(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))
+ '@vitest/ui': 5.0.3(vitest@5.0.3)
+ '@vitest/utils': 5.0.3
+ magic-string: 1.4.2
+ pngjs: 7.0.0
+ sirv: 3.0.2
+ tinyrainbow: 3.2.0
+ vitest: 5.0.3(@types/node@22.20.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))
+ ws: 8.22.0
+ transitivePeerDependencies:
+ - bufferutil
+ - msw
+ - utf-8-validate
+ - vite
+
+ '@vitest/coverage-v8@5.0.3(@vitest/browser@5.0.3)(vitest@5.0.3)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/istanbul-lib-coverage': 1.0.2
@@ -3968,7 +4195,9 @@ snapshots:
obug: 2.2.1
std-env: 4.3.0
tinyrainbow: 3.2.0
- vitest: 5.0.3(@types/node@22.20.4)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))
+ vitest: 5.0.3(@types/node@22.20.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))
+ optionalDependencies:
+ '@vitest/browser': 5.0.3(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.3)
'@vitest/istanbul-lib-coverage@1.0.2': {}
@@ -3985,8 +4214,28 @@ snapshots:
optionalDependencies:
vite: 8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1)
+ '@vitest/pretty-format@5.0.3':
+ dependencies:
+ tinyrainbow: 3.2.0
+
'@vitest/spy@5.0.3': {}
+ '@vitest/ui@5.0.3(vitest@5.0.3)':
+ dependencies:
+ '@vitest/utils': 5.0.3
+ fflate: 0.8.3
+ flatted: 3.4.4
+ pathe: 2.0.3
+ sirv: 3.0.2
+ tinyrainbow: 3.2.0
+ vitest: 5.0.3(@types/node@22.20.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))
+
+ '@vitest/utils@5.0.3':
+ dependencies:
+ '@vitest/pretty-format': 5.0.3
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.2.0
+
'@yuku-codegen/binding-android-arm64@0.10.2':
optional: true
@@ -4134,6 +4383,8 @@ snapshots:
estree-walker: 3.0.3
js-tokens: 10.0.0
+ axe-core@4.13.0: {}
+
balanced-match@4.0.4: {}
baseline-browser-mapping@2.11.26: {}
@@ -4267,6 +4518,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ csstype@3.2.3: {}
+
debug@4.4.3(supports-color@8.1.1):
dependencies:
ms: 2.1.3
@@ -4740,6 +4993,10 @@ snapshots:
lodash.groupby@4.6.0: {}
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
lru-cache@11.5.3: {}
magic-string@1.4.2:
@@ -4781,6 +5038,8 @@ snapshots:
mri@1.2.0: {}
+ mrmime@2.0.1: {}
+
ms@2.1.3: {}
mutation-server-protocol@0.4.1:
@@ -4902,12 +5161,22 @@ snapshots:
path-parse@1.0.7: {}
+ pathe@2.0.3: {}
+
picocolors@1.1.1: {}
picomatch@4.0.7: {}
pidtree@1.0.0: {}
+ playwright-core@1.63.0: {}
+
+ playwright@1.63.0:
+ dependencies:
+ playwright-core: 1.63.0
+
+ pngjs@7.0.0: {}
+
postcss@8.5.28:
dependencies:
nanoid: 3.3.19
@@ -4945,6 +5214,23 @@ snapshots:
quansync@1.0.0: {}
+ react-dom@18.3.1(react@19.3.0):
+ dependencies:
+ loose-envify: 1.4.0
+ react: 19.3.0
+ scheduler: 0.23.2
+
+ react-dom@19.3.0(react@19.3.0):
+ dependencies:
+ react: 19.3.0
+ scheduler: 0.28.0
+
+ react@18.3.1:
+ dependencies:
+ loose-envify: 1.4.0
+
+ react@19.3.0: {}
+
read-package-json-fast@6.0.0:
dependencies:
json-parse-even-better-errors: 6.0.0
@@ -5014,6 +5300,12 @@ snapshots:
safer-buffer@2.1.2: {}
+ scheduler@0.23.2:
+ dependencies:
+ loose-envify: 1.4.0
+
+ scheduler@0.28.0: {}
+
semver@7.7.4: {}
semver@7.8.5: {}
@@ -5056,6 +5348,12 @@ snapshots:
signal-exit@4.1.0: {}
+ sirv@3.0.2:
+ dependencies:
+ '@polka/url': 1.0.0-next.29
+ mrmime: 2.0.1
+ totalist: 3.0.1
+
sisteransi@1.0.5: {}
size-limit@14.1.0:
@@ -5154,6 +5452,8 @@ snapshots:
'@sindresorhus/base62': 1.0.0
reserved-identifiers: 1.2.0
+ totalist@3.0.1: {}
+
tree-kill@1.2.2: {}
ts-api-utils@2.5.0(typescript@6.0.3):
@@ -5268,7 +5568,7 @@ snapshots:
jiti: 2.6.1
yaml: 2.9.1
- vitest@5.0.3(@types/node@22.20.4)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1)):
+ vitest@5.0.3(@types/node@22.20.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.3(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))
@@ -5286,7 +5586,9 @@ snapshots:
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 22.20.4
- '@vitest/coverage-v8': 5.0.3(vitest@5.0.3)
+ '@vitest/browser-playwright': 5.0.3(playwright@1.63.0)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.3)
+ '@vitest/coverage-v8': 5.0.3(@vitest/browser@5.0.3)(vitest@5.0.3)
+ '@vitest/ui': 5.0.3(vitest@5.0.3)
transitivePeerDependencies:
- msw
@@ -5316,6 +5618,8 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.2.0
+ ws@8.22.0: {}
+
y18n@5.0.8: {}
yaml@2.9.1: {}
diff --git a/scripts/check-licences.mjs b/scripts/check-licences.mjs
index a9b06b9..ec38d47 100644
--- a/scripts/check-licences.mjs
+++ b/scripts/check-licences.mjs
@@ -47,6 +47,13 @@ const EXCEPTIONS = [
'and @gatepost/core ships dist/ only, so nothing redistributes it. The package has one ' +
'variant for each platform, so the name is a pattern.',
},
+ {
+ name: /^(axe-core|@axe-core\/playwright)$/,
+ licence: 'MPL-2.0',
+ reason:
+ 'Dev-only accessibility checks that the tests of the field run in a browser (T-10). We do ' +
+ 'not change them, and the published packages ship dist/ only, so nothing redistributes them.',
+ },
];
// "A OR B" passes when one alternative is allowed. This pattern reads nothing else, so an
diff --git a/scripts/check-licences.test.mjs b/scripts/check-licences.test.mjs
index 399e0c3..2de08ee 100644
--- a/scripts/check-licences.test.mjs
+++ b/scripts/check-licences.test.mjs
@@ -114,10 +114,12 @@ describe('the default policy', () => {
assert.equal(findViolations(report('CC-BY-4.0', 'spdx-exceptions')).length, 1);
});
- it('records lightningcss and its platform packages as the only users of MPL-2.0', () => {
+ it('records lightningcss and the axe accessibility checks as the only users of MPL-2.0', () => {
const platforms = ['lightningcss', 'lightningcss-darwin-arm64', 'lightningcss-linux-x64-gnu'];
- assert.deepEqual(findViolations(report('MPL-2.0', ...platforms)), []);
- assert.equal(findViolations(report('MPL-2.0', 'lightningcssx', 'another-tool')).length, 2);
+ const checks = ['axe-core', '@axe-core/playwright'];
+ assert.deepEqual(findViolations(report('MPL-2.0', ...platforms, ...checks)), []);
+ const others = ['lightningcssx', 'axe-corex', '@axe-core/react', 'another-tool'];
+ assert.equal(findViolations(report('MPL-2.0', ...others)).length, 4);
});
});
diff --git a/scripts/eslint-config.test.mjs b/scripts/eslint-config.test.mjs
index ae0e254..e1cd8a3 100644
--- a/scripts/eslint-config.test.mjs
+++ b/scripts/eslint-config.test.mjs
@@ -74,6 +74,20 @@ const GOOD = [
name: 'a function expression with a doc and an example',
code: `${WITH_EXAMPLE}\n${EXPRESSION}`,
},
+ {
+ name: 'a class getter with a doc comment and no @returns tag',
+ code: lines(
+ '/** A counter. */',
+ 'export class Counter {',
+ ' #count = 1;',
+ '',
+ ' /** The count so far. */',
+ ' get count(): number {',
+ ' return this.#count;',
+ ' }',
+ '}',
+ ),
+ },
{
name: 'an internal function with no example',
code: `${docComment(PARAM, RETURNS, '@internal')}\n${FUNCTION}`,
@@ -279,6 +293,19 @@ const BAD = [
name: 'an exported function expression with no example',
code: `${WITHOUT_EXAMPLE}\n${EXPRESSION}`,
},
+ {
+ rule: 'jsdoc/require-returns',
+ name: 'a documented method that returns a value with no @returns tag',
+ code: lines(
+ '/** A counter. */',
+ 'export class Counter {',
+ ' /** Counts one more. */',
+ ' next(): number {',
+ ' return 1;',
+ ' }',
+ '}',
+ ),
+ },
{
rule: 'jsdoc/check-tag-names',
name: 'a doc tag that the config does not define',
diff --git a/scripts/generate-messages.mjs b/scripts/generate-messages.mjs
new file mode 100644
index 0000000..3c69a30
--- /dev/null
+++ b/scripts/generate-messages.mjs
@@ -0,0 +1,115 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+// Writes packages/field/src/spec-messages.ts from the English message catalogue of the field.
+// Run `pnpm generate` after the spec submodule changes. `pnpm generate:check` fails when the
+// file is out of date.
+import { readFileSync, writeFileSync } from 'node:fs';
+import process from 'node:process';
+import { URL } from 'node:url';
+
+const root = new URL('../', import.meta.url);
+const source = new URL('spec/messages/field_en.arb', root);
+const target = new URL('packages/field/src/spec-messages.ts', root);
+const KEY = /^[a-z][a-z_]*$/;
+
+// These lines are the SPDX tags of the generated file. REUSE must not read them as tags of this
+// script, where they end in a quote and a comma.
+// REUSE-IgnoreStart
+const HEADER = [
+ '// SPDX-FileCopyrightText: 2026 The Gatepost authors',
+ '// SPDX-License-Identifier: Apache-2.0',
+ '// @generated by scripts/generate-messages.mjs from spec/messages. Do not edit this file.',
+ '',
+];
+// REUSE-IgnoreEnd
+
+// A single-quoted literal in printable ASCII (TELL-14). Each other UTF-16 unit becomes an escape,
+// so a translation can use any script.
+function quoted(text) {
+ const escaped = text
+ .replace(/[\\']/g, (character) => `\\${character}`)
+ .replace(/[^ -~]/g, (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, '0')}`);
+ return `'${escaped}'`;
+}
+
+// The keys become property names and a union type, so each one must be a plain name. ARB keeps
+// the description of a key under "@key", and the locale under "@@locale".
+function readMessages() {
+ const catalogue = JSON.parse(readFileSync(source, 'utf8'));
+ const keys = Object.keys(catalogue).filter((key) => !key.startsWith('@'));
+ for (const key of keys) {
+ if (!KEY.test(key)) {
+ throw new Error(`The key ${JSON.stringify(key)} is not lower case with underscores.`);
+ }
+ if (typeof catalogue[key] !== 'string') {
+ throw new Error(`The message ${JSON.stringify(key)} is not text.`);
+ }
+ }
+ if (keys.length === 0) {
+ throw new Error('The catalogue has no message.');
+ }
+ return keys.map((key) => [key, catalogue[key]]);
+}
+
+function render(messages) {
+ return [
+ ...HEADER,
+ '/**',
+ ' * The key of each message that the field shows. `spec/field.md` says when each one shows.',
+ ' */',
+ 'export type MessageKey =',
+ ...messages.map(
+ ([key], index) => ` | ${quoted(key)}${index === messages.length - 1 ? ';' : ''}`,
+ ),
+ '',
+ '/**',
+ ' * The text of the field in one language, one message for each key. A name in braces, such',
+ ' * as `{count}`, is a placeholder that the field fills in.',
+ ' */',
+ 'export type Messages = Readonly>;',
+ '',
+ '/**',
+ ' * The British English messages of the spec.',
+ ' *',
+ ' * @internal',
+ ' */',
+ 'export const ENGLISH: Messages = {',
+ ...messages.map(([key, text]) => ` ${key}: ${quoted(text)},`),
+ '};',
+ '',
+ ].join('\n');
+}
+
+const args = process.argv.slice(2);
+const unknown = args.find((argument) => argument !== '--check');
+if (unknown !== undefined) {
+ process.stderr.write(`Unknown argument ${unknown}. Use --check or no argument.\n`);
+ process.exit(2);
+}
+
+// A reader of this output wants the problem, not a stack trace.
+function readCurrent() {
+ try {
+ return readFileSync(target, 'utf8');
+ } catch {
+ return null;
+ }
+}
+
+let text;
+try {
+ text = render(readMessages());
+} catch (error) {
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
+ process.exit(1);
+}
+if (args.includes('--check')) {
+ if (readCurrent() !== text) {
+ process.stderr.write(
+ 'packages/field/src/spec-messages.ts is out of date. Run pnpm generate.\n',
+ );
+ process.exit(1);
+ }
+} else {
+ writeFileSync(target, text);
+}
diff --git a/scripts/generate-messages.test.mjs b/scripts/generate-messages.test.mjs
new file mode 100644
index 0000000..26ec6be
--- /dev/null
+++ b/scripts/generate-messages.test.mjs
@@ -0,0 +1,149 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import process from 'node:process';
+import { after, describe, it } from 'node:test';
+import { fileURLToPath, URL } from 'node:url';
+
+const SCRIPT = fileURLToPath(new URL('./generate-messages.mjs', import.meta.url));
+const CATALOGUE = fileURLToPath(new URL('../spec/messages/field_en.arb', import.meta.url));
+const NOT_GENERATED = '// This is not the generated file.\n';
+
+const folders = [];
+
+after(() => {
+ for (const folder of folders) {
+ rmSync(folder, { recursive: true, force: true });
+ }
+});
+
+// A copy of the script, the catalogue and the folder of the generated file. `change` edits the
+// parsed catalogue in the copy.
+function copyOfRepo(change = () => undefined) {
+ const root = mkdtempSync(join(tmpdir(), 'generate-messages-'));
+ folders.push(root);
+ mkdirSync(join(root, 'scripts'));
+ mkdirSync(join(root, 'spec', 'messages'), { recursive: true });
+ mkdirSync(join(root, 'packages', 'field', 'src'), { recursive: true });
+ copyFileSync(SCRIPT, join(root, 'scripts', 'generate-messages.mjs'));
+ const catalogue = JSON.parse(readFileSync(CATALOGUE, 'utf8'));
+ change(catalogue);
+ writeFileSync(join(root, 'spec', 'messages', 'field_en.arb'), JSON.stringify(catalogue));
+ return root;
+}
+
+function generatedFile(root) {
+ return join(root, 'packages', 'field', 'src', 'spec-messages.ts');
+}
+
+function generate(root, ...args) {
+ const script = join(root, 'scripts', 'generate-messages.mjs');
+ const { status, stdout, stderr } = spawnSync(process.execPath, [script, ...args], {
+ encoding: 'utf8',
+ });
+ return { status, stdout, stderr };
+}
+
+describe('generate-messages.mjs', () => {
+ it('writes a file that --check accepts, and --check does not change it', () => {
+ const root = copyOfRepo();
+ assert.deepEqual(generate(root), { status: 0, stdout: '', stderr: '' });
+ const text = readFileSync(generatedFile(root), 'utf8');
+ assert.match(text, /@generated by scripts\/generate-messages\.mjs from spec\/messages\./);
+ assert.ok(text.includes(" label: 'Postcode',"));
+ assert.deepEqual(generate(root, '--check'), { status: 0, stdout: '', stderr: '' });
+ assert.equal(readFileSync(generatedFile(root), 'utf8'), text);
+ });
+
+ it('writes each key once in the union of keys, in the order of the catalogue', () => {
+ const root = copyOfRepo();
+ generate(root);
+ const text = readFileSync(generatedFile(root), 'utf8');
+ assert.ok(text.includes("export type MessageKey =\n | 'label'\n | 'hint'\n"));
+ });
+
+ it('writes a character outside ASCII as an escape, so the file passes TELL-14', () => {
+ const eAcute = String.fromCodePoint(0xe9);
+ const root = copyOfRepo((catalogue) => {
+ catalogue.label = `Caf${eAcute}`;
+ });
+ generate(root);
+ const text = readFileSync(generatedFile(root), 'utf8');
+ assert.ok(text.includes(` label: 'Caf${String.fromCharCode(0x5c)}u00e9',`));
+ assert.ok(!text.includes(eAcute));
+ });
+
+ it('fails with --check when the catalogue changed, and leaves the file as it was', () => {
+ const root = copyOfRepo();
+ writeFileSync(generatedFile(root), NOT_GENERATED);
+ const checked = generate(root, '--check');
+ assert.equal(checked.status, 1);
+ assert.equal(
+ checked.stderr,
+ 'packages/field/src/spec-messages.ts is out of date. Run pnpm generate.\n',
+ );
+ assert.equal(readFileSync(generatedFile(root), 'utf8'), NOT_GENERATED);
+ });
+
+ for (const [key, text, problem] of [
+ ['Label', 'Postcode', 'The key "Label" is not lower case with underscores.'],
+ ['label', 7, 'The message "label" is not text.'],
+ ]) {
+ it(`rejects ${problem} and writes nothing`, () => {
+ const root = copyOfRepo((catalogue) => {
+ delete catalogue.label;
+ catalogue[key] = text;
+ });
+ writeFileSync(generatedFile(root), NOT_GENERATED);
+ const { status, stderr } = generate(root);
+ assert.equal(status, 1);
+ assert.equal(stderr, `${problem}\n`);
+ assert.equal(readFileSync(generatedFile(root), 'utf8'), NOT_GENERATED);
+ });
+ }
+
+ it('escapes a quote and a backslash, and writes a character above U+FFFF as two escapes', () => {
+ const slash = String.fromCharCode(0x5c);
+ const root = copyOfRepo((catalogue) => {
+ catalogue.label = `a'b${slash}c${String.fromCodePoint(0x1f600)}`;
+ });
+ generate(root);
+ const text = readFileSync(generatedFile(root), 'utf8');
+ const expected = ` label: 'a${slash}'b${slash}${slash}c${slash}ud83d${slash}ude00',`;
+ assert.ok(text.includes(expected), text);
+ });
+
+ it('rejects a catalogue with no message and writes nothing', () => {
+ const root = copyOfRepo((catalogue) => {
+ for (const key of Object.keys(catalogue)) {
+ if (!key.startsWith('@')) {
+ Reflect.deleteProperty(catalogue, key);
+ }
+ }
+ });
+ writeFileSync(generatedFile(root), NOT_GENERATED);
+ const { status, stderr } = generate(root);
+ assert.equal(status, 1);
+ assert.equal(stderr, 'The catalogue has no message.\n');
+ assert.equal(readFileSync(generatedFile(root), 'utf8'), NOT_GENERATED);
+ });
+
+ it('says that the file is out of date with --check when the file is missing', () => {
+ const { status, stderr } = generate(copyOfRepo(), '--check');
+ assert.equal(status, 1);
+ assert.equal(
+ stderr,
+ 'packages/field/src/spec-messages.ts is out of date. Run pnpm generate.\n',
+ );
+ });
+
+ it('rejects an unknown argument with exit code 2', () => {
+ const { status, stderr } = generate(copyOfRepo(), '--chek');
+ assert.equal(status, 2);
+ assert.equal(stderr, 'Unknown argument --chek. Use --check or no argument.\n');
+ });
+});
diff --git a/spec b/spec
index 359db01..db359cd 160000
--- a/spec
+++ b/spec
@@ -1 +1 @@
-Subproject commit 359db01aa97f2d5b183db431d14dbccb793af9f3
+Subproject commit db359cde28a7c5c549f1a3069f20078f19f05a58