From aa3b7e0f5b1625c1d9caf7f7c559930ed05062cf Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:40:36 +0100 Subject: [PATCH 01/23] chore(repo): move to spec 0.3.0, which defines the postcode field The spec now holds the contract of the field and its message catalogue, which the next packages build on. The core and the client behave as before, so each one only reports the new spec version. The agents file names the new interface file, so an agent that needs a field symbol finds where the spec lists it. Signed-off-by: MobileMage --- .changeset/client-spec-0-3-0.md | 5 +++++ .changeset/core-spec-0-3-0.md | 5 +++++ AGENTS.md | 2 +- CONTEXT.md | 8 ++++++++ packages/client/README.md | 4 ++-- packages/client/etc/client.api.md | 2 +- packages/client/src/version.ts | 4 ++-- packages/core/README.md | 4 ++-- packages/core/etc/core.api.md | 2 +- packages/core/src/version.ts | 4 ++-- packages/mock-server/README.md | 2 +- spec | 2 +- 12 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 .changeset/client-spec-0-3-0.md create mode 100644 .changeset/core-spec-0-3-0.md 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/AGENTS.md b/AGENTS.md index 2f12651..92329e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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..730c10f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -169,6 +169,14 @@ _Avoid_: input, widget, picker To show the user what a postcode points to before the app stores it. _Avoid_: verify, validate (for this step) +**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/packages/client/README.md b/packages/client/README.md index 6751239..fe4505a 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -171,14 +171,14 @@ client.clearCache(); | ------------- | ---------------------------------------------- | | Node | 22 or later, or a current browser with `fetch` | | Module format | ESM only | -| Gatepost spec | 0.2.0 | +| Gatepost spec | 0.3.0 | The package exports `SPEC_VERSION`, the version of the Gatepost spec that it implements. ```ts import { SPEC_VERSION } from '@gatepost/client'; -SPEC_VERSION; // '0.2.0' +SPEC_VERSION; // '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 6ea4ccf..e7553fa 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -180,14 +180,14 @@ if (building.ok) { | ------------- | ----------------------------------- | | Node | 22 or later, or any current browser | | Module format | ESM only | -| Gatepost spec | 0.2.0 | +| Gatepost spec | 0.3.0 | The package exports `SPEC_VERSION`, the version of the Gatepost spec that it implements. ```ts import { SPEC_VERSION } from '@gatepost/core'; -SPEC_VERSION; // '0.2.0' +SPEC_VERSION; // '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/mock-server/README.md b/packages/mock-server/README.md index 3084524..bdec597 100644 --- a/packages/mock-server/README.md +++ b/packages/mock-server/README.md @@ -69,7 +69,7 @@ folder and names a broken file. It does not check a fixture body against the Ope | 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/spec b/spec index 359db01..394544c 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 359db01aa97f2d5b183db431d14dbccb793af9f3 +Subproject commit 394544c61903b27559e73b34294f6d220390edc0 From 46ce03871ca244f4a7bf0ffd09c64811cb49027b Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:43:32 +0100 Subject: [PATCH 02/23] feat(field): read the text of a postcode field and hold its messages The field package starts with the parts that need no element yet. It reads what a user typed with the core, and picks the message that the spec names for each reading. The English messages come from the spec catalogue through a generator, so a change of wording happens in one place for every field. The tests run in Chromium from the start, because the element that comes next needs a real browser. Signed-off-by: MobileMage --- .changeset/first-field-release.md | 5 + .github/workflows/ci.yml | 3 + .gitignore | 1 + .prettierignore | 1 + AGENTS.md | 2 +- README.md | 4 + eslint.config.js | 1 + package.json | 8 +- packages/field/LICENSE | 202 +++++++++++++++++++++++++++ packages/field/NOTICE | 5 + packages/field/api-extractor.json | 25 ++++ packages/field/etc/field.api.md | 16 +++ packages/field/package.json | 70 ++++++++++ packages/field/scripts/smoke.mjs | 16 +++ packages/field/src/index.ts | 13 ++ packages/field/src/messages.ts | 60 ++++++++ packages/field/src/reading.ts | 61 ++++++++ packages/field/src/spec-messages.ts | 74 ++++++++++ packages/field/src/version.ts | 14 ++ packages/field/test/messages.test.ts | 38 +++++ packages/field/test/reading.test.ts | 48 +++++++ packages/field/test/version.test.ts | 12 ++ packages/field/tsconfig.json | 13 ++ packages/field/tsdown.config.ts | 39 ++++++ packages/field/vitest.config.ts | 34 +++++ pnpm-lock.yaml | 177 ++++++++++++++++++++++- scripts/generate-messages.mjs | 97 +++++++++++++ scripts/generate-messages.test.mjs | 114 +++++++++++++++ 28 files changed, 1144 insertions(+), 9 deletions(-) create mode 100644 .changeset/first-field-release.md create mode 100644 packages/field/LICENSE create mode 100644 packages/field/NOTICE create mode 100644 packages/field/api-extractor.json create mode 100644 packages/field/etc/field.api.md create mode 100644 packages/field/package.json create mode 100644 packages/field/scripts/smoke.mjs create mode 100644 packages/field/src/index.ts create mode 100644 packages/field/src/messages.ts create mode 100644 packages/field/src/reading.ts create mode 100644 packages/field/src/spec-messages.ts create mode 100644 packages/field/src/version.ts create mode 100644 packages/field/test/messages.test.ts create mode 100644 packages/field/test/reading.test.ts create mode 100644 packages/field/test/version.test.ts create mode 100644 packages/field/tsconfig.json create mode 100644 packages/field/tsdown.config.ts create mode 100644 packages/field/vitest.config.ts create mode 100644 scripts/generate-messages.mjs create mode 100644 scripts/generate-messages.test.mjs diff --git a/.changeset/first-field-release.md b/.changeset/first-field-release.md new file mode 100644 index 0000000..df14c00 --- /dev/null +++ b/.changeset/first-field-release.md @@ -0,0 +1,5 @@ +--- +'@gatepost/field': minor +--- + +Add the field package. It starts with the reading of the text of a postcode field and the English messages of spec 0.3.0. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9917feb..dff9c31 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,6 +50,7 @@ jobs: node-version: 22 - run: corepack enable - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium - run: pnpm test commits: diff --git a/.gitignore b/.gitignore index 2ec3672..5be3dab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ temp/ reports/ .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 92329e0..1dd7263 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` and the private `@gatepost/mock-server`. ## Read first diff --git a/README.md b/README.md index 668e7ed..673240e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ TypeScript packages for Nigeria's National Digital Postcode. | ----------------------------------------------- | --------------------------------------------------- | | [`@gatepost/core`](packages/core) | Parse, check and format postcodes offline. | | [`@gatepost/client`](packages/client) | Call NIPOST's gateway, with retries and limits. | +| [`@gatepost/field`](packages/field) | A postcode field for any web form. | | [`@gatepost/mock-server`](packages/mock-server) | Mock NIPOST's gateway for client tests. Not on npm. | ## Develop @@ -21,8 +22,11 @@ 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 run in Chromium, which Playwright installs once. + `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..f152141 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, diff --git a/package.json b/package.json index a0fdd48..d9d6dfb 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,10 @@ "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": { @@ -40,12 +42,14 @@ "@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", "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/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/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..c53221c --- /dev/null +++ b/packages/field/etc/field.api.md @@ -0,0 +1,16 @@ +## API Report File for "@gatepost/field" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @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 const SPEC_VERSION = "0.3.0"; + +``` diff --git a/packages/field/package.json b/packages/field/package.json new file mode 100644 index 0000000..641e396 --- /dev/null +++ b/packages/field/package.json @@ -0,0 +1,70 @@ +{ + "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", + "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/field/scripts/smoke.mjs b/packages/field/scripts/smoke.mjs new file mode 100644 index 0000000..8f27c85 --- /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 importing either +// entry does not fail there. +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') { + throw new Error(`${entry} gave the spec version ${String(exports.SPEC_VERSION)}.`); + } +} diff --git a/packages/field/src/index.ts b/packages/field/src/index.ts new file mode 100644 index 0000000..7a97178 --- /dev/null +++ b/packages/field/src/index.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * 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 type { MessageKey, Messages } from './spec-messages.js'; +export { SPEC_VERSION } from './version.js'; diff --git a/packages/field/src/messages.ts b/packages/field/src/messages.ts new file mode 100644 index 0000000..909eb74 --- /dev/null +++ b/packages/field/src/messages.ts @@ -0,0 +1,60 @@ +// 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. +function segmentKey(segment: Precision | null): MessageKey { + switch (segment) { + case 'lga': + return 'bad_lga'; + case 'area': + return 'bad_area'; + default: + return 'bad_unit'; + } +} + +/** + * 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 = values[name]; + 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..6f2fd96 --- /dev/null +++ b/packages/field/src/reading.ts @@ -0,0 +1,61 @@ +// 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 LONG_TEXT = 64; + +// The grammar counts code points, not UTF-16 units, so Array.from splits the text. +function countOf(text: string): number { + const codePoints = Array.from(text).length; + return codePoints > LONG_TEXT ? codePoints : Array.from(normalize(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/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/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/test/messages.test.ts b/packages/field/test/messages.test.ts new file mode 100644 index 0000000..f4bd64c --- /dev/null +++ b/packages/field/test/messages.test.ts @@ -0,0 +1,38 @@ +// 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 } 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); + }); +}); + +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}.'); + }); +}); diff --git a/packages/field/test/reading.test.ts b/packages/field/test/reading.test.ts new file mode 100644 index 0000000..659d73f --- /dev/null +++ b/packages/field/test/reading.test.ts @@ -0,0 +1,48 @@ +// 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); + }); +}); 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..fea3316 --- /dev/null +++ b/packages/field/vitest.config.ts @@ -0,0 +1,34 @@ +// 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'], + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 4c4d020..d8b0cca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,9 +196,12 @@ 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 @@ -214,6 +217,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 +240,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,6 +257,15 @@ importers: packages/core: {} + packages/field: + dependencies: + '@gatepost/client': + specifier: workspace:^ + version: link:../client + '@gatepost/core': + specifier: workspace:^ + version: link:../core + packages/mock-server: {} packages: @@ -477,6 +492,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==} @@ -910,6 +928,9 @@ packages: 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'} @@ -1194,6 +1215,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 +1254,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] @@ -2176,6 +2219,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 +2354,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 +2369,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} @@ -2474,6 +2538,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 +2652,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 +2900,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'} @@ -3143,6 +3227,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.10.0': {} + '@braidai/lang@1.1.2': {} '@cacheable/memory@2.2.0': @@ -3660,6 +3746,8 @@ snapshots: '@pnpm/deps.graph-sequencer@1100.0.1': {} + '@polka/url@1.0.0-next.29': {} + '@publint/pack@0.1.7': dependencies: tinyexec: 1.3.1 @@ -3958,7 +4046,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 +4087,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 +4106,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 @@ -4781,6 +4922,8 @@ snapshots: mri@1.2.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} mutation-server-protocol@0.4.1: @@ -4902,12 +5045,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 @@ -5056,6 +5209,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 +5313,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 +5429,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 +5447,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 +5479,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/generate-messages.mjs b/scripts/generate-messages.mjs new file mode 100644 index 0000000..3671c18 --- /dev/null +++ b/scripts/generate-messages.mjs @@ -0,0 +1,97 @@ +// 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.`); + } + } + 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); +} + +const text = render(readMessages()); +if (args.includes('--check')) { + if (readFileSync(target, 'utf8') !== 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..10017e1 --- /dev/null +++ b/scripts/generate-messages.test.mjs @@ -0,0 +1,114 @@ +// 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.ok(stderr.includes(problem), stderr); + assert.equal(readFileSync(generatedFile(root), 'utf8'), NOT_GENERATED); + }); + } + + 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'); + }); +}); From 2049a76d9b50172ae69c8564d06fd5a10123412c Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 15:15:05 +0100 Subject: [PATCH 03/23] feat(field): add the postcode field element for plain forms A form needs a postcode input that sends the canonical code, works with the keyboard alone and tells the user what is wrong in plain words. The element takes part in the form through its internals, so a form with no script logic submits it. It waits until the user leaves the input before it shows an error, and it never applies a fix by itself. axe-core checks each state in the browser, so it joins the licence exceptions as a dev-only tool. Signed-off-by: MobileMage --- .changeset/field-element.md | 5 + eslint.config.js | 2 + package.json | 1 + packages/field/etc/field.api.md | 37 ++++ packages/field/scripts/smoke.mjs | 8 +- packages/field/src/element.ts | 311 ++++++++++++++++++++++++++++ packages/field/src/index.ts | 12 ++ packages/field/src/state.ts | 81 ++++++++ packages/field/src/styles.ts | 134 ++++++++++++ packages/field/src/types.ts | 24 +++ packages/field/src/view.ts | 108 ++++++++++ packages/field/test/element.test.ts | 248 ++++++++++++++++++++++ packages/field/test/field.ts | 70 +++++++ packages/field/test/styles.test.ts | 53 +++++ packages/field/test/tsdoc.test.ts | 34 +++ pnpm-lock.yaml | 9 + scripts/check-licences.mjs | 7 + scripts/check-licences.test.mjs | 8 +- scripts/eslint-config.test.mjs | 27 +++ 19 files changed, 1172 insertions(+), 7 deletions(-) create mode 100644 .changeset/field-element.md create mode 100644 packages/field/src/element.ts create mode 100644 packages/field/src/state.ts create mode 100644 packages/field/src/styles.ts create mode 100644 packages/field/src/types.ts create mode 100644 packages/field/src/view.ts create mode 100644 packages/field/test/element.test.ts create mode 100644 packages/field/test/field.ts create mode 100644 packages/field/test/styles.test.ts create mode 100644 packages/field/test/tsdoc.test.ts diff --git a/.changeset/field-element.md b/.changeset/field-element.md new file mode 100644 index 0000000..2b1bd76 --- /dev/null +++ b/.changeset/field-element.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. diff --git a/eslint.config.js b/eslint.config.js index f152141..ee88aa3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -91,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 d9d6dfb..4c25856 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@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", diff --git a/packages/field/etc/field.api.md b/packages/field/etc/field.api.md index c53221c..86b503e 100644 --- a/packages/field/etc/field.api.md +++ b/packages/field/etc/field.api.md @@ -4,12 +4,49 @@ ```ts +import { Postcode } from '@gatepost/core'; + +// @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 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>; +// Warning: (ae-forgotten-export) The symbol "ElementBase" needs to be exported by the entry point index.d.ts +// +// @public +export class PostcodeFieldElement extends ElementBase { + constructor(); + attributeChangedCallback(name: string): void; + checkValidity(): boolean; + connectedCallback(): void; + get form(): HTMLFormElement | null; + static readonly formAssociated = true; + formDisabledCallback(disabled: boolean): void; + formResetCallback(): void; + formStateRestoreCallback(state: unknown): void; + get messages(): Messages; + set messages(messages: Partial); + static readonly observedAttributes: readonly string[]; + reportValidity(): boolean; + get validationMessage(): string; + get validity(): ValidityState; + get value(): string; + set value(text: string); + get willValidate(): boolean; +} + // @public export const SPEC_VERSION = "0.3.0"; diff --git a/packages/field/scripts/smoke.mjs b/packages/field/scripts/smoke.mjs index 8f27c85..2970c6b 100644 --- a/packages/field/scripts/smoke.mjs +++ b/packages/field/scripts/smoke.mjs @@ -1,8 +1,8 @@ // 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 importing either -// entry does not fail there. +// 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'; @@ -10,7 +10,7 @@ for (const [entry, exports] of [ ['@gatepost/field', field], ['@gatepost/field/element', element], ]) { - if (exports.SPEC_VERSION !== '0.3.0') { - throw new Error(`${entry} gave the spec version ${String(exports.SPEC_VERSION)}.`); + 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..eb8a079 --- /dev/null +++ b/packages/field/src/element.ts @@ -0,0 +1,311 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { fill } from './messages.js'; +import { readText, type Reading } from './reading.js'; +import { ENGLISH, type Messages } from './spec-messages.js'; +import { readingNote, readingState, type Note, type State } from './state.js'; +import type { ChangeDetail, ChangeSource } from './types.js'; +import { buildView, renderView, type View } from './view.js'; + +interface Events { + 'gatepost-change': ChangeDetail; +} + +// A change of these attributes changes the validity. The others change the view. +const CHECKED_ATTRIBUTES = new Set(['required', '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 postcode field, ``. A plain HTML form submits its form value: the + * canonical form of the postcode. It checks the format offline. + * + * @example + * ```html + *
+ * + * + *
+ * ``` + */ +export class PostcodeFieldElement extends ElementBase { + /** 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', 'legacy']; + + readonly #internals: ElementInternals; + readonly #view: View; + #messages: Messages = ENGLISH; + #reading: Reading = { kind: 'empty' }; + #value = ''; + #source: ChangeSource = 'typed'; + #connected = false; + #dirty = false; + #errorsShown = false; + + /** 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 } = this.#view; + input.addEventListener('beforeinput', (event) => { + this.#source = event.inputType === 'insertFromPaste' ? 'pasted' : 'typed'; + }); + input.addEventListener('input', () => { + this.#update(this.#source, null); + }); + input.addEventListener('blur', () => { + this.#leave(); + }); + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !event.isComposing) { + this.#internals.form?.requestSubmit(); + } + }); + suggestion.addEventListener('click', () => { + this.#useSuggestion(); + }); + 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) { + this.#messages = { ...ENGLISH, ...messages }; + 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.#connected = true; + this.#refresh(); + } + + /** + * 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 (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]) { + 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.#view.input.value = state; + this.#refresh(); + } + } + + get #legacy(): 'accept' | 'reject' { + return this.getAttribute('legacy') === 'reject' ? 'reject' : 'accept'; + } + + #update(source: ChangeSource, accuracyM: number | null): void { + const before = this.#value; + this.#dirty = true; + this.#refresh(); + 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 text or a setting changed: read the text again. + #refresh(): void { + const text = this.#view.input.value; + 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.#render(); + } + + #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; + this.#internals.setFormValue(this.#value, this.#view.input.value); + } + this.#render(); + } + + #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(); + } + } + + // 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 readingState(this.#reading, this.#errorsShown, required); + } + + #noteText(state: State): string { + if (state === 'idle' || state === 'typing') { + return ''; + } + return this.#text(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'; + renderView(this.#view, { + state, + invalid: state === 'invalid' || rejected, + rejected, + label: this.getAttribute('label') ?? this.#messages.label, + hint: this.#messages.hint, + note: this.#noteText(state), + didYouMean: this.#suggestionText(state), + useSuggestion: this.#messages.use_suggestion, + }); + } +} diff --git a/packages/field/src/index.ts b/packages/field/src/index.ts index 7a97178..5399d83 100644 --- a/packages/field/src/index.ts +++ b/packages/field/src/index.ts @@ -1,5 +1,6 @@ // 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 @@ -9,5 +10,16 @@ * * @packageDocumentation */ +export { PostcodeFieldElement }; export type { MessageKey, Messages } from './spec-messages.js'; +export type { ChangeDetail, ChangeSource } 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/state.ts b/packages/field/src/state.ts new file mode 100644 index 0000000..8874edc --- /dev/null +++ b/packages/field/src/state.ts @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +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>; +} + +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 }; +} diff --git a/packages/field/src/styles.ts b/packages/field/src/styles.ts new file mode 100644 index 0000000..a4b5437 --- /dev/null +++ b/packages/field/src/styles.ts @@ -0,0 +1,134 @@ +// 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, +// and the border has 3:1 or more, so the defaults meet WCAG 2.2 AA on a white page. +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..1b2d716 --- /dev/null +++ b/packages/field/src/types.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +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; +} diff --git a/packages/field/src/view.ts b/packages/field/src/view.ts new file mode 100644 index 0000000..dd5d88a --- /dev/null +++ b/packages/field/src/view.ts @@ -0,0 +1,108 @@ +// 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; +} + +// The DOM order is the visual order and the tab order. The live region holds the two messages, +// and not the button. +const TEMPLATE = ` +
+ +

