Skip to content

Commit 18ff2e4

Browse files
feat: set pattern constraint for regular expressions
1 parent a9481c5 commit 18ff2e4

File tree

9 files changed

+165
-1
lines changed

9 files changed

+165
-1
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@conform-to/dom': minor
3+
'@conform-to/react': minor
4+
'@conform-to/valibot': minor
5+
'@conform-to/zod': minor
6+
---
7+
8+
Mapped regex constraints to `pattern`, enabling advanced client-side validation.
9+
10+
```tsx
11+
import { configureForms } from '@conform-to/react/future';
12+
import { getConstraints } from '@conform-to/zod/v4/future';
13+
import type { InputHTMLAttributes } from 'react';
14+
15+
const schema = z.object({
16+
password: z
17+
.string()
18+
.min(8)
19+
.regex(/[A-Z]/, 'uppercase')
20+
.regex(/[0-9]/, 'digit')
21+
.regex(/[!@#$%^&*]/, 'special'),
22+
});
23+
24+
const configuredForms = configureForms({
25+
extendFieldMetadata(metadata) {
26+
return {
27+
get inputProps() {
28+
return {
29+
// '^(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*]).*$'
30+
pattern: metadata.pattern,
31+
minLength: metadata.minLength,
32+
name: metadata.name,
33+
required: metadata.required,
34+
} satisfies InputHTMLAttributes<HTMLInputElement>;
35+
},
36+
};
37+
},
38+
getConstraints,
39+
});
40+
```

packages/conform-dom/future/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export {
2525
getFieldValue,
2626
normalizeFormError,
2727
} from '../formdata';
28-
export { isPlainObject, deepEqual } from '../util';
28+
export { isPlainObject, deepEqual, combinePatterns } from '../util';
2929
export {
3030
isFieldElement,
3131
isGlobalInstance,

packages/conform-dom/util.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,23 @@ export function getTypeName(value: unknown): string {
113113
}
114114
return typeof value;
115115
}
116+
117+
/**
118+
* Combines multiple regex patterns into a single HTML pattern attribute string.
119+
* For a single pattern, returns the pattern source as-is.
120+
* For multiple patterns, combines them using lookahead assertions that must ALL match.
121+
*
122+
* Example: ['[A-Z]', '[0-9]'] -> '^(?=.*[A-Z])(?=.*[0-9]).*$'
123+
*/
124+
export function combinePatterns(patterns: RegExp[]): string {
125+
if (patterns.length === 0) {
126+
return '';
127+
}
128+
129+
const first = patterns[0];
130+
if (first && patterns.length === 1) {
131+
return first.source;
132+
}
133+
134+
return `^${patterns.map((p) => `(?=.*${p.source})`).join('')}.*$`;
135+
}

packages/conform-valibot/constraint.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Constraint } from '@conform-to/dom';
22
import { getPaths, formatPaths, getRelativePath } from '@conform-to/dom';
3+
import { combinePatterns } from '@conform-to/dom/future';
34
import type { GenericSchema, GenericSchemaAsync } from 'valibot';
45

56
const keys: Array<keyof Constraint> = [
@@ -149,6 +150,19 @@ export function getValibotConstraint<
149150
if (maxLength && 'requirement' in maxLength) {
150151
constraint.maxLength = maxLength.requirement as number;
151152
}
153+
// @ts-expect-error
154+
const regexValidators = schema.pipe?.filter(
155+
// @ts-expect-error
156+
(v) => 'type' in v && v.type === 'regex',
157+
);
158+
if (regexValidators?.length) {
159+
constraint.pattern = combinePatterns(
160+
regexValidators.map(
161+
// @ts-expect-error
162+
(v) => (v as { requirement: RegExp }).requirement,
163+
),
164+
);
165+
}
152166
} else if (
153167
schema.type === 'optional' ||
154168
schema.type === 'nullish' ||

packages/conform-valibot/tests/constraint.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
objectWithRest,
2525
optional,
2626
pipe,
27+
regex,
2728
strictObject,
2829
string,
2930
tuple,
@@ -374,4 +375,30 @@ describe('constraint', () => {
374375
constraint['conditions[0].conditions[1].conditions[2].type'],
375376
).toEqual({ required: true });
376377
});
378+
379+
test('regex patterns', () => {
380+
const schema = object({
381+
empty: string(),
382+
singlePattern: pipe(string(), regex(/^[A-Z]+$/)),
383+
multiplePatterns: pipe(
384+
string(),
385+
regex(/[A-Z]/, 'uppercase'),
386+
regex(/[0-9]/, 'digit'),
387+
),
388+
});
389+
390+
expect(getValibotConstraint(schema)).toEqual({
391+
empty: {
392+
required: true,
393+
},
394+
singlePattern: {
395+
required: true,
396+
pattern: '^[A-Z]+$',
397+
},
398+
multiplePatterns: {
399+
required: true,
400+
pattern: '^(?=.*[A-Z])(?=.*[0-9]).*$',
401+
},
402+
});
403+
});
377404
});

