Skip to content

Commit ce76ea1

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

File tree

10 files changed

+204
-2
lines changed

10 files changed

+204
-2
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
import { z } from 'zod';
15+
16+
const schema = z.object({
17+
password: z
18+
.string()
19+
.min(8)
20+
.regex(/[A-Z]/, 'uppercase')
21+
.regex(/[0-9]/, 'digit')
22+
.regex(/[!@#$%^&*]/, 'special'),
23+
});
24+
25+
const configuredForms = configureForms({
26+
extendFieldMetadata(metadata) {
27+
return {
28+
get inputProps() {
29+
return {
30+
// '^(?=.*(?:[A-Z]))(?=.*(?:[0-9]))(?=.*(?:[!@#$%^&*])).*$'
31+
pattern: metadata.pattern,
32+
minLength: metadata.minLength,
33+
name: metadata.name,
34+
required: metadata.required,
35+
} satisfies InputHTMLAttributes<HTMLInputElement>;
36+
},
37+
};
38+
},
39+
getConstraints,
40+
});
41+
```

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/tests/util.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from 'vitest';
2-
import { deepEqual } from '../util';
2+
import { combinePatterns, deepEqual } from '../util';
33

44
test('deepEqual', () => {
55
expect(deepEqual(null, null)).toBe(true);
@@ -22,3 +22,21 @@ test('deepEqual', () => {
2222
false,
2323
);
2424
});
25+
26+
test('combinePatterns', () => {
27+
// Empty pattern
28+
expect(combinePatterns([])).toBeUndefined();
29+
30+
// Single pattern
31+
expect(combinePatterns([/[A-Z]/])).toBe('^(?=.*(?:[A-Z])).*$');
32+
expect(combinePatterns([/^[A-Z]+$/])).toBe('^(?=.*(?:^[A-Z]+$)).*$');
33+
34+
// Multiple patterns without flags
35+
expect(combinePatterns([/[A-Z]/, /[0-9]/])).toBe(
36+
'^(?=.*(?:[A-Z]))(?=.*(?:[0-9])).*$',
37+
);
38+
39+
// Patterns with unsupported flags
40+
expect(combinePatterns([/[A-Z]/g])).toBeUndefined();
41+
expect(combinePatterns([/[A-Z]/, /[0-9]/i])).toBeUndefined();
42+
});

packages/conform-dom/util.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,25 @@ 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+
*
120+
* For multiple patterns, combines them using lookahead assertions that must all match.
121+
*
122+
* Returns undefined if any pattern has flags, since HTML5 pattern attributes don't support flags.
123+
* HTML pattern attributes implicitly run with the `v` flag, so we strip it (and `u`).
124+
*
125+
* Example: [/[A-Z]/, /[0-9]/] -> '^(?=.*(?:[A-Z]))(?=.*(?:[0-9])).*$'
126+
*/
127+
export function combinePatterns(patterns: RegExp[]): string | undefined {
128+
if (patterns.length === 0) {
129+
return undefined;
130+
}
131+
132+
if (patterns.some((p) => p.flags.replace(/[uv]/g, ''))) {
133+
return undefined;
134+
}
135+
136+
return `^${patterns.map((p) => `(?=.*(?:${p.source}))`).join('')}.*$`;
137+
}

packages/conform-valibot/constraint.ts

Lines changed: 17 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,22 @@ 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+
const pattern = combinePatterns(
160+
regexValidators.map(
161+
// @ts-expect-error
162+
(v) => (v as { requirement: RegExp }).requirement,
163+
),
164+
);
165+
if (pattern) {
166+
constraint.pattern = pattern;
167+
}
168+
}
152169
} else if (
153170
schema.type === 'optional' ||
154171
schema.type === 'nullish' ||

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

Lines changed: 35 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,38 @@ 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+
const constraint = getValibotConstraint(schema);
391+
392+
expect(constraint.empty?.required).toBe(true);
393+
expect(constraint.singlePattern?.required).toBe(true);
394+
expect(constraint.multiplePatterns?.required).toBe(true);
395+
396+
expect(constraint.singlePattern?.pattern).toBe('^(?=.*(?:^[A-Z]+$)).*$');
397+
expect(constraint.multiplePatterns?.pattern).toBe(
398+
'^(?=.*(?:[A-Z]))(?=.*(?:[0-9])).*$',
399+
);
400+
401+
const singleRegex = new RegExp(constraint.singlePattern?.pattern ?? '');
402+
expect(singleRegex.test('ABC')).toBe(true);
403+
expect(singleRegex.test('abc')).toBe(false);
404+
expect(singleRegex.test('ABC123')).toBe(false);
405+
406+
const multiRegex = new RegExp(constraint.multiplePatterns?.pattern ?? '');
407+
expect(multiRegex.test('ABC')).toBe(false);
408+
expect(multiRegex.test('123')).toBe(false);
409+
expect(multiRegex.test('ABC123')).toBe(true);
410+
expect(multiRegex.test('abc123')).toBe(false);
411+
});
377412
});

packages/conform-zod/default/constraint.ts

Lines changed: 12 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,16 @@ 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+
const pattern = combinePatterns(regexChecks.map((c) => c.regex));
140+
if (pattern) {
141+
constraint.pattern = pattern;
142+
}
143+
}
132144
} else if (def.typeName === 'ZodOptional') {
133145
constraint.required = false;
134146
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: 7 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,12 @@ 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+
const pattern = combinePatterns([..._schema._zod.bag.patterns]);
137+
if (pattern) {
138+
constraint.pattern = pattern;
139+
}
140+
}
134141
} else if (def.type === 'optional') {
135142
constraint.required = false;
136143
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)