+ +
+

+ +
+ +
`; + +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.innerHTML = TEMPLATE; + 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), + }; +} + +/** + * 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 rejected: boolean; + readonly label: string; + readonly hint: string; + readonly note: string; + readonly didYouMean: string | null; + readonly useSuggestion: string; +} + +/** + * 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); + view.label.textContent = display.label; + view.hint.textContent = display.hint; + view.input.setAttribute('aria-invalid', String(display.invalid)); + view.note.textContent = display.note; + view.didYouMean.hidden = display.didYouMean === null; + view.didYouMean.textContent = display.didYouMean ?? ''; + view.suggestion.hidden = display.didYouMean === null; + view.suggestion.textContent = display.useSuggestion; +} diff --git a/packages/field/test/element.test.ts b/packages/field/test/element.test.ts new file mode 100644 index 0000000..b935d0a --- /dev/null +++ b/packages/field/test/element.test.ts @@ -0,0 +1,248 @@ +// 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 { expectAccessible, mount } from './field.js'; + +const FIELD = ''; + +function formValue(form: HTMLFormElement): FormDataEntryValue | null { + return new FormData(form).get('postcode'); +} + +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', () => { + const dash = String.fromCodePoint(0x2013); + const space = String.fromCodePoint(0xa0); + // U+FF26 and U+FF23 are the full-width letters F and C. + const wide = String.fromCodePoint(0xff26, 0xff23); + const { field, input } = mount(FIELD); + for (const text of [ + `FC${dash}01${dash}Z99${dash}ZZ${dash}01`, + `FC${space}01 Z99 ZZ 01`, + `${wide}01Z99ZZ01`, + ]) { + input.value = text; + input.dispatchEvent(new InputEvent('input', { inputType: 'insertFromPaste' })); + expect(field.value).toBe('FC-01-Z99-ZZ-01'); + } + }); + + 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')); + await userEvent.type(billing!.shadowRoot!.querySelector('input')!, 'FC01Z99ZZ01'); + await userEvent.type(delivery!.shadowRoot!.querySelector('input')!, 'FC01Z99ZZ02'); + expect(new FormData(form).get('billing')).toBe('FC-01-Z99-ZZ-01'); + expect(new FormData(form).get('delivery')).toBe('FC-01-Z99-ZZ-02'); + expect(delivery!.shadowRoot!.querySelector('label')?.textContent).toBe('Delivery postcode'); + }); + + 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.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertFromPaste' })); + input.value = 'FC-01-Z99-ZZ-01'; + input.dispatchEvent(new InputEvent('input', { inputType: 'insertFromPaste' })); + expect(changes.map((change) => [change.value, change.source])).toEqual([ + ['F', 'typed'], + ['FC-01-Z99-ZZ-01', 'pasted'], + ]); + expect(changes.at(-1)?.postcode?.display).toBe('FC 01 Z99 ZZ 01'); + expect(changes.at(-1)?.accuracyM).toBeNull(); + }); +}); + +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.'); + }); +}); + +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('reaches the input and then the suggestion button with the Tab key alone', async () => { + const { form, field, input, suggestion } = mount(`${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'); + }); +}); diff --git a/packages/field/test/field.ts b/packages/field/test/field.ts new file mode 100644 index 0000000..a7494e4 --- /dev/null +++ b/packages/field/test/field.ts @@ -0,0 +1,70 @@ +// 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; + /** 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), + 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/styles.test.ts b/packages/field/test/styles.test.ts new file mode 100644 index 0000000..a7ddf8b --- /dev/null +++ b/packages/field/test/styles.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 { 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'); + }); +}); + +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.height).toBeGreaterThanOrEqual(24); + expect(Number.parseFloat(getComputedStyle(input).fontSize)).toBeGreaterThanOrEqual(16); + }); +}); 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/pnpm-lock.yaml b/pnpm-lock.yaml index d8b0cca..deddd78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,6 +205,9 @@ importers: 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) @@ -1490,6 +1493,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} @@ -4275,6 +4282,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: {} 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', From f7ec99edf40d4e51231a58d1cec15d6f17df414e Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 15:17:25 +0100 Subject: [PATCH 04/23] fix(field): harden placeholders, the input count and the generator A placeholder named like an inherited property, such as toString, inserted native function text instead of staying in view. fill now reads only own properties. The count of a long paste now stops at the input limit before it splits the whole text, and a segment error for a segment that the grammar never fails now throws instead of showing the unit message. The generator prints its problem without a stack trace, rejects an empty catalogue and treats a missing target as out of date. New tests cover quote and backslash escapes, an astral character, code points at the limit and full-width digits. Signed-off-by: MobileMage --- packages/field/src/messages.ts | 9 ++++--- packages/field/src/reading.ts | 14 ++++++++--- packages/field/test/messages.test.ts | 17 ++++++++++++- packages/field/test/reading.test.ts | 16 ++++++++++++ scripts/generate-messages.mjs | 22 +++++++++++++++-- scripts/generate-messages.test.mjs | 37 +++++++++++++++++++++++++++- 6 files changed, 104 insertions(+), 11 deletions(-) diff --git a/packages/field/src/messages.ts b/packages/field/src/messages.ts index 909eb74..4bc87fa 100644 --- a/packages/field/src/messages.ts +++ b/packages/field/src/messages.ts @@ -7,15 +7,18 @@ 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. +// 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'; - default: + case 'unit': return 'bad_unit'; + default: + throw new RangeError(`The core reported a segment error for ${String(segment)}.`); } } @@ -54,7 +57,7 @@ export function fill( values: Readonly>>, ): string { return template.replace(PLACEHOLDER, (placeholder, name: string) => { - const value = values[name]; + 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 index 6f2fd96..f4e7fb1 100644 --- a/packages/field/src/reading.ts +++ b/packages/field/src/reading.ts @@ -23,12 +23,18 @@ export type Reading = // 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 LONG_TEXT = 64; +const INPUT_LIMIT = 64; -// The grammar counts code points, not UTF-16 units, so Array.from splits the text. +// 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 = Array.from(text).length; - return codePoints > LONG_TEXT ? codePoints : Array.from(normalize(text)).length; + 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 diff --git a/packages/field/test/messages.test.ts b/packages/field/test/messages.test.ts index f4bd64c..5a448d7 100644 --- a/packages/field/test/messages.test.ts +++ b/packages/field/test/messages.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from 'vitest'; import { fill, keyForReading } from '../src/messages.js'; -import { readText } from '../src/reading.js'; +import { readText, type Reading } from '../src/reading.js'; describe('keyForReading', () => { it.each([ @@ -19,6 +19,17 @@ describe('keyForReading', () => { ] 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', () => { @@ -35,4 +46,8 @@ describe('fill', () => { 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 index 659d73f..f5aa73c 100644 --- a/packages/field/test/reading.test.ts +++ b/packages/field/test/reading.test.ts @@ -45,4 +45,20 @@ describe('readText', () => { 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/scripts/generate-messages.mjs b/scripts/generate-messages.mjs index 3671c18..3c69a30 100644 --- a/scripts/generate-messages.mjs +++ b/scripts/generate-messages.mjs @@ -45,6 +45,9 @@ function readMessages() { 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]]); } @@ -84,9 +87,24 @@ if (unknown !== undefined) { process.exit(2); } -const text = render(readMessages()); +// 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 (readFileSync(target, 'utf8') !== text) { + if (readCurrent() !== text) { process.stderr.write( 'packages/field/src/spec-messages.ts is out of date. Run pnpm generate.\n', ); diff --git a/scripts/generate-messages.test.mjs b/scripts/generate-messages.test.mjs index 10017e1..26ec6be 100644 --- a/scripts/generate-messages.test.mjs +++ b/scripts/generate-messages.test.mjs @@ -101,11 +101,46 @@ describe('generate-messages.mjs', () => { writeFileSync(generatedFile(root), NOT_GENERATED); const { status, stderr } = generate(root); assert.equal(status, 1); - assert.ok(stderr.includes(problem), stderr); + 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); From d34b34dc611b42f2c30a0d93b73c9256f98b754c Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 15:25:03 +0100 Subject: [PATCH 05/23] fix(field): submit like a native input, and keep the live region quiet Enter now follows the implicit submission of a native text input. The form gets the name and value of its default button, a disabled default button stops the submit, and a form with no submit button and a second text field does not submit. The view writes a text only when it differs, so a screen reader hears each new message once. The input carries aria-required when the field is required. The change source comes from the input event itself, a blur of the window no longer shows an error, and a restored text counts as edited. The public type names HTMLElement as its base, with no internal base class. Signed-off-by: MobileMage --- packages/field/etc/field.api.md | 23 ++-- packages/field/src/element.ts | 148 ++++++++++++++++++-- packages/field/src/view.ts | 20 ++- packages/field/test/element.test.ts | 201 ++++++++++++++++++++++++++-- packages/field/test/styles.test.ts | 10 ++ 5 files changed, 367 insertions(+), 35 deletions(-) diff --git a/packages/field/etc/field.api.md b/packages/field/etc/field.api.md index 86b503e..cd89c66 100644 --- a/packages/field/etc/field.api.md +++ b/packages/field/etc/field.api.md @@ -23,30 +23,33 @@ export type MessageKey = "label" | "hint" | "empty" | "bad_character" | "bad_len // @public export type Messages = Readonly>; -// Warning: (ae-forgotten-export) The symbol "ElementBase" needs to be exported by the entry point index.d.ts -// // @public -export class PostcodeFieldElement extends ElementBase { - constructor(); +export interface PostcodeFieldElement extends HTMLElement { attributeChangedCallback(name: string): void; checkValidity(): boolean; connectedCallback(): void; - get form(): HTMLFormElement | null; - static readonly formAssociated = true; + readonly form: HTMLFormElement | null; formDisabledCallback(disabled: boolean): void; formResetCallback(): void; formStateRestoreCallback(state: unknown): void; get messages(): Messages; set messages(messages: Partial); - static readonly observedAttributes: readonly string[]; reportValidity(): boolean; - get validationMessage(): string; - get validity(): ValidityState; + readonly validationMessage: string; + readonly validity: ValidityState; get value(): string; set value(text: string); - get willValidate(): boolean; + 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/src/element.ts b/packages/field/src/element.ts index eb8a079..42d66a9 100644 --- a/packages/field/src/element.ts +++ b/packages/field/src/element.ts @@ -19,6 +19,29 @@ const CHECKED_ATTRIBUTES = new Set(['required', 'legacy']); 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. @@ -31,7 +54,77 @@ const ElementBase: typeof HTMLElement = * * ``` */ -export class PostcodeFieldElement extends ElementBase { +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; + + /** + * 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; @@ -43,7 +136,6 @@ export class PostcodeFieldElement extends ElementBase { #messages: Messages = ENGLISH; #reading: Reading = { kind: 'empty' }; #value = ''; - #source: ChangeSource = 'typed'; #connected = false; #dirty = false; #errorsShown = false; @@ -54,18 +146,19 @@ export class PostcodeFieldElement extends ElementBase { this.#internals = this.attachInternals(); this.#view = buildView(this.attachShadow({ mode: 'open', delegatesFocus: true })); const { input, suggestion } = this.#view; - input.addEventListener('beforeinput', (event) => { - this.#source = event.inputType === 'insertFromPaste' ? 'pasted' : 'typed'; - }); - input.addEventListener('input', () => { - this.#update(this.#source, null); + input.addEventListener('input', (event) => { + const pasted = event instanceof InputEvent && event.inputType === 'insertFromPaste'; + this.#update(pasted ? 'pasted' : 'typed', null); }); input.addEventListener('blur', () => { - this.#leave(); + // 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.#internals.form?.requestSubmit(); + this.#submit(); } }); suggestion.addEventListener('click', () => { @@ -196,6 +289,7 @@ export class PostcodeFieldElement extends ElementBase { */ formStateRestoreCallback(state: unknown): void { if (typeof state === 'string') { + this.#dirty = true; this.#view.input.value = state; this.#refresh(); } @@ -256,6 +350,31 @@ export class PostcodeFieldElement extends ElementBase { 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.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) { @@ -301,6 +420,7 @@ export class PostcodeFieldElement extends ElementBase { 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), @@ -309,3 +429,13 @@ export class PostcodeFieldElement extends ElementBase { }); } } + +/** 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/view.ts b/packages/field/src/view.ts index dd5d88a..c542365 100644 --- a/packages/field/src/view.ts +++ b/packages/field/src/view.ts @@ -78,6 +78,7 @@ export function buildView(root: ShadowRoot): View { export interface Display { readonly state: State; readonly invalid: boolean; + readonly required: boolean; readonly rejected: boolean; readonly label: string; readonly hint: string; @@ -86,6 +87,14 @@ export interface Display { readonly useSuggestion: string; } +// 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. @@ -97,12 +106,13 @@ export interface Display { export function renderView(view: View, display: Display): void { view.field.dataset['state'] = display.state; view.field.toggleAttribute('data-rejected', display.rejected); - view.label.textContent = display.label; - view.hint.textContent = display.hint; + setText(view.label, display.label); + setText(view.hint, display.hint); view.input.setAttribute('aria-invalid', String(display.invalid)); - view.note.textContent = display.note; + view.input.setAttribute('aria-required', String(display.required)); + setText(view.note, display.note); view.didYouMean.hidden = display.didYouMean === null; - view.didYouMean.textContent = display.didYouMean ?? ''; + setText(view.didYouMean, display.didYouMean ?? ''); view.suggestion.hidden = display.didYouMean === null; - view.suggestion.textContent = display.useSuggestion; + setText(view.suggestion, display.useSuggestion); } diff --git a/packages/field/test/element.test.ts b/packages/field/test/element.test.ts index b935d0a..5a31ccf 100644 --- a/packages/field/test/element.test.ts +++ b/packages/field/test/element.test.ts @@ -10,6 +10,18 @@ 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); @@ -80,20 +92,27 @@ describe('the field in a plain form', () => { expect(field.shadowRoot?.activeElement).toBe(input); }); - it('takes a pasted postcode with en dashes, no-break spaces and full-width letters', () => { + 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); // U+FF26 and U+FF23 are the full-width letters F and C. const wide = String.fromCodePoint(0xff26, 0xff23); - const { field, input } = mount(FIELD); + // 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${space}01 Z99 ZZ 01`, `${wide}01Z99ZZ01`, + mixed, ]) { - input.value = text; - input.dispatchEvent(new InputEvent('input', { inputType: 'insertFromPaste' })); - expect(field.value).toBe('FC-01-Z99-ZZ-01'); + await userEvent.clear(input); + await pasteInto(input, text); + expect(formValue(form)).toBe('FC-01-Z99-ZZ-01'); + expect(changes.at(-1)?.source).toBe('pasted'); } }); @@ -105,28 +124,109 @@ describe('the field in a plain form', () => { '', ); const [billing, delivery] = Array.from(form.querySelectorAll('gatepost-postcode-field')); - await userEvent.type(billing!.shadowRoot!.querySelector('input')!, 'FC01Z99ZZ01'); - await userEvent.type(delivery!.shadowRoot!.querySelector('input')!, 'FC01Z99ZZ02'); + 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('FC-01-Z99-ZZ-02'); - expect(delivery!.shadowRoot!.querySelector('label')?.textContent).toBe('Delivery postcode'); + 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.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertFromPaste' })); 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 01'); + 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}` + + '', + ); + 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}`, + ); + 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); @@ -217,6 +317,74 @@ describe('the field and its form', () => { 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', () => { @@ -234,6 +402,17 @@ describe('accessibility', () => { 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(`${FIELD}`); form.querySelector('button')?.focus(); diff --git a/packages/field/test/styles.test.ts b/packages/field/test/styles.test.ts index a7ddf8b..24a9481 100644 --- a/packages/field/test/styles.test.ts +++ b/packages/field/test/styles.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import { mount } from './field.js'; // The relative luminance and the contrast ratio of WCAG 2.2, for colours written #rrggbb. @@ -47,7 +48,16 @@ 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); + }); }); From 531846e3de4d4e91c35f0314f497848ce658884d Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 15:32:44 +0100 Subject: [PATCH 06/23] feat(field): check each whole postcode with NIPOST when a key is set A page with a publishable key can now show the user that NIPOST knows the postcode, and where it is. The field sends nothing without a key, and it refuses a secret key, because anyone can read a key in a page. A failed or negative answer never blocks the form, because the gateway's data is new and the server checks the postcode again. The field remembers only a lookup that the gateway answered, so a failed lookup is tried again and a settled one is not sent twice. The house address of a level 2 answer never shows, because a user can type any postcode. Signed-off-by: MobileMage --- .changeset/field-lookups.md | 5 + packages/field/etc/field.api.md | 15 ++ packages/field/src/element.ts | 193 ++++++++++++++++++++-- packages/field/src/index.ts | 8 +- packages/field/src/state.ts | 48 ++++++ packages/field/src/types.ts | 24 +++ packages/field/test/gateway.ts | 50 ++++++ packages/field/test/lookup.test.ts | 249 +++++++++++++++++++++++++++++ 8 files changed, 582 insertions(+), 10 deletions(-) create mode 100644 .changeset/field-lookups.md create mode 100644 packages/field/test/gateway.ts create mode 100644 packages/field/test/lookup.test.ts diff --git a/.changeset/field-lookups.md b/.changeset/field-lookups.md new file mode 100644 index 0000000..e6744e6 --- /dev/null +++ b/.changeset/field-lookups.md @@ -0,0 +1,5 @@ +--- +'@gatepost/field': minor +--- + +With a publishable key, the field 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. diff --git a/packages/field/etc/field.api.md b/packages/field/etc/field.api.md index cd89c66..1ed9f30 100644 --- a/packages/field/etc/field.api.md +++ b/packages/field/etc/field.api.md @@ -4,7 +4,9 @@ ```ts +import { LookupResult } from '@gatepost/client'; import { Postcode } from '@gatepost/core'; +import { PostcodeErrorCode } from '@gatepost/client'; // @public export interface ChangeDetail { @@ -17,6 +19,19 @@ export interface ChangeDetail { // @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"; diff --git a/packages/field/src/element.ts b/packages/field/src/element.ts index 42d66a9..7b80bf5 100644 --- a/packages/field/src/element.ts +++ b/packages/field/src/element.ts @@ -1,18 +1,49 @@ // 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 { fill } from './messages.js'; import { readText, type Reading } from './reading.js'; import { ENGLISH, type Messages } from './spec-messages.js'; -import { readingNote, readingState, type Note, type State } from './state.js'; -import type { ChangeDetail, ChangeSource } from './types.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; } -// A change of these attributes changes the validity. The others change the view. -const CHECKED_ATTRIBUTES = new Set(['required', 'legacy']); +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']); + +/** The three values that identify a lookup: the postcode, the level and the key. */ +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 + ); +} // 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. @@ -44,7 +75,8 @@ function isSubmitButton(element: Element): element is HTMLButtonElement | HTMLIn /** * The postcode field, ``. A plain HTML form submits its form value: the - * canonical form of the postcode. It checks the format offline. + * 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 @@ -129,7 +161,15 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { static readonly formAssociated = true; /** The attributes that change the field when a page sets them. */ - static readonly observedAttributes: readonly string[] = ['label', 'value', 'required', 'legacy']; + static readonly observedAttributes: readonly string[] = [ + 'label', + 'value', + 'required', + 'api-key', + 'base-url', + 'confirm', + 'legacy', + ]; readonly #internals: ElementInternals; readonly #view: View; @@ -139,6 +179,14 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { #connected = false; #dirty = false; #errorsShown = false; + #outcome: Outcome | null = null; + #client: PostcodeClient | null = null; + #keyRefused = false; + #work: AbortController | null = null; + // The lookup in progress, and the last one that the gateway answered. + #asked: Asked | null = null; + #remembered: Asked | null = null; + #lastText = ''; /** Builds the shadow tree. The browser calls it when it creates the element. */ constructor() { @@ -239,9 +287,21 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { /** Reads the settings and checks the text. The browser calls it on insertion. */ connectedCallback(): void { 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.#work?.abort(); + this.#work = null; + this.#asked = null; + this.#remembered = null; + this.#outcome = null; + this.#render(); + } + /** * Applies a changed attribute. The browser calls it. * @@ -255,6 +315,9 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { if (!this.#connected) { return; } + if (name === 'api-key' || name === 'base-url') { + this.#makeClient(); + } if (defaultText || CHECKED_ATTRIBUTES.has(name)) { this.#refresh(); } else { @@ -299,6 +362,32 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { 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; + } + + #makeClient(): void { + const apiKey = this.getAttribute('api-key'); + const baseUrl = this.getAttribute('base-url'); + this.#client = null; + this.#keyRefused = false; + if (apiKey === null || 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.'); + } + } + #update(source: ChangeSource, accuracyM: number | null): void { const before = this.#value; this.#dirty = true; @@ -309,9 +398,21 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { } } - // The text or a setting changed: read the text again. + // 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.getAttribute('api-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') { @@ -323,9 +424,32 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { } this.#internals.setFormValue(this.#value, text); this.#setValidity(); + this.#startLookup(reading, textChanged); this.#render(); } + // A lookup that matches the one in progress or the remembered one stays. Any other change + // cancels the lookup in progress and forgets its outcome, except a location outcome, which + // lasts until the text changes. + #startLookup(reading: Reading, textChanged: boolean): void { + const state = this.#outcome?.state; + if ((state === 'coarse' || state === 'no-location') && !textChanged) { + return; + } + const wanted = this.#wanted(reading); + if (sameAsked(wanted, this.#asked) || sameAsked(wanted, this.#remembered)) { + return; + } + this.#work?.abort(); + this.#work = null; + this.#asked = null; + this.#remembered = null; + this.#outcome = null; + if (wanted !== null && reading.kind === 'postcode') { + void this.#lookUp(reading.postcode, wanted); + } + } + #setValidity(): void { const reading = this.#reading; const { input } = this.#view; @@ -384,6 +508,57 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { } } + async #lookUp(postcode: Postcode, asked: Asked): Promise { + if (this.#keyRefused) { + this.#outcome = { state: 'error', note: { key: 'secret_key' } }; + this.#raise('gatepost-error', { code: 'secret_key' }); + return; + } + if (this.#client === null) { + return; + } + const work = this.#startWork({ state: 'checking', note: { key: 'checking' } }); + this.#asked = asked; + try { + const lookup = await this.#client.lookup(postcode, { + level: asked.level, + signal: work.signal, + }); + if (work.signal.aborted) { + return; + } + this.#remembered = asked; + this.#finish(lookupOutcome(lookup)); + if (lookup.valid) { + this.#raise('gatepost-confirm', { lookup }); + } + } catch (error) { + if (work.signal.aborted) { + return; + } + if (!(error instanceof PostcodeError)) { + throw error; + } + this.#finish({ state: 'error', note: { key: 'check_failed' } }); + this.#raise('gatepost-error', { code: error.code }); + } + } + + #startWork(outcome: Outcome): AbortController { + this.#work?.abort(); + const work = new AbortController(); + this.#work = work; + this.#outcome = outcome; + return work; + } + + #finish(outcome: Outcome): void { + this.#work = null; + this.#asked = null; + this.#outcome = 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 })); @@ -395,14 +570,14 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { #state(): State { const required = this.hasAttribute('required'); - return readingState(this.#reading, this.#errorsShown, required); + return this.#outcome?.state ?? readingState(this.#reading, this.#errorsShown, required); } #noteText(state: State): string { if (state === 'idle' || state === 'typing') { return ''; } - return this.#text(readingNote(this.#reading, this.#legacy)); + return this.#text(this.#outcome?.note ?? readingNote(this.#reading, this.#legacy)); } #suggestionText(state: State): string | null { diff --git a/packages/field/src/index.ts b/packages/field/src/index.ts index 5399d83..740e649 100644 --- a/packages/field/src/index.ts +++ b/packages/field/src/index.ts @@ -12,7 +12,13 @@ import { PostcodeFieldElement } from './element.js'; */ export { PostcodeFieldElement }; export type { MessageKey, Messages } from './spec-messages.js'; -export type { ChangeDetail, ChangeSource } from './types.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 diff --git a/packages/field/src/state.ts b/packages/field/src/state.ts index 8874edc..42475e5 100644 --- a/packages/field/src/state.ts +++ b/packages/field/src/state.ts @@ -1,5 +1,6 @@ // 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'; @@ -34,6 +35,16 @@ export interface Note { 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. @@ -79,3 +90,40 @@ export function readingNote(reading: Reading, legacy: 'accept' | 'reject'): Note } 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/types.ts b/packages/field/src/types.ts index 1b2d716..d741299 100644 --- a/packages/field/src/types.ts +++ b/packages/field/src/types.ts @@ -1,5 +1,6 @@ // 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'; /** @@ -22,3 +23,26 @@ export interface ChangeDetail { /** 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/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/lookup.test.ts b/packages/field/test/lookup.test.ts new file mode 100644 index 0000000..97138f0 --- /dev/null +++ b/packages/field/test/lookup.test.ts @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +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); + 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'); + }); + + 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 { input, message } = mount(FIELD); + 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'); + }); + + it('sends no request when it has no key', async () => { + const fetch = vi.spyOn(globalThis, 'fetch'); + const { input } = mount(''); + await userEvent.type(input, 'FC01Z99ZZ01'); + await userEvent.tab(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('sends no lookup when confirm is none, or for a legacy postcode', async () => { + const fetch = vi.spyOn(globalThis, 'fetch'); + 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(fetch).not.toHaveBeenCalled(); + }); + + 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('sends no new lookup when only the gateway address changes', async () => { + const { requests } = scriptGateway(await fixture('lookup/valid-level-1')); + const { field, input, message } = mount(FIELD); + await userEvent.type(input, 'FC01Z99ZZ01'); + await vi.waitFor(() => { + expect(message()).toBe('We found this postcode in Federal Capital Territory.'); + }); + field.setAttribute('base-url', 'http://127.0.0.1:4010'); + await Promise.resolve(); + expect(requests).toHaveLength(1); + expect(message()).toBe('We found this postcode in Federal Capital Territory.'); + }); +}); + +describe('a secret key in the page', () => { + it('sends no request, shows the state error, logs one error and raises secret_key', async () => { + const fetch = vi.spyOn(globalThis, 'fetch'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const form = document.createElement('form'); + document.body.append(form); + const errors = listen(form, 'gatepost-error'); + form.innerHTML = + ''; + const field = form.querySelector('gatepost-postcode-field'); + const input = field?.shadowRoot?.querySelector('input'); + await userEvent.type(input!, 'FC01Z99ZZ01'); + expect(field?.shadowRoot?.querySelector('#note')?.textContent).toBe( + 'We cannot check postcodes on this page. You can still continue.', + ); + expect(errors).toEqual([{ code: 'secret_key' }]); + expect(consoleError).toHaveBeenCalledOnce(); + expect(String(consoleError.mock.calls[0]?.[0])).not.toContain('nipost_live_abc'); + expect(fetch).not.toHaveBeenCalled(); + }); +}); From 6459e429a3e5cd2703ea47db28a3bbed4e4b3b39 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 15:34:33 +0100 Subject: [PATCH 07/23] fix(field): ignore a default button in a disabled fieldset A submit button inside a disabled fieldset reads as enabled through its disabled property, so Enter in the field submitted the form. A native input does nothing there, because the button is disabled by its fieldset. The field now asks the browser with the :disabled selector. The paste test also covers the minus sign, which the core already maps to a hyphen. Signed-off-by: MobileMage --- packages/field/src/element.ts | 2 +- packages/field/test/element.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/field/src/element.ts b/packages/field/src/element.ts index 7b80bf5..b9ee250 100644 --- a/packages/field/src/element.ts +++ b/packages/field/src/element.ts @@ -484,7 +484,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { const elements = Array.from(form.elements); const button = elements.find(isSubmitButton); if (button !== undefined) { - if (!button.disabled) { + if (!button.matches(':disabled')) { form.requestSubmit(button); } return; diff --git a/packages/field/test/element.test.ts b/packages/field/test/element.test.ts index 5a31ccf..d22e345 100644 --- a/packages/field/test/element.test.ts +++ b/packages/field/test/element.test.ts @@ -95,6 +95,7 @@ describe('the field in a plain form', () => { 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. @@ -105,6 +106,7 @@ describe('the field in a plain form', () => { 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, @@ -205,6 +207,15 @@ describe('Enter in the input', () => { expect(submitters).toEqual([]); }); + it('does nothing when the default button sits in a disabled fieldset', async () => { + const { form, input } = mount( + `${FIELD}
`, + ); + 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); From f9c9e7b2ad75f41832ae2e5cb9cb84862fc425e6 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 15:40:26 +0100 Subject: [PATCH 08/23] feat(field): fill in the postcode of the user's location on request Many users do not know their new postcode yet, so the field can ask the device where it is and the gateway which unit is there. It asks only when the user presses its button. A fix that is not precise enough for a building fills in only the first segments, and the user types the rest, so the field never guesses a wrong unit. A refused or missing location leaves the input ready for typing. A change of a setting never cancels the request, and the gateway call uses the key that the field holds when the position arrives. The field never shows the address of the unit that the gateway found. Signed-off-by: MobileMage --- .changeset/field-location.md | 5 + packages/field/src/element.ts | 83 ++++- packages/field/src/location.ts | 129 ++++++++ packages/field/src/view.ts | 10 +- packages/field/test/field.ts | 2 + packages/field/test/location.test.ts | 455 +++++++++++++++++++++++++++ 6 files changed, 675 insertions(+), 9 deletions(-) create mode 100644 .changeset/field-location.md create mode 100644 packages/field/src/location.ts create mode 100644 packages/field/test/location.test.ts diff --git a/.changeset/field-location.md b/.changeset/field-location.md new file mode 100644 index 0000000..8999299 --- /dev/null +++ b/.changeset/field-location.md @@ -0,0 +1,5 @@ +--- +'@gatepost/field': minor +--- + +With `gps` and a key, the field 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/packages/field/src/element.ts b/packages/field/src/element.ts index b9ee250..b50de68 100644 --- a/packages/field/src/element.ts +++ b/packages/field/src/element.ts @@ -2,6 +2,7 @@ // 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 { ENGLISH, type Messages } from './spec-messages.js'; @@ -168,6 +169,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { 'api-key', 'base-url', 'confirm', + 'gps', 'legacy', ]; @@ -193,7 +195,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { super(); this.#internals = this.attachInternals(); this.#view = buildView(this.attachShadow({ mode: 'open', delegatesFocus: true })); - const { input, suggestion } = this.#view; + const { input, suggestion, location } = this.#view; input.addEventListener('input', (event) => { const pasted = event instanceof InputEvent && event.inputType === 'insertFromPaste'; this.#update(pasted ? 'pasted' : 'typed', null); @@ -212,6 +214,9 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { suggestion.addEventListener('click', () => { this.#useSuggestion(); }); + location.addEventListener('click', () => { + void this.#locate(); + }); this.addEventListener('invalid', () => { this.#errorsShown = true; this.#render(); @@ -339,7 +344,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { * @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]) { + for (const control of [this.#view.input, this.#view.suggestion, this.#view.location]) { control.disabled = disabled; } } @@ -429,11 +434,10 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { } // A lookup that matches the one in progress or the remembered one stays. Any other change - // cancels the lookup in progress and forgets its outcome, except a location outcome, which - // lasts until the text changes. + // cancels the lookup in progress and forgets its outcome, except a location request and its + // outcome, which last until the text changes. A location request in progress starts no lookup. #startLookup(reading: Reading, textChanged: boolean): void { - const state = this.#outcome?.state; - if ((state === 'coarse' || state === 'no-location') && !textChanged) { + if (!textChanged && this.#locationOutcome()) { return; } const wanted = this.#wanted(reading); @@ -450,6 +454,12 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { } } + // 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'; + } + #setValidity(): void { const reading = this.#reading; const { input } = this.#view; @@ -544,6 +554,58 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { } } + // 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.#startWork({ state: 'locating', note: { key: 'locating' } }); + this.#asked = null; + this.#remembered = null; + 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.#endRequest(); + 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.#endRequest(); + input.value = whole ? postcode.display : `${postcode.display} `; + this.#update('gps', accuracyM); + if (!whole) { + this.#finish({ state: 'coarse', note: { key: 'gps_coarse' } }); + input.focus(); + } + } + #startWork(outcome: Outcome): AbortController { this.#work?.abort(); const work = new AbortController(); @@ -552,9 +614,14 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { return work; } - #finish(outcome: Outcome): void { + #endRequest(): void { this.#work = null; this.#asked = null; + this.#outcome = null; + } + + #finish(outcome: Outcome): void { + this.#endRequest(); this.#outcome = outcome; this.#render(); } @@ -591,6 +658,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { #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, @@ -601,6 +669,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { note: this.#noteText(state), didYouMean: this.#suggestionText(state), useSuggestion: this.#messages.use_suggestion, + useLocation: showLocation ? this.#messages.use_location : null, }); } } diff --git a/packages/field/src/location.ts b/packages/field/src/location.ts new file mode 100644 index 0000000..76bce86 --- /dev/null +++ b/packages/field/src/location.ts @@ -0,0 +1,129 @@ +// 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); +} + +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) { + if (!(error instanceof GeolocationPositionError)) { + throw error; + } + const key = error.code === error.PERMISSION_DENIED ? '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) { + if (!(error instanceof PostcodeError)) { + throw error; + } + return { kind: 'failed', key: 'gps_unavailable', code: error.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/view.ts b/packages/field/src/view.ts index c542365..dfb1f52 100644 --- a/packages/field/src/view.ts +++ b/packages/field/src/view.ts @@ -16,10 +16,11 @@ export interface View { readonly note: HTMLElement; readonly didYouMean: HTMLElement; readonly suggestion: HTMLButtonElement; + readonly location: HTMLButtonElement; } -// The DOM order is the visual order and the tab order. The live region holds the two messages, -// and not the button. +// 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. const TEMPLATE = `
@@ -32,6 +33,7 @@ const TEMPLATE = `
+ `; let sheet: CSSStyleSheet | undefined; @@ -67,6 +69,7 @@ export function buildView(root: ShadowRoot): View { note: find(root, '#note', HTMLParagraphElement), didYouMean: find(root, '#did-you-mean', HTMLParagraphElement), suggestion: find(root, '#suggestion', HTMLButtonElement), + location: find(root, '#location', HTMLButtonElement), }; } @@ -85,6 +88,7 @@ export interface Display { 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 @@ -115,4 +119,6 @@ export function renderView(view: View, display: Display): void { 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/field.ts b/packages/field/test/field.ts index a7494e4..a9ded81 100644 --- a/packages/field/test/field.ts +++ b/packages/field/test/field.ts @@ -11,6 +11,7 @@ export interface Mounted { 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. */ @@ -55,6 +56,7 @@ export function mount(html: string): Mounted { 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, }; diff --git a/packages/field/test/location.test.ts b/packages/field/test/location.test.ts new file mode 100644 index 0000000..7dacc77 --- /dev/null +++ b/packages/field/test/location.test.ts @@ -0,0 +1,455 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import 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 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); + }); +} + +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('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 Promise.resolve(); + 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('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 Promise.resolve(); + await Promise.resolve(); + 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); + }); +}); From 7f4e6ecce981233caf17ab0262c2e5b5b1027192 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 15:45:24 +0100 Subject: [PATCH 09/23] fix(field): end every failed call as a failed request A base-url without a scheme, or a client that threw a plain error, left the field in the state checking or GPS locating with no request behind it, and a lookup kept that way blocked every retry. Any failure of a lookup now shows the error state with the check failed message, and the field does not remember the lookup. Any failure of the device call or of reverse ends the request the same way. Only an error of the client has a code, so only it raises gatepost-error. The secret key error now comes after the render and the change event of the same keystroke. The tests also cover the late answer of a cancelled lookup, a base-url change that must not retry, and fetch spies that can no longer reach the real gateway. Signed-off-by: MobileMage --- packages/field/src/element.ts | 13 +- packages/field/src/location.ts | 11 +- packages/field/test/location.test.ts | 44 +++++- packages/field/test/lookup.test.ts | 192 +++++++++++++++++++++++---- 4 files changed, 223 insertions(+), 37 deletions(-) diff --git a/packages/field/src/element.ts b/packages/field/src/element.ts index b50de68..67dd796 100644 --- a/packages/field/src/element.ts +++ b/packages/field/src/element.ts @@ -521,7 +521,10 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { async #lookUp(postcode: Postcode, asked: Asked): Promise { if (this.#keyRefused) { this.#outcome = { state: 'error', note: { key: 'secret_key' } }; - this.#raise('gatepost-error', { code: '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) { @@ -546,11 +549,11 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { if (work.signal.aborted) { return; } - if (!(error instanceof PostcodeError)) { - throw error; - } + // Any failure ends the request. Only an error of the client has a code for the event. this.#finish({ state: 'error', note: { key: 'check_failed' } }); - this.#raise('gatepost-error', { code: error.code }); + if (error instanceof PostcodeError) { + this.#raise('gatepost-error', { code: error.code }); + } } } diff --git a/packages/field/src/location.ts b/packages/field/src/location.ts index 76bce86..f175770 100644 --- a/packages/field/src/location.ts +++ b/packages/field/src/location.ts @@ -98,7 +98,8 @@ export async function findFix( position = await currentPosition(); } catch (error) { if (!(error instanceof GeolocationPositionError)) { - throw error; + // Any other failure of the device call ends the request too, and it has no event code. + return { kind: 'failed', key: 'gps_unavailable', code: null }; } const key = error.code === error.PERMISSION_DENIED ? 'gps_denied' : 'gps_unavailable'; return { kind: 'failed', key, code: key }; @@ -116,10 +117,10 @@ export async function findFix( signal, }); } catch (error) { - if (!(error instanceof PostcodeError)) { - throw error; - } - return { kind: 'failed', key: 'gps_unavailable', code: error.code }; + // A cancelled call is the one failure that ends with no outcome. + signal.throwIfAborted(); + const code = error instanceof PostcodeError ? error.code : null; + return { kind: 'failed', key: 'gps_unavailable', code }; } const postcode = postcodeAtFix(result, accuracy); if (postcode === null) { diff --git a/packages/field/test/location.test.ts b/packages/field/test/location.test.ts index 7dacc77..90a74e3 100644 --- a/packages/field/test/location.test.ts +++ b/packages/field/test/location.test.ts @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 -import type { ReverseResult } from '@gatepost/client'; +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'; @@ -12,6 +12,7 @@ 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(() => { @@ -275,6 +276,47 @@ describe('the location button', () => { 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([]); + 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([]); + }); + + 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([]); + }); + it('shows the state GPS locating, and typing cancels it', async () => { const place = holdDevice(); const fetch = vi.spyOn(globalThis, 'fetch'); diff --git a/packages/field/test/lookup.test.ts b/packages/field/test/lookup.test.ts index 97138f0..be2dfcf 100644 --- a/packages/field/test/lookup.test.ts +++ b/packages/field/test/lookup.test.ts @@ -1,5 +1,7 @@ // 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'; @@ -65,6 +67,8 @@ describe('lookups', () => { 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( @@ -73,6 +77,7 @@ describe('lookups', () => { }); 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 () => { @@ -107,7 +112,9 @@ describe('lookups', () => { new Promise(() => undefined), await fixture('lookup/not-found'), ); - const { input, message } = mount(FIELD); + 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); @@ -118,18 +125,26 @@ describe('lookups', () => { }); 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', async () => { - const fetch = vi.spyOn(globalThis, 'fetch'); - const { input } = mount(''); - await userEvent.type(input, 'FC01Z99ZZ01'); - await userEvent.tab(); - expect(fetch).not.toHaveBeenCalled(); + 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 fetch = vi.spyOn(globalThis, 'fetch'); + const { requests } = scriptGateway(); const { input, message } = mount( ``, ); @@ -137,7 +152,7 @@ describe('lookups', () => { expect(message()).toBe('This postcode is in Federal Capital Territory.'); const legacy = mount(FIELD); await userEvent.type(legacy.input, '900108'); - expect(fetch).not.toHaveBeenCalled(); + expect(requests).toEqual([]); }); it('has no WCAG 2.2 AA violation while checking, confirmed, not found and failed', async () => { @@ -212,38 +227,163 @@ describe('what the field remembers', () => { expect(requests).toHaveLength(2); }); - it('sends no new lookup when only the gateway address changes', async () => { - const { requests } = scriptGateway(await fixture('lookup/valid-level-1')); + 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'); - await Promise.resolve(); + 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'); + await userEvent.type(input, 'FC01Z99ZZ01'); + await vi.waitFor(() => { + expect(message()).toBe(FAILED); + }); + 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([]); + 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); - expect(message()).toBe('We found this postcode in Federal Capital Territory.'); }); }); 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 fetch = vi.spyOn(globalThis, 'fetch'); + const { requests } = scriptGateway(); const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const form = document.createElement('form'); - document.body.append(form); - const errors = listen(form, 'gatepost-error'); - form.innerHTML = - ''; - const field = form.querySelector('gatepost-postcode-field'); - const input = field?.shadowRoot?.querySelector('input'); - await userEvent.type(input!, 'FC01Z99ZZ01'); - expect(field?.shadowRoot?.querySelector('#note')?.textContent).toBe( - 'We cannot check postcodes on this page. You can still continue.', + const { form, field, input, message, changes } = mount( + '', ); - expect(errors).toEqual([{ code: 'secret_key' }]); + 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(fetch).not.toHaveBeenCalled(); + 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'); + } }); }); From 8a6d809fe68c0c467017a605674b55f094188d26 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 15:47:53 +0100 Subject: [PATCH 10/23] fix(field): raise network_error for an error that is not the client's A lookup or a reverse call that throws an error of another kind, such as a base address with no scheme, now raises gatepost-error with the code network_error, as the failure table of the spec says. The state and the message stay the same. The spec moves to the commit that adds the two rows. Signed-off-by: MobileMage --- packages/field/src/element.ts | 8 ++++---- packages/field/src/location.ts | 2 +- packages/field/test/location.test.ts | 4 ++-- packages/field/test/lookup.test.ts | 4 +++- spec | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/field/src/element.ts b/packages/field/src/element.ts index 67dd796..2f1fed9 100644 --- a/packages/field/src/element.ts +++ b/packages/field/src/element.ts @@ -549,11 +549,11 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { if (work.signal.aborted) { return; } - // Any failure ends the request. Only an error of the client has a code for the event. + // 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' } }); - if (error instanceof PostcodeError) { - this.#raise('gatepost-error', { code: error.code }); - } + this.#raise('gatepost-error', { + code: error instanceof PostcodeError ? error.code : 'network_error', + }); } } diff --git a/packages/field/src/location.ts b/packages/field/src/location.ts index f175770..26e08e7 100644 --- a/packages/field/src/location.ts +++ b/packages/field/src/location.ts @@ -119,7 +119,7 @@ export async function findFix( } catch (error) { // A cancelled call is the one failure that ends with no outcome. signal.throwIfAborted(); - const code = error instanceof PostcodeError ? error.code : null; + const code = error instanceof PostcodeError ? error.code : 'network_error'; return { kind: 'failed', key: 'gps_unavailable', code }; } const postcode = postcodeAtFix(result, accuracy); diff --git a/packages/field/test/location.test.ts b/packages/field/test/location.test.ts index 90a74e3..af25261 100644 --- a/packages/field/test/location.test.ts +++ b/packages/field/test/location.test.ts @@ -288,7 +288,7 @@ describe('the location button', () => { await vi.waitFor(() => { expect(message()).toBe(UNAVAILABLE); }); - expect(errors).toEqual([]); + expect(errors).toEqual([{ code: 'network_error' }]); expect(requests).toEqual([]); }); @@ -301,7 +301,7 @@ describe('the location button', () => { await vi.waitFor(() => { expect(message()).toBe(UNAVAILABLE); }); - expect(errors).toEqual([]); + expect(errors).toEqual([{ code: 'network_error' }]); }); it('ends as a failed request when the device call throws a plain error', async () => { diff --git a/packages/field/test/lookup.test.ts b/packages/field/test/lookup.test.ts index be2dfcf..a51d618 100644 --- a/packages/field/test/lookup.test.ts +++ b/packages/field/test/lookup.test.ts @@ -313,10 +313,12 @@ describe('a call that fails', () => { '
', ); 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); @@ -332,7 +334,7 @@ describe('a call that fails', () => { await vi.waitFor(() => { expect(message()).toBe(FAILED); }); - expect(errors).toEqual([]); + expect(errors).toEqual([{ code: 'network_error' }]); expect(field.checkValidity()).toBe(true); field.setAttribute('required', ''); await vi.waitFor(() => { diff --git a/spec b/spec index 394544c..02ee9a9 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 394544c61903b27559e73b34294f6d220390edc0 +Subproject commit 02ee9a9644adb3461880bbd59e8902eb379e8021 From 07aa85efbb8f92c3d875981b087be5ba965adaa9 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 15:53:16 +0100 Subject: [PATCH 11/23] fix(field): end every failed location call, and split the request rules A device that rejects with a plain object, or whose location call throws, now ends the request with the message and the event of a refusal or of a missing location, so the field never stays on "Finding your location". The change event of a coarse fill now comes after the field shows its state. The rules for when a change keeps or cancels a request moved to their own class. The tests wait for the end state, and a test that sends a request without scripting it fails. Signed-off-by: MobileMage --- packages/field/src/element.ts | 112 +++++++-------------------- packages/field/src/location.ts | 12 +-- packages/field/src/requests.ts | 108 ++++++++++++++++++++++++++ packages/field/test/location.test.ts | 104 ++++++++++++++++++++++++- packages/field/test/setup.ts | 23 ++++++ packages/field/vitest.config.ts | 1 + 6 files changed, 269 insertions(+), 91 deletions(-) create mode 100644 packages/field/src/requests.ts create mode 100644 packages/field/test/setup.ts diff --git a/packages/field/src/element.ts b/packages/field/src/element.ts index 2f1fed9..cea6922 100644 --- a/packages/field/src/element.ts +++ b/packages/field/src/element.ts @@ -5,6 +5,7 @@ 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, @@ -29,23 +30,6 @@ const LEVELS = { level1: 1, level2: 2 } as const; // A change of `base-url` alone makes a new client and starts no lookup. const CHECKED_ATTRIBUTES = new Set(['required', 'api-key', 'confirm', 'legacy']); -/** The three values that identify a lookup: the postcode, the level and the key. */ -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 - ); -} - // 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 = @@ -181,13 +165,9 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { #connected = false; #dirty = false; #errorsShown = false; - #outcome: Outcome | null = null; + readonly #requests = new Requests(); #client: PostcodeClient | null = null; #keyRefused = false; - #work: AbortController | null = null; - // The lookup in progress, and the last one that the gateway answered. - #asked: Asked | null = null; - #remembered: Asked | null = null; #lastText = ''; /** Builds the shadow tree. The browser calls it when it creates the element. */ @@ -299,11 +279,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { /** Cancels any request and forgets its outcome. The browser calls it on removal. */ disconnectedCallback(): void { this.#connected = false; - this.#work?.abort(); - this.#work = null; - this.#asked = null; - this.#remembered = null; - this.#outcome = null; + this.#requests.cancel(); this.#render(); } @@ -393,10 +369,12 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { } } - #update(source: ChangeSource, accuracyM: number | null): void { + // `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 }); @@ -433,33 +411,16 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { this.#render(); } - // A lookup that matches the one in progress or the remembered one stays. Any other change - // cancels the lookup in progress and forgets its outcome, except a location request and its - // outcome, which last until the text changes. A location request in progress starts no lookup. + // A change that the requests do not keep cancels them. Then a whole postcode gets a lookup. #startLookup(reading: Reading, textChanged: boolean): void { - if (!textChanged && this.#locationOutcome()) { - return; - } const wanted = this.#wanted(reading); - if (sameAsked(wanted, this.#asked) || sameAsked(wanted, this.#remembered)) { - return; - } - this.#work?.abort(); - this.#work = null; - this.#asked = null; - this.#remembered = null; - this.#outcome = null; - if (wanted !== null && reading.kind === 'postcode') { - void this.#lookUp(reading.postcode, wanted); + if (!this.#requests.keeps(wanted, textChanged) && wanted !== null) { + if (reading.kind === 'postcode') { + void this.#lookUp(reading.postcode, wanted); + } } } - // 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'; - } - #setValidity(): void { const reading = this.#reading; const { input } = this.#view; @@ -520,7 +481,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { async #lookUp(postcode: Postcode, asked: Asked): Promise { if (this.#keyRefused) { - this.#outcome = { state: 'error', note: { key: 'secret_key' } }; + 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' }); @@ -530,8 +491,8 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { if (this.#client === null) { return; } - const work = this.#startWork({ state: 'checking', note: { key: 'checking' } }); - this.#asked = asked; + 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, @@ -540,7 +501,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { if (work.signal.aborted) { return; } - this.#remembered = asked; + this.#requests.remembered = asked; this.#finish(lookupOutcome(lookup)); if (lookup.valid) { this.#raise('gatepost-confirm', { lookup }); @@ -563,9 +524,8 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { if (this.#client === null) { return; } - const work = this.#startWork({ state: 'locating', note: { key: 'locating' } }); - this.#asked = null; - this.#remembered = null; + const work = this.#requests.start({ state: 'locating', note: { key: 'locating' } }); + this.#requests.remembered = null; this.#render(); try { const fix = await findFix(() => this.#client, work.signal); @@ -585,7 +545,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { 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.#endRequest(); + this.#requests.end(); this.#render(); } } catch (error) { @@ -600,32 +560,18 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { #placePostcode(postcode: Postcode, accuracyM: number): void { const { input } = this.#view; const whole = postcode.precision === 'unit'; - this.#endRequest(); + 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(); - } - } - - #startWork(outcome: Outcome): AbortController { - this.#work?.abort(); - const work = new AbortController(); - this.#work = work; - this.#outcome = outcome; - return work; - } - - #endRequest(): void { - this.#work = null; - this.#asked = null; - this.#outcome = null; + this.#update('gps', accuracyM, () => { + if (!whole) { + this.#finish({ state: 'coarse', note: { key: 'gps_coarse' } }); + input.focus(); + } + }); } #finish(outcome: Outcome): void { - this.#endRequest(); - this.#outcome = outcome; + this.#requests.finish(outcome); this.#render(); } @@ -640,14 +586,16 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { #state(): State { const required = this.hasAttribute('required'); - return this.#outcome?.state ?? readingState(this.#reading, this.#errorsShown, 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.#outcome?.note ?? readingNote(this.#reading, this.#legacy)); + return this.#text(this.#requests.outcome?.note ?? readingNote(this.#reading, this.#legacy)); } #suggestionText(state: State): string | null { diff --git a/packages/field/src/location.ts b/packages/field/src/location.ts index 26e08e7..7e851f3 100644 --- a/packages/field/src/location.ts +++ b/packages/field/src/location.ts @@ -49,6 +49,11 @@ export function postcodeAtFix(result: ReverseResult, accuracyM: number): Postcod 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, { @@ -97,11 +102,8 @@ export async function findFix( try { position = await currentPosition(); } catch (error) { - if (!(error instanceof GeolocationPositionError)) { - // Any other failure of the device call ends the request too, and it has no event code. - return { kind: 'failed', key: 'gps_unavailable', code: null }; - } - const key = error.code === error.PERMISSION_DENIED ? 'gps_denied' : 'gps_unavailable'; + const refused = isRefusal(error); + const key = refused ? 'gps_denied' : 'gps_unavailable'; return { kind: 'failed', key, code: key }; } signal.throwIfAborted(); diff --git a/packages/field/src/requests.ts b/packages/field/src/requests.ts new file mode 100644 index 0000000..ee19616 --- /dev/null +++ b/packages/field/src/requests.ts @@ -0,0 +1,108 @@ +// 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 the requests stay. False when the caller can start the wanted lookup. + */ + keeps(wanted: Asked | null, textChanged: boolean): boolean { + if (!textChanged && this.#locationOutcome()) { + return true; + } + if (sameAsked(wanted, this.asked) || sameAsked(wanted, this.remembered)) { + return true; + } + this.cancel(); + return false; + } + + /** 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; + } + + /** 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/test/location.test.ts b/packages/field/test/location.test.ts index af25261..f559704 100644 --- a/packages/field/test/location.test.ts +++ b/packages/field/test/location.test.ts @@ -72,6 +72,13 @@ function refuseLocation(code: number): void { }); } +// 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) => { @@ -314,7 +321,35 @@ describe('the location button', () => { await vi.waitFor(() => { expect(message()).toBe(UNAVAILABLE); }); - expect(errors).toEqual([]); + 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 () => { @@ -326,7 +361,7 @@ describe('the location button', () => { await expectAccessible(); await userEvent.type(input, 'FC'); place(5); - await Promise.resolve(); + await settle(); expect(input.value).toBe('FC'); expect(message()).toBe(''); expect(fetch).not.toHaveBeenCalled(); @@ -345,6 +380,68 @@ describe('the location button', () => { }); }); + 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('turns off with the form', () => { const { location } = mount(`
${FIELD}
`); expect(location.disabled).toBe(true); @@ -415,8 +512,7 @@ describe('a location request and the settings', () => { await userEvent.click(location); field.removeAttribute('api-key'); place(5); - await Promise.resolve(); - await Promise.resolve(); + await settle(); expect(message()).toBe(''); expect(errors).toEqual([]); expect(fetch).not.toHaveBeenCalled(); 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/vitest.config.ts b/packages/field/vitest.config.ts index fea3316..0b11ac5 100644 --- a/packages/field/vitest.config.ts +++ b/packages/field/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ }, test: { include: ['test/**/*.test.ts'], + setupFiles: ['test/setup.ts'], browser: { enabled: true, headless: true, From 0d06a732f658be31e8b387a13d727ca452783929 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 15:58:30 +0100 Subject: [PATCH 12/23] test(field): run the field in a plain form in three browser engines The component tests script the network, so they cannot show that a real page, a real form and the mock server work together. The journeys load the built one-file element into a plain HTML form, submit it with the keyboard, and run axe in each state. They also cover Enter with a disabled default button and the window blur that reads the active element. pnpm check runs them in Chromium, and CI runs them in Firefox and WebKit, the engine of Safari. The README and its examples run in the same way. The README says that the first alpha is not on npm yet, and that a property set before the element is defined is lost. Signed-off-by: MobileMage --- .changeset/field-journeys.md | 2 + .github/workflows/ci.yml | 25 +++ .gitignore | 2 + README.md | 2 + package.json | 5 +- packages/field/README.md | 141 +++++++++++++ packages/field/package.json | 4 + packages/field/playwright.config.ts | 29 +++ packages/field/test/journeys/form.test.ts | 217 ++++++++++++++++++++ packages/field/test/journeys/readme.test.ts | 114 ++++++++++ packages/field/test/journeys/serve.mjs | 71 +++++++ packages/field/vitest.config.ts | 2 + pnpm-lock.yaml | 29 +++ 13 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 .changeset/field-journeys.md create mode 100644 packages/field/README.md create mode 100644 packages/field/playwright.config.ts create mode 100644 packages/field/test/journeys/form.test.ts create mode 100644 packages/field/test/journeys/readme.test.ts create mode 100644 packages/field/test/journeys/serve.mjs diff --git a/.changeset/field-journeys.md b/.changeset/field-journeys.md new file mode 100644 index 0000000..a845151 --- /dev/null +++ b/.changeset/field-journeys.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dff9c31..7916cdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,31 @@ jobs: - 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" + commits: if: github.event_name == 'pull_request' runs-on: ubuntu-24.04 diff --git a/.gitignore b/.gitignore index 5be3dab..e04eda5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ dist/ coverage/ temp/ reports/ +test-results/ +playwright-report/ .stryker-tmp/ *.tsbuildinfo .vitest/ diff --git a/README.md b/README.md index 673240e..66069b8 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ You need Node 24, `corepack enable`, Python 3.11 or later and `uv`. pnpm check The tests of the field 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 diff --git a/package.json b/package.json index 4c25856..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", @@ -33,12 +34,14 @@ "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", diff --git a/packages/field/README.md b/packages/field/README.md new file mode 100644 index 0000000..71c262c --- /dev/null +++ b/packages/field/README.md @@ -0,0 +1,141 @@ + + + gatepost + + +# @gatepost/field + +A postcode field for any web form. It checks Nigeria's new postcode while the user types, and the form sends the postcode in its canonical form. + +> Unofficial. Not made or endorsed by NIPOST. + +[![CI](https://github.com/gatepost-dev/js/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gatepost-dev/js/actions/workflows/ci.yml) +[![Licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](https://github.com/gatepost-dev/js/blob/main/LICENSE) + +## Install + +```sh +pnpm add @gatepost/field +``` + +The first alpha is not on npm yet, so this command and the CDN address below work only after its release. + +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. + +## Quickstart + +```html + +
+ + +
+``` + +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. + +## What it does + +- 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. +- Stores nothing, sets no cookie and sends no telemetry. + +### 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. `confirm="level2"` needs a key with level 2 access. + +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. + +| 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. + +Set the properties `messages` and `value` only after the browser defines the element. A property that you set earlier is lost. Module scripts run in order, so a module script below the field's script is safe. Other code can wait for `customElements.whenDefined('gatepost-postcode-field')`. + +The tokens are `--gatepost-text`, `--gatepost-muted`, `--gatepost-background`, `--gatepost-border`, `--gatepost-accent`, `--gatepost-error`, `--gatepost-warning`, `--gatepost-radius` and `--gatepost-font`. The defaults meet a contrast of 4.5:1 on white. Check your own colours on your page's background. The parts `field`, `label`, `hint`, `input`, `message` and `button` take `::part()` rules. + +## 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 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. + +## Support + +Ask questions and report bugs in [GitHub Issues](https://github.com/gatepost-dev/js/issues). Report security problems privately, as [`SECURITY.md`](https://github.com/gatepost-dev/.github/blob/main/SECURITY.md) describes. + +## Contributing + +Read [`CONTRIBUTING.md`](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request. `pnpm check` runs the browser tests, so ports 3000 and 4010 must be free. + +## 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/package.json b/packages/field/package.json index 641e396..3ae6e21 100644 --- a/packages/field/package.json +++ b/packages/field/package.json @@ -60,11 +60,15 @@ "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..494d0a8 --- /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' }, + 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/test/journeys/form.test.ts b/packages/field/test/journeys/form.test.ts new file mode 100644 index 0000000..1ab3f2c --- /dev/null +++ b/packages/field/test/journeys/form.test.ts @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { AxeBuilder } from '@axe-core/playwright'; +import { expect, test, type Page } from '@playwright/test'; + +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); + await page.getByLabel('Postcode').fill('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 requests: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(message.text()); + } + }); + 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.', + ); + expect(errors).toHaveLength(1); + expect(requests.filter((url) => url.startsWith(MOCK))).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('/form?gps'); + await page.getByLabel('Postcode').fill('FCO1Z99ZZ01'); + 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'); +}); diff --git a/packages/field/test/journeys/readme.test.ts b/packages/field/test/journeys/readme.test.ts new file mode 100644 index 0000000..b95834d --- /dev/null +++ b/packages/field/test/journeys/readme.test.ts @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { expect, test, type Page } from '@playwright/test'; + +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.'); + expect(README).toMatch(/Gatepost spec \| 0\.3\.0 +\|/); +}); + +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 and the accent colour', async ({ page }) => { + await open(page, EXAMPLES[3]!); + const field = page.getByLabel('Delivery postcode'); + await expect(field).toBeVisible(); + const accent = await page + .locator('gatepost-postcode-field') + .evaluate((element) => getComputedStyle(element).getPropertyValue('--gatepost-accent').trim()); + expect(accent).toBe('#5b2a86'); +}); + +test('a property set before the element is defined is lost, as the README warns', 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('Postcode')).toBeVisible(); + await expect(page.getByLabel('Early')).toHaveCount(0); + expect(README).toContain('A property that you set earlier is lost.'); +}); + +test('a property set after whenDefined reaches the element', async ({ page }) => { + await page.route('http://localhost:3000/late', (route) => + route.fulfill({ + contentType: 'text/html', + body: [ + 'Late', + '', + '', + '', + ].join('\n'), + }), + ); + await page.goto('/late'); + await expect(page.getByLabel('Late')).toBeVisible(); +}); diff --git a/packages/field/test/journeys/serve.mjs b/packages/field/test/journeys/serve.mjs new file mode 100644 index 0000000..c4961be --- /dev/null +++ b/packages/field/test/journeys/serve.mjs @@ -0,0 +1,71 @@ +// 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}`, + // 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); +}).listen(3000, '127.0.0.1'); diff --git a/packages/field/vitest.config.ts b/packages/field/vitest.config.ts index 0b11ac5..c522341 100644 --- a/packages/field/vitest.config.ts +++ b/packages/field/vitest.config.ts @@ -18,6 +18,8 @@ export default defineConfig({ }, 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index deddd78..134a9ed 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) @@ -268,6 +274,10 @@ importers: '@gatepost/core': specifier: workspace:^ version: link:../core + devDependencies: + '@gatepost/mock-server': + specifier: workspace:* + version: link:../mock-server packages/mock-server: {} @@ -285,6 +295,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'} @@ -927,6 +942,11 @@ 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'} @@ -2989,6 +3009,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 @@ -3751,6 +3776,10 @@ 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': {} From 6d1e0eb8e8e151e7d0d3dbae39bd686018cb1526 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:01:57 +0100 Subject: [PATCH 13/23] fix(field): cancel a location request when the text is rewritten A press of the location button left the lookup in progress set. If the user then rewrote the text to the same postcode in another form, the change matched that lookup and kept the location request, so the position later overwrote the input. A location request now forgets the lookup in progress when it starts, and the method that cancels on a mismatch says so in its name. Signed-off-by: MobileMage --- packages/field/src/element.ts | 5 ++--- packages/field/src/requests.ts | 25 ++++++++++++++++----- packages/field/test/location.test.ts | 14 ++++++++++++ packages/field/test/requests.test.ts | 33 ++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 packages/field/test/requests.test.ts diff --git a/packages/field/src/element.ts b/packages/field/src/element.ts index cea6922..e89937b 100644 --- a/packages/field/src/element.ts +++ b/packages/field/src/element.ts @@ -414,7 +414,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { // 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.keeps(wanted, textChanged) && wanted !== null) { + if (this.#requests.cancelUnlessKept(wanted, textChanged) && wanted !== null) { if (reading.kind === 'postcode') { void this.#lookUp(reading.postcode, wanted); } @@ -524,8 +524,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { if (this.#client === null) { return; } - const work = this.#requests.start({ state: 'locating', note: { key: 'locating' } }); - this.#requests.remembered = null; + const work = this.#requests.startLocation({ state: 'locating', note: { key: 'locating' } }); this.#render(); try { const fix = await findFix(() => this.#client, work.signal); diff --git a/packages/field/src/requests.ts b/packages/field/src/requests.ts index ee19616..8bfd53d 100644 --- a/packages/field/src/requests.ts +++ b/packages/field/src/requests.ts @@ -47,17 +47,18 @@ export class Requests { * * @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 the requests stay. False when the caller can start the wanted lookup. + * @returns True when it cancelled the requests, so the caller can start the wanted lookup. + * False when the requests stay. */ - keeps(wanted: Asked | null, textChanged: boolean): boolean { + cancelUnlessKept(wanted: Asked | null, textChanged: boolean): boolean { if (!textChanged && this.#locationOutcome()) { - return true; + return false; } if (sameAsked(wanted, this.asked) || sameAsked(wanted, this.remembered)) { - return true; + return false; } this.cancel(); - return false; + return true; } /** Cancels the request in progress and forgets the remembered lookup and the outcome. */ @@ -83,6 +84,20 @@ export class Requests { 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; diff --git a/packages/field/test/location.test.ts b/packages/field/test/location.test.ts index f559704..cb8ab08 100644 --- a/packages/field/test/location.test.ts +++ b/packages/field/test/location.test.ts @@ -442,6 +442,20 @@ describe('the location button', () => { 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('turns off with the form', () => { const { location } = mount(`
${FIELD}
`); expect(location.disabled).toBe(true); 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); + }); +}); From fbf0dd17f30c7132947f9d834b2838c786bc78fe Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:09:33 +0100 Subject: [PATCH 14/23] fix(field): take properties set before the element is defined A framework can render the field before the script that defines it loads. A property such as messages then sits on the element itself and hides the accessor, so the first value and every later write never reached the field. The element now removes such an own property when it connects, and sets it again through the accessor. Signed-off-by: MobileMage --- packages/field/src/element.ts | 14 ++++++++++++ packages/field/test/element.test.ts | 35 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/field/src/element.ts b/packages/field/src/element.ts index e89937b..468c4aa 100644 --- a/packages/field/src/element.ts +++ b/packages/field/src/element.ts @@ -271,6 +271,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { /** Reads the settings and checks the text. The browser calls it on insertion. */ connectedCallback(): void { + this.#takeEarlyProperties(); this.#connected = true; this.#makeClient(); this.#refresh(); @@ -339,6 +340,19 @@ class FieldElement extends ElementBase implements PostcodeFieldElement { } } + // 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'; } diff --git a/packages/field/test/element.test.ts b/packages/field/test/element.test.ts index d22e345..d8f7e06 100644 --- a/packages/field/test/element.test.ts +++ b/packages/field/test/element.test.ts @@ -2,6 +2,7 @@ // 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 = ''; @@ -435,4 +436,38 @@ describe('accessibility', () => { 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'); + }); + }); }); From 76414d424e6768f8c089f5d635a0008f9f65e7cb Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:09:34 +0100 Subject: [PATCH 15/23] test(field): stop stray requests, check the theme and the back button Every journey now runs under a fixture that stops a request to any host except the test page and the mock server, and fails the journey that made it. The secret-key journey compares the exact console message, and the test page has an empty icon, so no engine logs a 404. The theming journey reads the outline of the field's own input. New journeys cover the back button, paste, a slow lookup, the suggestion button and the full-width row at 320 pixels. The README now says that early properties work. The browsers job keeps the trace of a failure. Signed-off-by: MobileMage --- .github/workflows/ci.yml | 8 ++ packages/field/README.md | 8 +- packages/field/playwright.config.ts | 2 +- packages/field/test/journeys/fixtures.ts | 42 ++++++++++ packages/field/test/journeys/form.test.ts | 89 +++++++++++++++++++-- packages/field/test/journeys/readme.test.ts | 57 ++++++------- packages/field/test/journeys/serve.mjs | 4 + 7 files changed, 167 insertions(+), 43 deletions(-) create mode 100644 packages/field/test/journeys/fixtures.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7916cdc..156b8c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,14 @@ jobs: - 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' diff --git a/packages/field/README.md b/packages/field/README.md index 71c262c..652d6ca 100644 --- a/packages/field/README.md +++ b/packages/field/README.md @@ -20,7 +20,7 @@ pnpm add @gatepost/field The first alpha is not on npm yet, so this command and the CDN address below work only after its release. -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. +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 @@ -56,7 +56,7 @@ A user who types `fc 01 z99 zz 01` sends `postcode=FC-01-Z99-ZZ-01`. With no key ``` -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. `confirm="level2"` needs a key with level 2 access. +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. @@ -101,7 +101,7 @@ The events bubble, so a page can listen on the form. --gatepost-accent: #5b2a86; } - + ', - '', - ].join('\n'), - }), - ); - await page.goto('/late'); - await expect(page.getByLabel('Late')).toBeVisible(); + 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 index c4961be..52a0e0e 100644 --- a/packages/field/test/journeys/serve.mjs +++ b/packages/field/test/journeys/serve.mjs @@ -23,6 +23,8 @@ function page(title, body) { '', '', `${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. '', '', @@ -68,4 +70,6 @@ createServer((request, response) => { } 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'); From e3f256e480a990ffcad921963428ace09b8eb373 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:14:18 +0100 Subject: [PATCH 16/23] feat(react): add the postcode field as a React component React apps, and the Next.js docs site, need the field as a component with props and callbacks. The component renders the element with one attribute for each prop, so a server render already holds the settings, and it adds the listeners after the element mounts. It passes only text to the element, because React 18 writes false as text and the attribute would stay. The tests run on React 18 and 19, and one test sets the messages before the element is defined. Signed-off-by: MobileMage --- .changeset/first-react-release.md | 5 + AGENTS.md | 2 +- README.md | 3 +- packages/field/README.md | 2 +- packages/react/LICENSE | 202 +++++++++++++++++++++++++ packages/react/NOTICE | 5 + packages/react/README.md | 99 ++++++++++++ packages/react/api-extractor.json | 25 +++ packages/react/etc/react.api.md | 39 +++++ packages/react/package.json | 73 +++++++++ packages/react/scripts/smoke.mjs | 18 +++ packages/react/src/index.ts | 13 ++ packages/react/src/postcode-field.ts | 148 ++++++++++++++++++ packages/react/src/version.ts | 14 ++ packages/react/test/browser.test.ts | 147 ++++++++++++++++++ packages/react/test/readme-example.tsx | 22 +++ packages/react/test/readme.test.ts | 41 +++++ packages/react/test/server.test.ts | 40 +++++ packages/react/tsconfig.json | 16 ++ packages/react/tsdown.config.ts | 28 ++++ packages/react/vitest.config.ts | 58 +++++++ pnpm-lock.yaml | 101 +++++++++++++ 22 files changed, 1098 insertions(+), 3 deletions(-) create mode 100644 .changeset/first-react-release.md create mode 100644 packages/react/LICENSE create mode 100644 packages/react/NOTICE create mode 100644 packages/react/README.md create mode 100644 packages/react/api-extractor.json create mode 100644 packages/react/etc/react.api.md create mode 100644 packages/react/package.json create mode 100644 packages/react/scripts/smoke.mjs create mode 100644 packages/react/src/index.ts create mode 100644 packages/react/src/postcode-field.ts create mode 100644 packages/react/src/version.ts create mode 100644 packages/react/test/browser.test.ts create mode 100644 packages/react/test/readme-example.tsx create mode 100644 packages/react/test/readme.test.ts create mode 100644 packages/react/test/server.test.ts create mode 100644 packages/react/tsconfig.json create mode 100644 packages/react/tsdown.config.ts create mode 100644 packages/react/vitest.config.ts 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/AGENTS.md b/AGENTS.md index 1dd7263..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`, `@gatepost/field` 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 diff --git a/README.md b/README.md index 66069b8..7336af5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ TypeScript packages for Nigeria's National Digital Postcode. | [`@gatepost/core`](packages/core) | Parse, check and format postcodes offline. | | [`@gatepost/client`](packages/client) | Call NIPOST's gateway, with retries and limits. | | [`@gatepost/field`](packages/field) | A postcode field for any web form. | +| [`@gatepost/react`](packages/react) | The postcode field as a React component. | | [`@gatepost/mock-server`](packages/mock-server) | Mock NIPOST's gateway for client tests. Not on npm. | ## Develop @@ -25,7 +26,7 @@ You need Node 24, `corepack enable`, Python 3.11 or later and `uv`. pnpm exec playwright install chromium pnpm check -The tests of the field run in Chromium, which Playwright installs once. +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. diff --git a/packages/field/README.md b/packages/field/README.md index 652d6ca..466b86e 100644 --- a/packages/field/README.md +++ b/packages/field/README.md @@ -126,7 +126,7 @@ The package exports `SPEC_VERSION`, the version of the Gatepost spec that it imp ## Docs -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. +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 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..195d6e1 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,99 @@ + + + gatepost + + +# @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. + +> Unofficial. Not made or endorsed by NIPOST. + +[![CI](https://github.com/gatepost-dev/js/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gatepost-dev/js/actions/workflows/ci.yml) +[![Licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](https://github.com/gatepost-dev/js/blob/main/LICENSE) + +## Install + +```sh +pnpm add @gatepost/react +``` + +The first alpha is not on npm yet, so this command works only after its release. + +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 ( +
+ { + setPostcode(detail.value); + }} + /> +

