@clicktap/ui 1.0.3 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/Breadcrumbs/BreadcrumbLink.js +1 -1
- package/components/Breadcrumbs/BreadcrumbSeparator.js +1 -1
- package/docs/migrations/2026/08/ui-2026-08-11-peer-ranges-capped.md +30 -0
- package/docs/migrations/2026/08/ui-2026-08-14-breadcrumb-trail-fully-tokenised.md +80 -0
- package/docs/migrations/2026/08/ui-2026-08-14-zod-error-map.md +104 -0
- package/docs/migrations/index.json +28 -3
- package/package.json +1 -1
- package/utils/zod/configureZodErrorMap.d.ts +115 -0
- package/utils/zod/configureZodErrorMap.js +1 -0
- package/utils/zod/index.d.ts +1 -0
- package/utils/zod/index.js +1 -0
- package/utils/zod.d.ts +1 -0
- package/utils/zod.js +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
"use client";import{jsx as
|
|
1
|
+
"use client";import{jsx as u}from"react/jsx-runtime";import{Link as o}from"react-aria-components";import{cn as i}from"../../utils/cn.js";function f({children:e,className:r,...t}){return u(o,{className:i("text-fg-muted no-underline cursor-pointer","focus-visible:outline-2 focus-visible:outline focus-visible:outline-border-subtle","hover:text-fg hover:underline","current:cursor-default current:text-fg current:font-semibold","disabled:cursor-default disabled:text-fg-subtle disabled:current:text-fg",r),...t,children:e})}export{f as BreadcrumbLink,f as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use client";import{jsx as e}from"react/jsx-runtime";import{Breadcrumb as i}from"react-aria-components";import{cn as n}from"../../utils/cn.js";function
|
|
1
|
+
"use client";import{jsx as e}from"react/jsx-runtime";import{Breadcrumb as i}from"react-aria-components";import{cn as n}from"../../utils/cn.js";function d({children:r,className:t,...o}){return e(i,{className:n("flex items-center text-fg-muted","my-0 mx-2",t),...o,children:r??e("svg",{"aria-hidden":"true",xmlns:"http://www.w3.org/2000/svg",width:"16",height:"16",viewBox:"0 0 16 16",fill:"none",className:"w-4 h-4",children:e("path",{d:"M6 12L10 8L6 4",stroke:"currentColor",strokeWidth:"1.5",strokeLinecap:"round",strokeLinejoin:"round"})})})}export{d as BreadcrumbSeparator,d as default};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
**Affects:** every consumer — the peer ranges are the declaration your package manager installs against.
|
|
6
6
|
**Classification:** COMPATIBILITY
|
|
7
7
|
**Breaking:** Yes for a consumer already past a ceiling. No code change; the contract narrowed.
|
|
8
|
+
**Read before updating:** the capped ranges are the contract your install resolves against — decide before taking `@clicktap/ui` 1.x, not after
|
|
8
9
|
**Ticket:** CTAP-1241
|
|
9
10
|
|
|
10
11
|
## Summary
|
|
@@ -53,6 +54,35 @@ in your own app, confirm it matches what `@clicktap/ui` resolves. The split abov
|
|
|
53
54
|
without symptoms, and duplicate react-aria/stately instances surface as subtle focus and overlay bugs
|
|
54
55
|
rather than as an error.
|
|
55
56
|
|
|
57
|
+
### When the duplicate is transitive — nothing of yours to pin
|
|
58
|
+
|
|
59
|
+
The paragraph above assumes the second copy comes from a declaration you own. Often it does not:
|
|
60
|
+
crispi resolves `react-stately` 3.49.0 cleanly across every workspace member and still carries a
|
|
61
|
+
second copy at 3.46.0, arriving via `@adobe/react-spectrum` → `react-aria-components`. No manifest of
|
|
62
|
+
theirs names it, so there is no pin to correct.
|
|
63
|
+
|
|
64
|
+
Decide it in this order:
|
|
65
|
+
|
|
66
|
+
1. **Ask whether the two copies ever share a context.** The hazard is not two versions existing; it
|
|
67
|
+
is two versions of the same *provider* — `@react-aria/overlays`' focus scope and dismiss stack,
|
|
68
|
+
most of all — being live in one tree at once. Two design systems that each portal their own
|
|
69
|
+
overlays, never nesting one inside the other, can hold separate copies indefinitely. Two copies
|
|
70
|
+
reached through the same `OverlayProvider` cannot.
|
|
71
|
+
2. **If they do share one, force a single resolution with `pnpm.overrides`:**
|
|
72
|
+
```json
|
|
73
|
+
{ "pnpm": { "overrides": { "react-stately": "3.49.0" } } }
|
|
74
|
+
```
|
|
75
|
+
A bare-name override collapses every copy in the tree whatever any package declares. Verify with
|
|
76
|
+
`pnpm why react-stately` and then exercise the *other* library's overlays — you have moved it onto
|
|
77
|
+
a version it did not choose, which is the cost of this lever.
|
|
78
|
+
3. **If they do not, record the decision and move on.** `@clicktap/ui` externalizes `react-stately`
|
|
79
|
+
and `@react-stately/*`, so the copy under a foreign design system is never the copy this package
|
|
80
|
+
imports.
|
|
81
|
+
|
|
82
|
+
What you should not do is leave the check unresolved either way. A duplicate you have decided is
|
|
83
|
+
harmless is a different state from one you have not looked at, and only the first survives the next
|
|
84
|
+
person reading `pnpm why`.
|
|
85
|
+
|
|
56
86
|
## Verify
|
|
57
87
|
|
|
58
88
|
```bash
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# The breadcrumb trail is fully tokenised, so a dark-ground app can re-theme it
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-14
|
|
4
|
+
**Package:** `@clicktap/ui`
|
|
5
|
+
**Affects:** any consumer rendering `Breadcrumbs` — and, for the diagnosis below, any consumer on a
|
|
6
|
+
dark ground.
|
|
7
|
+
**Classification:** BUGFIX
|
|
8
|
+
**Breaking:** No. One small visual change in light mode: the ancestor crumbs and the chevron move
|
|
9
|
+
from `slate-600` to the `fg-muted` token (`slate-500`), one step lighter. The current crumb is
|
|
10
|
+
unchanged.
|
|
11
|
+
|
|
12
|
+
## What was wrong
|
|
13
|
+
|
|
14
|
+
`BreadcrumbLink` and `BreadcrumbSeparator` still carried a literal `text-slate-600` — residue from
|
|
15
|
+
the CTAP-1123 token sweep. A literal palette class cannot be re-themed at all: a consumer overriding
|
|
16
|
+
`--color-fg-muted` for a dark ground got no effect on the trail, because nothing in it read that
|
|
17
|
+
token.
|
|
18
|
+
|
|
19
|
+
Both now use `text-fg-muted`, so the whole trail re-themes with the rest of the library.
|
|
20
|
+
|
|
21
|
+
## The reported symptom, and what actually causes it
|
|
22
|
+
|
|
23
|
+
The report (CTAP-1304) was that the **current** crumb is illegible on a dark ground — roughly
|
|
24
|
+
1.2:1 against a black body. That crumb is styled `current:text-fg`, which is already a token, so
|
|
25
|
+
this fix does not change it. The cause is one level up:
|
|
26
|
+
|
|
27
|
+
> `@clicktap/ui` ships **light** token defaults and deliberately does not assume a ground. Re-theming
|
|
28
|
+
> is a runtime override of the tokens — see the top of `theme.css`.
|
|
29
|
+
|
|
30
|
+
An app that paints `body { background-color: #000 }` without overriding `--color-fg` gets
|
|
31
|
+
`slate-900` text on black. That is not specific to breadcrumbs: **`text-fg` appears 132 times across
|
|
32
|
+
39 components**, so the same app has the same problem everywhere. Breadcrumbs is simply where QA
|
|
33
|
+
noticed, because the current crumb is the item you most expect to stand out.
|
|
34
|
+
|
|
35
|
+
The fix on the consumer side is two lines, and it repairs all 39 components at once:
|
|
36
|
+
|
|
37
|
+
```css
|
|
38
|
+
:root[data-theme='dark'] {
|
|
39
|
+
--color-fg: oklch(98% 0 0);
|
|
40
|
+
--color-fg-muted: oklch(70.4% 0.04 256.788);
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The new `DarkGround` story shows exactly this. Delete the two custom properties from it and it
|
|
45
|
+
reproduces the reported bug.
|
|
46
|
+
|
|
47
|
+
## Overriding just the current crumb
|
|
48
|
+
|
|
49
|
+
If you are not re-theming globally and want to repaint one crumb, there is a sharp edge worth
|
|
50
|
+
knowing — it is why an override can look like it does nothing:
|
|
51
|
+
|
|
52
|
+
> tailwind-merge only drops a conflicting class when the **variant strings match**. `current:text-fg`
|
|
53
|
+
> (ours) and `data-[current]:text-white` (the natural guess) are different variants, so both survive
|
|
54
|
+
> the merge and stylesheet order decides the winner.
|
|
55
|
+
|
|
56
|
+
Claim the same variant and it resolves deterministically:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
<BreadcrumbLink className="current:text-white">Product</BreadcrumbLink>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The `OverridingTheCurrentCrumb` story demonstrates it. This is also why a consumer override written
|
|
63
|
+
against `data-[disabled]` never affected the current crumb — `disabled` and `current` are different
|
|
64
|
+
states, so it was never going to match.
|
|
65
|
+
|
|
66
|
+
## Migration checklist
|
|
67
|
+
|
|
68
|
+
- [ ] Nothing required. The trail keeps working as-is.
|
|
69
|
+
- [ ] If you are on a dark ground, override `--color-fg` and `--color-fg-muted` rather than
|
|
70
|
+
restyling components — and check the rest of your app, since the same tokens drive 39 components.
|
|
71
|
+
- [ ] If you carry a local patch repainting breadcrumb colours, it can go; if you keep one, make
|
|
72
|
+
sure it claims the `current:` variant.
|
|
73
|
+
|
|
74
|
+
## Known residue
|
|
75
|
+
|
|
76
|
+
Twenty-six literal `text-slate-*` occurrences remain elsewhere in the library (CTAP-1123 residue,
|
|
77
|
+
across `FeatureGate`, `Collection`, `Pagination`, `EmptyResults` and others). They have the same
|
|
78
|
+
un-themeable problem as the two fixed here, but mapping them onto the token ladder is a visual
|
|
79
|
+
judgement per site rather than a mechanical swap — `slate-700` and `slate-800` have no exact token —
|
|
80
|
+
so they are deliberately not swept here. Tracked separately.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# A shared zod 4 error map, so every app stops hand-rolling `config/zod.ts`
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-14
|
|
4
|
+
**Package:** `@clicktap/ui`
|
|
5
|
+
**Affects:** any consumer on **zod 4**. Nothing changes for a zod 3 app, or for one not using zod
|
|
6
|
+
at all.
|
|
7
|
+
**Classification:** IMPROVEMENT
|
|
8
|
+
**Breaking:** No. Purely additive — one new subpath. `@clicktap/ui` gains **no** dependency on zod,
|
|
9
|
+
so nothing about your install changes.
|
|
10
|
+
|
|
11
|
+
## Why this exists
|
|
12
|
+
|
|
13
|
+
zod 4 dropped zod 3's readable defaults. Verified against zod 4.4.3:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
undefined against z.string() -> "Invalid input: expected string, received undefined"
|
|
17
|
+
z.string().min(8) -> "Too small: expected string to have >=8 characters"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Both are developer output. Neither should reach a visitor.
|
|
21
|
+
|
|
22
|
+
**The first cannot be fixed with per-field messages, and that is the part that catches people out.**
|
|
23
|
+
react-hook-form leaves an untouched field as `undefined` (inputs render `value ?? ''`), so
|
|
24
|
+
`.min(1, '…')` and `.nonempty({ message })` only ever see an empty string — their message never
|
|
25
|
+
applies to the missing case. Submitting an untouched form is the common path, so nearly every field
|
|
26
|
+
in every app is exposed. It has to be fixed globally.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
import { configureZodErrorMap } from '@clicktap/ui/utils/zod';
|
|
33
|
+
|
|
34
|
+
configureZodErrorMap(z);
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**You pass `z` in, and that is deliberate.** This package does not depend on zod — not on a version
|
|
38
|
+
and not on a copy. The map has to land on the zod instance your schemas were built with, and an app
|
|
39
|
+
on zod 3 must be able to install `@clicktap/ui` without a stray zod 4 appearing in its type graph.
|
|
40
|
+
That last one is not hypothetical: adding zod 4 to this library, even only as a dev dependency,
|
|
41
|
+
made `@hookform/resolvers`' `zodResolver` resolve zod 4's types against the reference app's zod 3
|
|
42
|
+
schemas and produced 29 typecheck errors across it. Passing the namespace makes the instance
|
|
43
|
+
explicit and costs one argument.
|
|
44
|
+
|
|
45
|
+
Call it **once per module registry**. In a Next.js app that is two places, because the server and
|
|
46
|
+
browser bundles are separate registries and a module evaluated in one is not evaluated in the other:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// app/layout.tsx — SSR and server actions
|
|
50
|
+
// app/providers.tsx — 'use client', the browser
|
|
51
|
+
configureZodErrorMap(z);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It is a plain idempotent function rather than a side-effecting import on purpose: calling it twice
|
|
55
|
+
is harmless, and an explicit call is not something a bundler can tree-shake away. That also answers
|
|
56
|
+
the "structure it so there is only one import point" question — there is no structure that can
|
|
57
|
+
collapse two module registries into one, so the API is at least honest about needing both calls.
|
|
58
|
+
|
|
59
|
+
### Customising
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
configureZodErrorMap(z, {
|
|
63
|
+
required: 'Required.',
|
|
64
|
+
constraints: { stringTooSmall: (min) => `At least ${min}, please.` },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
configureZodErrorMap(z, { constraints: false }); // only rewrite the missing-value case
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Precedence — per-check messages still win
|
|
71
|
+
|
|
72
|
+
The map returns `undefined` for anything it does not handle, so zod falls back to whatever message
|
|
73
|
+
is attached at the schema. Covered by tests:
|
|
74
|
+
|
|
75
|
+
| schema | input | message |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `z.string().min(8, 'Pick a longer password.')` | `'abc'` | `Pick a longer password.` |
|
|
78
|
+
| `z.string().min(8)` | `'abc'` | `Must be at least 8 characters.` |
|
|
79
|
+
| `z.string().min(1, 'Enter your name.')` | `''` | `Enter your name.` |
|
|
80
|
+
| `z.string().min(1, 'Enter your name.')` | `undefined` | `This field is required.` |
|
|
81
|
+
| `z.email()` | `'nope'` | `Invalid email address` (zod's own, already readable) |
|
|
82
|
+
|
|
83
|
+
The fourth row is the one worth staring at: the field's own message loses to the global map,
|
|
84
|
+
because the field was never filled in and its `.min(1)` check never ran.
|
|
85
|
+
|
|
86
|
+
## Migration checklist
|
|
87
|
+
|
|
88
|
+
- [ ] **Delete your hand-rolled `config/zod.ts`** and call `configureZodErrorMap(z)` from the same
|
|
89
|
+
two places the side-effect import was made.
|
|
90
|
+
- [ ] Nothing to install — you supply your own `z`.
|
|
91
|
+
- [ ] If you are on zod 3, ignore this entirely.
|
|
92
|
+
|
|
93
|
+
## Note for maintainers
|
|
94
|
+
|
|
95
|
+
The behaviour above was verified against a real zod 4.4.3 before the API was settled; the unit tests
|
|
96
|
+
then drive the map with issue fixtures captured from that run, since the package carries no zod to
|
|
97
|
+
test against.
|
|
98
|
+
|
|
99
|
+
An earlier attempt imported zod directly and declared it an optional peer. Two things killed it, in
|
|
100
|
+
order of discovery: the library build **inlined zod's own global-config implementation**
|
|
101
|
+
(`globalThis.__zod_globalConfig` and the assignment around it) rather than importing it — visible
|
|
102
|
+
only by reading `dist/`, because the in-repo import resolves through the tsconfig path alias and
|
|
103
|
+
looks identical either way — and installing zod 4 here to test against broke the zod-3 reference
|
|
104
|
+
app's typecheck. Taking the namespace as an argument removes both problems at once.
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"path": "2026/08/ui-2026-08-14-zod-error-map.md",
|
|
4
|
+
"module": "libs/frontend-modules/ui",
|
|
5
|
+
"package": "@clicktap/ui",
|
|
6
|
+
"date": "2026-08-14",
|
|
7
|
+
"title": "A shared zod 4 error map, so every app stops hand-rolling `config/zod.ts`",
|
|
8
|
+
"classification": "IMPROVEMENT",
|
|
9
|
+
"breaking": "No. Purely additive — one new subpath. `@clicktap/ui` gains **no** dependency on zod,",
|
|
10
|
+
"affects": "any consumer on **zod 4**. Nothing changes for a zod 3 app, or for one not using zod"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"path": "2026/08/ui-2026-08-14-breadcrumb-trail-fully-tokenised.md",
|
|
14
|
+
"module": "libs/frontend-modules/ui",
|
|
15
|
+
"package": "@clicktap/ui",
|
|
16
|
+
"date": "2026-08-14",
|
|
17
|
+
"title": "The breadcrumb trail is fully tokenised, so a dark-ground app can re-theme it",
|
|
18
|
+
"classification": "BUGFIX",
|
|
19
|
+
"breaking": "No. One small visual change in light mode: the ancestor crumbs and the chevron move",
|
|
20
|
+
"affects": "any consumer rendering `Breadcrumbs` — and, for the diagnosis below, any consumer on a"
|
|
21
|
+
},
|
|
2
22
|
{
|
|
3
23
|
"path": "2026/08/ui-2026-08-11-peer-ranges-capped.md",
|
|
4
24
|
"module": "libs/frontend-modules/ui",
|
|
@@ -7,7 +27,8 @@
|
|
|
7
27
|
"title": "`next` and `@heroui/*` peer ranges are capped; `react-stately` pinned",
|
|
8
28
|
"classification": "COMPATIBILITY",
|
|
9
29
|
"breaking": "Yes for a consumer already past a ceiling. No code change; the contract narrowed.",
|
|
10
|
-
"affects": "every consumer — the peer ranges are the declaration your package manager installs against."
|
|
30
|
+
"affects": "every consumer — the peer ranges are the declaration your package manager installs against.",
|
|
31
|
+
"read_before_updating": "the capped ranges are the contract your install resolves against — decide before taking `@clicktap/ui` 1.x, not after"
|
|
11
32
|
},
|
|
12
33
|
{
|
|
13
34
|
"path": "2026/08/ui-2026-08-11-password-reveal-toggle.md",
|
|
@@ -17,7 +38,9 @@
|
|
|
17
38
|
"title": "Password reveal is a toggle again, not press-and-hold",
|
|
18
39
|
"classification": "IMPROVEMENT (UX / accessibility)",
|
|
19
40
|
"breaking": "Behavioural only — the reveal changed from press-and-hold back to a click toggle, and the",
|
|
20
|
-
"affects": "`@clicktap/ui` — `Input` with `type=\"password\"`."
|
|
41
|
+
"affects": "`@clicktap/ui` — `Input` with `type=\"password\"`.",
|
|
42
|
+
"revised": "2026-08-11",
|
|
43
|
+
"revised_note": "2026-08-11 — consumer action now says to rename a component named for the press-and-hold"
|
|
21
44
|
},
|
|
22
45
|
{
|
|
23
46
|
"path": "2026/08/ui-2026-08-07-ui-peer-contract-form-graphql-optional-peers.md",
|
|
@@ -97,7 +120,9 @@
|
|
|
97
120
|
"title": "`@clicktap/ui` resolves its deep subpaths under `moduleResolution: bundler` (CTAP-1060)",
|
|
98
121
|
"classification": "BUGFIX (packaging) — unblocks `moduleResolution: bundler`, and with it TypeScript 7",
|
|
99
122
|
"breaking": "No — additive to the `exports` map. Existing `moduleResolution: node` / `node10`",
|
|
100
|
-
"affects": ""
|
|
123
|
+
"affects": "",
|
|
124
|
+
"revised": "2026-08-08",
|
|
125
|
+
"revised_note": "2026-08-08 — the test-runner half (CTAP-1195): every runtime subpath entry gains a `default` condition, so CJS resolvers (Jest) stop falling through to the `.d.ts`. See \"Test runners (Jest)\" below; reported by noco."
|
|
101
126
|
},
|
|
102
127
|
{
|
|
103
128
|
"path": "2026/06/ui-2026-06-18-select-hiddenselect-setvalue-crash.md",
|
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of a zod 4 issue, to the extent this helper reads one.
|
|
3
|
+
*
|
|
4
|
+
* Declared structurally rather than imported from `zod` on purpose — see {@link configureZodErrorMap}.
|
|
5
|
+
*/
|
|
6
|
+
export interface ZodIssueLike {
|
|
7
|
+
/** The value that failed. `undefined` means the key was never supplied. */
|
|
8
|
+
readonly input?: unknown;
|
|
9
|
+
readonly code?: string;
|
|
10
|
+
/** What was measured: `'string'`, `'number'`, `'array'`, … */
|
|
11
|
+
readonly origin?: string;
|
|
12
|
+
readonly minimum?: unknown;
|
|
13
|
+
readonly maximum?: unknown;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The one method this needs from the zod namespace.
|
|
17
|
+
*
|
|
18
|
+
* Structural, so any zod 4.x satisfies it and nothing here depends on a particular copy.
|
|
19
|
+
*/
|
|
20
|
+
export interface ZodNamespaceLike {
|
|
21
|
+
config(config: {
|
|
22
|
+
customError: (issue: ZodIssueLike) => string | undefined;
|
|
23
|
+
}): unknown;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Messages for the constraint checks whose zod 4 defaults leak internals.
|
|
27
|
+
*
|
|
28
|
+
* Each is a function of the bound so a consumer can localise or reword without reimplementing the
|
|
29
|
+
* branching. A per-check message (`.min(8, '…')`) or a schema-level one always wins over these —
|
|
30
|
+
* that is the whole contract of the error map below.
|
|
31
|
+
*/
|
|
32
|
+
export interface ZodConstraintMessages {
|
|
33
|
+
stringTooSmall: (minimum: number) => string;
|
|
34
|
+
stringTooBig: (maximum: number) => string;
|
|
35
|
+
numberTooSmall: (minimum: number) => string;
|
|
36
|
+
numberTooBig: (maximum: number) => string;
|
|
37
|
+
arrayTooSmall: (minimum: number) => string;
|
|
38
|
+
arrayTooBig: (maximum: number) => string;
|
|
39
|
+
}
|
|
40
|
+
export interface ConfigureZodErrorMapOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Shown when the value is absent entirely. This is the one that matters most — see below.
|
|
43
|
+
*
|
|
44
|
+
* @default 'This field is required.'
|
|
45
|
+
*/
|
|
46
|
+
required?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Readable copy for the common constraint checks. Pass `false` to rewrite only the
|
|
49
|
+
* missing-value case and leave every other default alone.
|
|
50
|
+
*
|
|
51
|
+
* @default true
|
|
52
|
+
*/
|
|
53
|
+
constraints?: boolean | Partial<ZodConstraintMessages>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Install a global zod 4 error map that produces human-readable validation messages.
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { z } from 'zod';
|
|
60
|
+
* import { configureZodErrorMap } from '@clicktap/ui/utils/zod';
|
|
61
|
+
*
|
|
62
|
+
* configureZodErrorMap(z);
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* ## Why every app needs this
|
|
66
|
+
*
|
|
67
|
+
* zod 4 dropped zod 3's readable defaults. Verified against zod 4.4.3:
|
|
68
|
+
*
|
|
69
|
+
* ```
|
|
70
|
+
* undefined against z.string() -> "Invalid input: expected string, received undefined"
|
|
71
|
+
* z.string().min(8) -> "Too small: expected string to have >=8 characters"
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* Both are developer output. Neither should reach a visitor.
|
|
75
|
+
*
|
|
76
|
+
* **The first cannot be fixed with per-field messages, which is the part that catches people out.**
|
|
77
|
+
* react-hook-form leaves an untouched field as `undefined` (inputs render `value ?? ''`), so
|
|
78
|
+
* `.min(1, '…')` and `.nonempty({ message })` only ever see an empty string — their message never
|
|
79
|
+
* applies to the missing case. Submitting an untouched form is the common path, so nearly every
|
|
80
|
+
* field in every app is exposed. It has to be fixed globally, which is what this does.
|
|
81
|
+
*
|
|
82
|
+
* ## Why you pass `z` in
|
|
83
|
+
*
|
|
84
|
+
* So this package does not depend on zod. A version is not the issue — two *copies* are: the map
|
|
85
|
+
* has to land on the zod instance your schemas were built with, and an app on zod 3 must be able to
|
|
86
|
+
* install `@clicktap/ui` without a stray zod 4 appearing in its type graph. (It does appear, and it
|
|
87
|
+
* breaks `zodResolver`, which is how this API was arrived at.) Passing the namespace makes the
|
|
88
|
+
* instance explicit and costs a consumer one argument.
|
|
89
|
+
*
|
|
90
|
+
* ## Precedence
|
|
91
|
+
*
|
|
92
|
+
* The map returns `undefined` for anything it does not handle, and zod then falls back to the
|
|
93
|
+
* message attached at the schema. So per-check messages keep winning:
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* z.string().min(8, 'Pick a longer password.') // -> 'Pick a longer password.'
|
|
97
|
+
* z.string().min(8) // -> 'Must be at least 8 characters.'
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* ## Where to call it
|
|
101
|
+
*
|
|
102
|
+
* Once per module registry. In a Next.js app that is **two** places, because server and browser
|
|
103
|
+
* bundles are separate registries and a module evaluated in one is not evaluated in the other:
|
|
104
|
+
*
|
|
105
|
+
* ```ts
|
|
106
|
+
* // app/layout.tsx — SSR and server actions
|
|
107
|
+
* // app/providers.tsx — 'use client', the browser
|
|
108
|
+
* configureZodErrorMap(z);
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* It is a plain idempotent function rather than a side-effecting import on purpose: calling it
|
|
112
|
+
* twice is harmless, and an explicit call is not something a bundler can tree-shake away.
|
|
113
|
+
*/
|
|
114
|
+
export declare function configureZodErrorMap(z: ZodNamespaceLike, options?: ConfigureZodErrorMapOptions): void;
|
|
115
|
+
export default configureZodErrorMap;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const a={stringTooSmall:r=>r===1?"This field is required.":`Must be at least ${r} characters.`,stringTooBig:r=>`Must be ${r} character${r===1?"":"s"} or fewer.`,numberTooSmall:r=>`Must be ${r} or more.`,numberTooBig:r=>`Must be ${r} or less.`,arrayTooSmall:r=>r===1?"Select at least one option.":`Select at least ${r} options.`,arrayTooBig:r=>`Select no more than ${r} option${r===1?"":"s"}.`};function m(r,n={}){const e=n.required??"This field is required.",i=n.constraints!==!1,t={...a,...typeof n.constraints=="object"?n.constraints:{}};r.config({customError:o=>{if(o.input===void 0)return e;if(i){if(o.code==="too_small"&&typeof o.minimum=="number"){if(o.origin==="string")return t.stringTooSmall(o.minimum);if(o.origin==="number")return t.numberTooSmall(o.minimum);if(o.origin==="array")return t.arrayTooSmall(o.minimum)}if(o.code==="too_big"&&typeof o.maximum=="number"){if(o.origin==="string")return t.stringTooBig(o.maximum);if(o.origin==="number")return t.numberTooBig(o.maximum);if(o.origin==="array")return t.arrayTooBig(o.maximum)}}}})}export{m as configureZodErrorMap,m as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { configureZodErrorMap, type ConfigureZodErrorMapOptions, type ZodConstraintMessages, type ZodIssueLike, type ZodNamespaceLike, } from './configureZodErrorMap';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{configureZodErrorMap as e}from"./configureZodErrorMap.js";export{e as configureZodErrorMap};
|
package/utils/zod.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './zod/index';
|
package/utils/zod.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './zod/index.js';
|