packages/conform-zod/default/constraint.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { Constraint } from '@conform-to/dom';
22
import { getPaths, formatPaths, getRelativePath } from '@conform-to/dom';
3+
import { combinePatterns } from '@conform-to/dom/future';
34
import type {
45
ZodTypeAny,
56
ZodFirstPartySchemaTypes,
67
ZodNumber,
78
ZodString,
9+
ZodStringCheck,
810
} from 'zod';
911

1012
const keys: Array<keyof Constraint> = [
@@ -129,6 +131,13 @@ export function getZodConstraint(
129131
if (_schema.maxLength !== null) {
130132
constraint.maxLength = _schema.maxLength;
131133
}
134+
const regexChecks = def.checks.filter(
135+
(check): check is ZodStringCheck & { kind: 'regex' } =>
136+
check.kind === 'regex',
137+
);
138+
if (regexChecks.length > 0) {
139+
constraint.pattern = combinePatterns(regexChecks.map((c) => c.regex));
140+
}
132141
} else if (def.typeName === 'ZodOptional') {
133142
constraint.required = false;
134143
updateConstraint(def.innerType, data, name);

packages/conform-zod/default/tests/constraint.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,4 +360,29 @@ describe('getZodConstraint', () => {
360360
constraint['conditions[0].conditions[1].conditions[2].type'],
361361
).toEqual({ required: true });
362362
});
363+
364+
test('regex patterns', () => {
365+
const schema = z.object({
366+
empty: z.string(),
367+
singlePattern: z.string().regex(/^[A-Z]+$/),
368+
multiplePatterns: z
369+
.string()
370+
.regex(/[A-Z]/, 'uppercase')
371+
.regex(/[0-9]/, 'digit'),
372+
});
373+
374+
expect(getZodConstraint(schema)).toEqual({
375+
empty: {
376+
required: true,
377+
},
378+
singlePattern: {
379+
required: true,
380+
pattern: '^[A-Z]+$',
381+
},
382+
multiplePatterns: {
383+
required: true,
384+
pattern: '^(?=.*[A-Z])(?=.*[0-9]).*$',
385+
},
386+
});
387+
});
363388
});

packages/conform-zod/v4/constraint.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Constraint } from '@conform-to/dom';
22
import { getPaths, formatPaths, getRelativePath } from '@conform-to/dom';
3+
import { combinePatterns } from '@conform-to/dom/future';
34
import {
45
$ZodType,
56
$ZodTypes,
@@ -131,6 +132,9 @@ export function getZodConstraint(schema: $ZodType): Record<string, Constraint> {
131132
if (_schema._zod.bag.maximum != null) {
132133
constraint.maxLength = _schema._zod.bag.maximum;
133134
}
135+
if (_schema._zod.bag.patterns?.size) {
136+
constraint.pattern = combinePatterns([..._schema._zod.bag.patterns]);
137+
}
134138
} else if (def.type === 'optional') {
135139
constraint.required = false;
136140
updateConstraint(def.innerType, data, name);

packages/conform-zod/v4/tests/constraint.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,4 +457,29 @@ describe('getZodConstraint', () => {
457457
required: true,
458458
});
459459
});
460+
461+
test('regex patterns', () => {
462+
const schema = z.object({
463+
empty: z.string(),
464+
singlePattern: z.string().regex(/^[A-Z]+$/),
465+
multiplePatterns: z
466+
.string()
467+
.regex(/[A-Z]/, 'uppercase')
468+
.regex(/[0-9]/, 'digit'),
469+
});
470+
471+
expect(getZodConstraint(schema)).toEqual({
472+
empty: {
473+
required: true,
474+
},
475+
singlePattern: {
476+
required: true,
477+
pattern: '^[A-Z]+$',
478+
},
479+
multiplePatterns: {
480+
required: true,
481+
pattern: '^(?=.*[A-Z])(?=.*[0-9]).*$',
482+
},
483+
});
484+
});
460485
});

0 commit comments

Comments
 (0)