{postcode}

+ + + ); +} +``` + +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`. + +## What it does + +- 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. Read each new value in `onChange`, or read the form when it submits. + +| 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 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 and report bugs in [GitHub Issues](https://github.com/gatepost-dev/js/issues). Report security problems privately, as [`SECURITY.md`](https://github.com/gatepost-dev/.github/blob/main/SECURITY.md) describes. + +## Contributing + +Read [`CONTRIBUTING.md`](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..bc83cf4 --- /dev/null +++ b/packages/react/src/postcode-field.ts @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import '@gatepost/field'; +import type { + ChangeDetail, + ConfirmDetail, + ErrorDetail, + Messages, + PostcodeFieldElement, +} from '@gatepost/field'; +import { + createElement, + forwardRef, + useCallback, + useEffect, + 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; +} + +function forward( + ref: ForwardedRef, + element: PostcodeFieldElement | null, +): void { + if (typeof ref === 'function') { + ref(element); + } else if (ref !== null) { + ref.current = element; + } +} + +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) => { + setElement(attached); + forward(ref, attached); + }, + [ref], + ); + const { messages } = props; + useEffect(() => { + if (element !== null && messages !== undefined) { + element.messages = messages; + } + }, [element, messages]); + 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: props.defaultValue, + 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..eab6967 --- /dev/null +++ b/packages/react/test/browser.test.ts @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import type { ChangeDetail, ErrorDetail, PostcodeFieldElement } from '@gatepost/field'; +import { act, createElement, createRef, version } from 'react'; +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]\./); + }); + + 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('hydrates the HTML of a server render with no warning', () => { + const consoleError = vi.spyOn(console, 'error'); + const element = createElement(PostcodeField, { name: 'postcode', defaultValue: 'fc01z99zz01' }); + const form = container(); + form.innerHTML = renderToString(element); + act(() => { + roots.push(hydrateRoot(form, element)); + }); + expect(consoleError).not.toHaveBeenCalled(); + expect(new FormData(form).get('postcode')).toBe('FC-01-Z99-ZZ-01'); + }); +}); 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 ( +
+ { + setPostcode(detail.value); + }} + /> +

{postcode}

+ + + ); +} 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 134a9ed..8adabd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,6 +281,31 @@ importers: 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': @@ -1179,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} @@ -1655,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'} @@ -2202,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} @@ -2454,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} @@ -2523,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'} @@ -3991,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 @@ -4446,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 @@ -4919,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: @@ -5136,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 @@ -5205,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: {} From 8192fe061331d4f03cb7d78800f1ef44f129452e Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:26:49 +0100 Subject: [PATCH 17/23] fix(react): keep defaultValue as the first text on React 19 React 19 sets a prop of a custom element as a property when the element has one of that name. So defaultValue reached the field as the current text: a form reset emptied the field, and a new defaultValue replaced what the user typed. The component now writes defaultValue as the value attribute after the render, and the prop keeps its first value. It also resets the messages when the prop goes away, sets an unchanged messages object once, passes on the cleanup of a callback ref, and drops the directive from the source file, because the build banner writes it at the top of the built file. Signed-off-by: MobileMage --- packages/react/README.md | 2 +- packages/react/src/postcode-field.ts | 91 +++++++++++-- packages/react/test/browser.test.ts | 185 ++++++++++++++++++++++++++- 3 files changed, 262 insertions(+), 16 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 195d6e1..ee3f7fe 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -56,7 +56,7 @@ A user who types `fc 01 z99 zz 01` sees `FC-01-Z99-ZZ-01`, and the form sends `p - 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. Read each new value in `onChange`, or read the form when it submits. +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. | Prop | Meaning | | ---------------------------------------------- | --------------------------------------------------------------------- | diff --git a/packages/react/src/postcode-field.ts b/packages/react/src/postcode-field.ts index bc83cf4..d466ea0 100644 --- a/packages/react/src/postcode-field.ts +++ b/packages/react/src/postcode-field.ts @@ -1,6 +1,5 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 -'use client'; import '@gatepost/field'; import type { ChangeDetail, @@ -14,6 +13,8 @@ import { forwardRef, useCallback, useEffect, + useLayoutEffect, + useRef, useState, type ForwardedRef, type ForwardRefExoticComponent, @@ -62,15 +63,38 @@ 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 | null, -): void { + element: PostcodeFieldElement, +): (() => void) | undefined { if (typeof ref === 'function') { - ref(element); - } else if (ref !== null) { + 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 { @@ -101,25 +125,68 @@ function useEvent( function render(props: PostcodeFieldProps, ref: ForwardedRef): ReactElement { const [element, setElement] = useState(null); const attach = useCallback( - (attached: PostcodeFieldElement | null) => { + (attached: PostcodeFieldElement | null): (() => void) | undefined => { setElement(attached); - forward(ref, 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], ); - const { messages } = props; - useEffect(() => { - if (element !== null && messages !== undefined) { - element.messages = messages; + // 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: props.defaultValue, + value: first, label: props.label, 'api-key': props.apiKey, 'base-url': props.baseUrl, diff --git a/packages/react/test/browser.test.ts b/packages/react/test/browser.test.ts index eab6967..6ea3b80 100644 --- a/packages/react/test/browser.test.ts +++ b/packages/react/test/browser.test.ts @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 import type { ChangeDetail, ErrorDetail, PostcodeFieldElement } from '@gatepost/field'; -import { act, createElement, createRef, version } from 'react'; +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'; @@ -46,6 +47,7 @@ function input(form: HTMLFormElement): HTMLInputElement { 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 () => { @@ -133,15 +135,192 @@ describe(`PostcodeField on React ${version}`, () => { 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 consoleError = vi.spyOn(console, 'error'); 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).not.toHaveBeenCalled(); + 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', + ); }); }); From 7ff0465ff4039c51e6fda877836d688764271017 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:45:19 +0100 Subject: [PATCH 18/23] fix(field): keep a location request, and refuse a padded secret key After the blur reformat, a later change of a setting read the new text as a change by the user and cancelled a location request or cleared its message. The key now loses the white space around it before the field uses it, so a secret key with a space or a line break before it is refused as any secret key is. The shadow tree is built with DOM calls, so a page with a Trusted Types policy can show it. A message that is not text keeps its English text. The README says that the default colours are for a light page, gives a dark example, and warns about the recent house address. The change files now describe one release. Signed-off-by: MobileMage --- .changeset/field-element.md | 5 --- .changeset/field-journeys.md | 2 - .changeset/field-location.md | 5 --- .changeset/field-lookups.md | 5 --- .changeset/first-field-release.md | 2 +- packages/field/README.md | 22 ++++++++-- packages/field/etc/field.api.md | 1 + packages/field/src/element.ts | 21 ++++++++-- packages/field/src/styles.ts | 6 ++- packages/field/src/view.ts | 63 ++++++++++++++++++++-------- packages/field/test/element.test.ts | 11 +++++ packages/field/test/location.test.ts | 32 ++++++++++++++ packages/field/test/lookup.test.ts | 32 ++++++++++++++ packages/field/test/styles.test.ts | 48 ++++++++++++++++++++- packages/react/README.md | 2 +- 15 files changed, 210 insertions(+), 47 deletions(-) delete mode 100644 .changeset/field-element.md delete mode 100644 .changeset/field-journeys.md delete mode 100644 .changeset/field-location.md delete mode 100644 .changeset/field-lookups.md diff --git a/.changeset/field-element.md b/.changeset/field-element.md deleted file mode 100644 index 2b1bd76..0000000 --- a/.changeset/field-element.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@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. diff --git a/.changeset/field-journeys.md b/.changeset/field-journeys.md deleted file mode 100644 index a845151..0000000 --- a/.changeset/field-journeys.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/field-location.md b/.changeset/field-location.md deleted file mode 100644 index 8999299..0000000 --- a/.changeset/field-location.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@gatepost/field': minor ---- - -With `gps` and a key, the field 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/field-lookups.md b/.changeset/field-lookups.md deleted file mode 100644 index e6744e6..0000000 --- a/.changeset/field-lookups.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@gatepost/field': minor ---- - -With a publishable key, the field 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. diff --git a/.changeset/first-field-release.md b/.changeset/first-field-release.md index df14c00..6ca9e11 100644 --- a/.changeset/first-field-release.md +++ b/.changeset/first-field-release.md @@ -2,4 +2,4 @@ '@gatepost/field': minor --- -Add the field package. It starts with the reading of the text of a postcode field and the English messages of spec 0.3.0. +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/packages/field/README.md b/packages/field/README.md index 466b86e..35462f4 100644 --- a/packages/field/README.md +++ b/packages/field/README.md @@ -32,6 +32,8 @@ Import `@gatepost/field` once in your app, and the page can use the element. A p ``` +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 `