@ankhorage/zora 0.5.3 → 0.6.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/CHANGELOG.md +12 -0
- package/dist/components/form/Form.d.ts +4 -0
- package/dist/components/form/Form.d.ts.map +1 -0
- package/dist/components/form/Form.js +27 -0
- package/dist/components/form/Form.js.map +1 -0
- package/dist/components/form/FormActions.d.ts +4 -0
- package/dist/components/form/FormActions.d.ts.map +1 -0
- package/dist/components/form/FormActions.js +12 -0
- package/dist/components/form/FormActions.js.map +1 -0
- package/dist/components/form/FormError.d.ts +4 -0
- package/dist/components/form/FormError.d.ts.map +1 -0
- package/dist/components/form/FormError.js +14 -0
- package/dist/components/form/FormError.js.map +1 -0
- package/dist/components/form/FormField.d.ts +4 -0
- package/dist/components/form/FormField.d.ts.map +1 -0
- package/dist/components/form/FormField.js +74 -0
- package/dist/components/form/FormField.js.map +1 -0
- package/dist/components/form/index.d.ts +8 -0
- package/dist/components/form/index.d.ts.map +1 -0
- package/dist/components/form/index.js +7 -0
- package/dist/components/form/index.js.map +1 -0
- package/dist/components/form/types.d.ts +107 -0
- package/dist/components/form/types.d.ts.map +1 -0
- package/dist/components/form/types.js +2 -0
- package/dist/components/form/types.js.map +1 -0
- package/dist/components/form/useFormController.d.ts +3 -0
- package/dist/components/form/useFormController.d.ts.map +1 -0
- package/dist/components/form/useFormController.js +62 -0
- package/dist/components/form/useFormController.js.map +1 -0
- package/dist/components/form/validation.d.ts +6 -0
- package/dist/components/form/validation.d.ts.map +1 -0
- package/dist/components/form/validation.js +52 -0
- package/dist/components/form/validation.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/patterns/auth/ForgotPasswordForm.d.ts +4 -0
- package/dist/patterns/auth/ForgotPasswordForm.d.ts.map +1 -0
- package/dist/patterns/auth/ForgotPasswordForm.js +31 -0
- package/dist/patterns/auth/ForgotPasswordForm.js.map +1 -0
- package/dist/patterns/auth/OtpForm.d.ts +4 -0
- package/dist/patterns/auth/OtpForm.d.ts.map +1 -0
- package/dist/patterns/auth/OtpForm.js +30 -0
- package/dist/patterns/auth/OtpForm.js.map +1 -0
- package/dist/patterns/auth/SignInForm.d.ts +4 -0
- package/dist/patterns/auth/SignInForm.d.ts.map +1 -0
- package/dist/patterns/auth/SignInForm.js +45 -0
- package/dist/patterns/auth/SignInForm.js.map +1 -0
- package/dist/patterns/auth/SignUpForm.d.ts +4 -0
- package/dist/patterns/auth/SignUpForm.d.ts.map +1 -0
- package/dist/patterns/auth/SignUpForm.js +37 -0
- package/dist/patterns/auth/SignUpForm.js.map +1 -0
- package/dist/patterns/auth/index.d.ts +6 -0
- package/dist/patterns/auth/index.d.ts.map +1 -0
- package/dist/patterns/auth/index.js +5 -0
- package/dist/patterns/auth/index.js.map +1 -0
- package/dist/patterns/auth/types.d.ts +57 -0
- package/dist/patterns/auth/types.d.ts.map +1 -0
- package/dist/patterns/auth/types.js +2 -0
- package/dist/patterns/auth/types.js.map +1 -0
- package/dist/patterns/auth/utils.d.ts +8 -0
- package/dist/patterns/auth/utils.d.ts.map +1 -0
- package/dist/patterns/auth/utils.js +51 -0
- package/dist/patterns/auth/utils.js.map +1 -0
- package/package.json +2 -2
- package/src/components/form/Form.tsx +61 -0
- package/src/components/form/FormActions.tsx +23 -0
- package/src/components/form/FormError.tsx +20 -0
- package/src/components/form/FormField.tsx +128 -0
- package/src/components/form/index.ts +24 -0
- package/src/components/form/types.ts +115 -0
- package/src/components/form/useFormController.ts +105 -0
- package/src/components/form/validation.test.ts +79 -0
- package/src/components/form/validation.ts +83 -0
- package/src/index.ts +43 -2
- package/src/patterns/auth/ForgotPasswordForm.tsx +84 -0
- package/src/patterns/auth/OtpForm.tsx +80 -0
- package/src/patterns/auth/SignInForm.tsx +111 -0
- package/src/patterns/auth/SignUpForm.tsx +76 -0
- package/src/patterns/auth/index.ts +17 -0
- package/src/patterns/auth/types.ts +67 -0
- package/src/patterns/auth/utils.ts +80 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Field, Stack, Text } from '@ankhorage/surface';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import { Input } from '../input';
|
|
5
|
+
import type { FormFieldConfig, FormFieldControlProps, FormFieldProps } from './types';
|
|
6
|
+
import { hasRequiredRule } from './validation';
|
|
7
|
+
|
|
8
|
+
function isControlFieldProps<TName extends string>(
|
|
9
|
+
props: FormFieldProps<TName>,
|
|
10
|
+
): props is FormFieldControlProps<TName> {
|
|
11
|
+
return 'field' in props;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveKeyboardType(field: FormFieldConfig) {
|
|
15
|
+
if (field.keyboardType) {
|
|
16
|
+
return field.keyboardType;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (field.type === 'email') {
|
|
20
|
+
return 'email-address';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (field.type === 'number' || field.type === 'otp') {
|
|
24
|
+
return 'number-pad';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (field.type === 'tel') {
|
|
28
|
+
return 'phone-pad';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (field.type === 'url') {
|
|
32
|
+
return 'url';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveAutoCapitalize(field: FormFieldConfig) {
|
|
39
|
+
if (field.autoCapitalize) {
|
|
40
|
+
return field.autoCapitalize;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (['email', 'password', 'url'].includes(field.type ?? 'text')) {
|
|
44
|
+
return 'none';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveTextContentType(field: FormFieldConfig) {
|
|
51
|
+
if (field.textContentType) {
|
|
52
|
+
return field.textContentType;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (field.type === 'email') {
|
|
56
|
+
return 'emailAddress';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (field.type === 'password') {
|
|
60
|
+
return 'password';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (field.type === 'otp') {
|
|
64
|
+
return 'oneTimeCode';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderLabel(label: React.ReactNode, description: React.ReactNode | undefined) {
|
|
71
|
+
return (
|
|
72
|
+
<Stack gap="xs">
|
|
73
|
+
<Text variant="label" weight="semiBold">
|
|
74
|
+
{label}
|
|
75
|
+
</Text>
|
|
76
|
+
{description ? (
|
|
77
|
+
<Text tone="muted" variant="bodySmall">
|
|
78
|
+
{description}
|
|
79
|
+
</Text>
|
|
80
|
+
) : null}
|
|
81
|
+
</Stack>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function FormField<TName extends string = string>(props: FormFieldProps<TName>) {
|
|
86
|
+
if (!isControlFieldProps(props)) {
|
|
87
|
+
const { label, description, helperText, children, ...fieldProps } = props;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Field {...fieldProps} helperText={helperText} label={renderLabel(label, description)}>
|
|
91
|
+
{children}
|
|
92
|
+
</Field>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { field, value, error, disabled = false, loading = false, onChange, testID } = props;
|
|
97
|
+
const fieldDisabled = disabled || loading || field.disabled;
|
|
98
|
+
const required = field.required ?? hasRequiredRule(field.rules);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Field
|
|
102
|
+
disabled={fieldDisabled}
|
|
103
|
+
errorText={error}
|
|
104
|
+
helperText={field.helperText}
|
|
105
|
+
invalid={Boolean(error)}
|
|
106
|
+
label={renderLabel(field.label, field.description)}
|
|
107
|
+
readOnly={field.readOnly}
|
|
108
|
+
required={required}
|
|
109
|
+
testID={testID ?? field.testID}
|
|
110
|
+
>
|
|
111
|
+
<Input
|
|
112
|
+
accessibilityLabel={typeof field.label === 'string' ? field.label : undefined}
|
|
113
|
+
autoCapitalize={resolveAutoCapitalize(field)}
|
|
114
|
+
autoComplete={field.autoComplete}
|
|
115
|
+
disabled={fieldDisabled}
|
|
116
|
+
invalid={Boolean(error)}
|
|
117
|
+
keyboardType={resolveKeyboardType(field)}
|
|
118
|
+
maxLength={field.maxLength}
|
|
119
|
+
onChangeText={(nextValue) => onChange(field.name, nextValue)}
|
|
120
|
+
placeholder={field.placeholder}
|
|
121
|
+
readOnly={field.readOnly}
|
|
122
|
+
secureTextEntry={field.secureTextEntry ?? field.type === 'password'}
|
|
123
|
+
textContentType={resolveTextContentType(field)}
|
|
124
|
+
value={value}
|
|
125
|
+
/>
|
|
126
|
+
</Field>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { Form } from './Form';
|
|
2
|
+
export { FormActions } from './FormActions';
|
|
3
|
+
export { FormError } from './FormError';
|
|
4
|
+
export { FormField } from './FormField';
|
|
5
|
+
export type {
|
|
6
|
+
FormActionsProps,
|
|
7
|
+
FormErrorProps,
|
|
8
|
+
FormErrors,
|
|
9
|
+
FormFieldConfig,
|
|
10
|
+
FormFieldControlProps,
|
|
11
|
+
FormFieldInputType,
|
|
12
|
+
FormFieldProps,
|
|
13
|
+
FormFieldValue,
|
|
14
|
+
FormFieldWrapperProps,
|
|
15
|
+
FormProps,
|
|
16
|
+
FormValidationErrors,
|
|
17
|
+
FormValidationResult,
|
|
18
|
+
FormValues,
|
|
19
|
+
UseFormControllerOptions,
|
|
20
|
+
UseFormControllerResult,
|
|
21
|
+
ValidationRule,
|
|
22
|
+
} from './types';
|
|
23
|
+
export { useFormController } from './useFormController';
|
|
24
|
+
export { hasRequiredRule, validateField, validateFields, validateValue } from './validation';
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { FieldProps as SurfaceFieldProps } from '@ankhorage/surface';
|
|
2
|
+
import type React from 'react';
|
|
3
|
+
|
|
4
|
+
import type { InputProps } from '../input';
|
|
5
|
+
|
|
6
|
+
export type ValidationRule =
|
|
7
|
+
| { kind: 'required'; message?: string }
|
|
8
|
+
| { kind: 'email'; message?: string }
|
|
9
|
+
| { kind: 'minLength'; value: number; message?: string }
|
|
10
|
+
| { kind: 'pattern'; value: string; message?: string };
|
|
11
|
+
|
|
12
|
+
export type FormFieldValue = string;
|
|
13
|
+
export type FormValues<TName extends string = string> = Record<TName, FormFieldValue>;
|
|
14
|
+
export type FormErrors<TName extends string = string> = Partial<Record<TName, React.ReactNode>>;
|
|
15
|
+
export type FormValidationErrors<TName extends string = string> = Partial<Record<TName, string>>;
|
|
16
|
+
|
|
17
|
+
export type FormFieldInputType = 'email' | 'number' | 'otp' | 'password' | 'tel' | 'text' | 'url';
|
|
18
|
+
|
|
19
|
+
export interface FormFieldConfig<TName extends string = string> {
|
|
20
|
+
name: TName;
|
|
21
|
+
label: React.ReactNode;
|
|
22
|
+
description?: React.ReactNode;
|
|
23
|
+
helperText?: React.ReactNode;
|
|
24
|
+
type?: FormFieldInputType;
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
rules?: readonly ValidationRule[];
|
|
27
|
+
autoCapitalize?: InputProps['autoCapitalize'];
|
|
28
|
+
autoComplete?: InputProps['autoComplete'];
|
|
29
|
+
keyboardType?: InputProps['keyboardType'];
|
|
30
|
+
maxLength?: InputProps['maxLength'];
|
|
31
|
+
readOnly?: boolean;
|
|
32
|
+
required?: boolean;
|
|
33
|
+
secureTextEntry?: boolean;
|
|
34
|
+
textContentType?: InputProps['textContentType'];
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
testID?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FormFieldWrapperProps extends Pick<
|
|
40
|
+
SurfaceFieldProps,
|
|
41
|
+
'children' | 'disabled' | 'errorText' | 'invalid' | 'readOnly' | 'required' | 'testID'
|
|
42
|
+
> {
|
|
43
|
+
label: React.ReactNode;
|
|
44
|
+
description?: React.ReactNode;
|
|
45
|
+
helperText?: React.ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FormFieldControlProps<TName extends string = string> {
|
|
49
|
+
field: FormFieldConfig<TName>;
|
|
50
|
+
value: FormFieldValue;
|
|
51
|
+
onChange: (name: TName, value: FormFieldValue) => void;
|
|
52
|
+
error?: React.ReactNode;
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
loading?: boolean;
|
|
55
|
+
testID?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type FormFieldProps<TName extends string = string> =
|
|
59
|
+
| FormFieldControlProps<TName>
|
|
60
|
+
| FormFieldWrapperProps;
|
|
61
|
+
|
|
62
|
+
export interface FormActionsProps {
|
|
63
|
+
submitLabel?: React.ReactNode;
|
|
64
|
+
loading?: boolean;
|
|
65
|
+
disabled?: boolean;
|
|
66
|
+
onSubmit?: () => void;
|
|
67
|
+
children?: React.ReactNode;
|
|
68
|
+
testID?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface FormErrorProps {
|
|
72
|
+
error?: React.ReactNode;
|
|
73
|
+
testID?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface FormProps<TName extends string = string> {
|
|
77
|
+
fields: readonly FormFieldConfig<TName>[];
|
|
78
|
+
values: FormValues<TName>;
|
|
79
|
+
onChange: (values: FormValues<TName>) => void;
|
|
80
|
+
onSubmit: (values: FormValues<TName>) => void | Promise<void>;
|
|
81
|
+
errors?: FormErrors<TName>;
|
|
82
|
+
error?: React.ReactNode;
|
|
83
|
+
loading?: boolean;
|
|
84
|
+
disabled?: boolean;
|
|
85
|
+
submitLabel?: React.ReactNode;
|
|
86
|
+
actions?: React.ReactNode;
|
|
87
|
+
footer?: React.ReactNode;
|
|
88
|
+
validateOnChange?: boolean;
|
|
89
|
+
testID?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface FormValidationResult<TName extends string = string> {
|
|
93
|
+
errors: FormValidationErrors<TName>;
|
|
94
|
+
valid: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface UseFormControllerOptions<TName extends string = string> {
|
|
98
|
+
fields: readonly FormFieldConfig<TName>[];
|
|
99
|
+
initialValues?: Partial<FormValues<TName>>;
|
|
100
|
+
values?: FormValues<TName>;
|
|
101
|
+
errors?: FormErrors<TName>;
|
|
102
|
+
onChange?: (values: FormValues<TName>) => void;
|
|
103
|
+
onSubmit?: (values: FormValues<TName>) => void | Promise<void>;
|
|
104
|
+
validateOnChange?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface UseFormControllerResult<TName extends string = string> {
|
|
108
|
+
values: FormValues<TName>;
|
|
109
|
+
errors: FormErrors<TName>;
|
|
110
|
+
setValues: (values: FormValues<TName>) => void;
|
|
111
|
+
setFieldValue: (name: TName, value: FormFieldValue) => void;
|
|
112
|
+
validate: () => FormValidationResult<TName>;
|
|
113
|
+
handleSubmit: () => Promise<void>;
|
|
114
|
+
reset: () => void;
|
|
115
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
FormErrors,
|
|
5
|
+
FormValues,
|
|
6
|
+
UseFormControllerOptions,
|
|
7
|
+
UseFormControllerResult,
|
|
8
|
+
} from './types';
|
|
9
|
+
import { validateFields } from './validation';
|
|
10
|
+
|
|
11
|
+
function createInitialValues<TName extends string>(
|
|
12
|
+
fields: readonly { name: TName }[],
|
|
13
|
+
initialValues: Partial<FormValues<TName>> | undefined,
|
|
14
|
+
): FormValues<TName> {
|
|
15
|
+
const values = fields.reduce<Record<string, string>>((nextValues, field) => {
|
|
16
|
+
nextValues[field.name] = initialValues?.[field.name] ?? '';
|
|
17
|
+
return nextValues;
|
|
18
|
+
}, {});
|
|
19
|
+
|
|
20
|
+
return values as FormValues<TName>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useFormController<TName extends string = string>({
|
|
24
|
+
fields,
|
|
25
|
+
initialValues,
|
|
26
|
+
values: controlledValues,
|
|
27
|
+
errors: externalErrors,
|
|
28
|
+
onChange,
|
|
29
|
+
onSubmit,
|
|
30
|
+
validateOnChange = false,
|
|
31
|
+
}: UseFormControllerOptions<TName>): UseFormControllerResult<TName> {
|
|
32
|
+
const initialValuesRef = React.useRef(createInitialValues(fields, initialValues));
|
|
33
|
+
const [internalValues, setInternalValues] = React.useState<FormValues<TName>>(
|
|
34
|
+
initialValuesRef.current,
|
|
35
|
+
);
|
|
36
|
+
const [validationErrors, setValidationErrors] = React.useState<FormErrors<TName>>({});
|
|
37
|
+
|
|
38
|
+
const values = controlledValues ?? internalValues;
|
|
39
|
+
const errors = React.useMemo(
|
|
40
|
+
() => ({
|
|
41
|
+
...validationErrors,
|
|
42
|
+
...externalErrors,
|
|
43
|
+
}),
|
|
44
|
+
[externalErrors, validationErrors],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const setValues = React.useCallback(
|
|
48
|
+
(nextValues: FormValues<TName>) => {
|
|
49
|
+
if (!controlledValues) {
|
|
50
|
+
setInternalValues(nextValues);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
onChange?.(nextValues);
|
|
54
|
+
},
|
|
55
|
+
[controlledValues, onChange],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const validate = React.useCallback(() => {
|
|
59
|
+
const result = validateFields(fields, values);
|
|
60
|
+
setValidationErrors(result.errors);
|
|
61
|
+
return result;
|
|
62
|
+
}, [fields, values]);
|
|
63
|
+
|
|
64
|
+
const setFieldValue = React.useCallback(
|
|
65
|
+
(name: TName, value: string) => {
|
|
66
|
+
const nextValues = {
|
|
67
|
+
...values,
|
|
68
|
+
[name]: value,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
setValues(nextValues);
|
|
72
|
+
|
|
73
|
+
if (validateOnChange) {
|
|
74
|
+
const result = validateFields(fields, nextValues);
|
|
75
|
+
setValidationErrors(result.errors);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
[fields, setValues, validateOnChange, values],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const handleSubmit = React.useCallback(async () => {
|
|
82
|
+
const result = validate();
|
|
83
|
+
|
|
84
|
+
if (!result.valid) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await onSubmit?.(values);
|
|
89
|
+
}, [onSubmit, validate, values]);
|
|
90
|
+
|
|
91
|
+
const reset = React.useCallback(() => {
|
|
92
|
+
setValidationErrors({});
|
|
93
|
+
setValues(initialValuesRef.current);
|
|
94
|
+
}, [setValues]);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
values,
|
|
98
|
+
errors,
|
|
99
|
+
setValues,
|
|
100
|
+
setFieldValue,
|
|
101
|
+
validate,
|
|
102
|
+
handleSubmit,
|
|
103
|
+
reset,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import type { FormFieldConfig, FormValues } from './types';
|
|
4
|
+
import { validateFields, validateValue } from './validation';
|
|
5
|
+
|
|
6
|
+
describe('form validation', () => {
|
|
7
|
+
test('validates required fields', () => {
|
|
8
|
+
expect(validateValue('', [{ kind: 'required' }])).toBe('This field is required.');
|
|
9
|
+
expect(validateValue(' ', [{ kind: 'required', message: 'Required' }])).toBe('Required');
|
|
10
|
+
expect(validateValue('value', [{ kind: 'required' }])).toBeUndefined();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('validates email fields', () => {
|
|
14
|
+
expect(validateValue('not-email', [{ kind: 'email' }])).toBe('Enter a valid email address.');
|
|
15
|
+
expect(validateValue('person@example.com', [{ kind: 'email' }])).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('validates minimum length fields', () => {
|
|
19
|
+
expect(validateValue('short', [{ kind: 'minLength', value: 8 }])).toBe(
|
|
20
|
+
'Enter at least 8 characters.',
|
|
21
|
+
);
|
|
22
|
+
expect(validateValue('long-enough', [{ kind: 'minLength', value: 8 }])).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('validates pattern fields', () => {
|
|
26
|
+
expect(validateValue('abc', [{ kind: 'pattern', value: '^\\d+$' }])).toBe(
|
|
27
|
+
'Enter a valid value.',
|
|
28
|
+
);
|
|
29
|
+
expect(validateValue('123', [{ kind: 'pattern', value: '^\\d+$' }])).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('returns the first failed rule per field', () => {
|
|
33
|
+
expect(validateValue('', [{ kind: 'required' }, { kind: 'email' }])).toBe(
|
|
34
|
+
'This field is required.',
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('validates multiple fields', () => {
|
|
39
|
+
type FieldName = 'email' | 'password';
|
|
40
|
+
const fields: readonly FormFieldConfig<FieldName>[] = [
|
|
41
|
+
{
|
|
42
|
+
name: 'email',
|
|
43
|
+
label: 'Email',
|
|
44
|
+
rules: [{ kind: 'required' }, { kind: 'email' }],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'password',
|
|
48
|
+
label: 'Password',
|
|
49
|
+
rules: [{ kind: 'required' }, { kind: 'minLength', value: 8 }],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
const values: FormValues<FieldName> = {
|
|
53
|
+
email: 'person@example.com',
|
|
54
|
+
password: 'short',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
expect(validateFields(fields, values)).toEqual({
|
|
58
|
+
errors: {
|
|
59
|
+
password: 'Enter at least 8 characters.',
|
|
60
|
+
},
|
|
61
|
+
valid: false,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('returns a successful validation result', () => {
|
|
66
|
+
const fields: readonly FormFieldConfig<'email'>[] = [
|
|
67
|
+
{
|
|
68
|
+
name: 'email',
|
|
69
|
+
label: 'Email',
|
|
70
|
+
rules: [{ kind: 'required' }, { kind: 'email' }],
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
expect(validateFields(fields, { email: 'person@example.com' })).toEqual({
|
|
75
|
+
errors: {},
|
|
76
|
+
valid: true,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FormFieldConfig,
|
|
3
|
+
FormValidationErrors,
|
|
4
|
+
FormValidationResult,
|
|
5
|
+
FormValues,
|
|
6
|
+
ValidationRule,
|
|
7
|
+
} from './types';
|
|
8
|
+
|
|
9
|
+
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
10
|
+
|
|
11
|
+
export function hasRequiredRule(rules: readonly ValidationRule[] | undefined): boolean {
|
|
12
|
+
return rules?.some((rule) => rule.kind === 'required') ?? false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateValue(
|
|
16
|
+
value: string,
|
|
17
|
+
rules: readonly ValidationRule[] | undefined,
|
|
18
|
+
): string | undefined {
|
|
19
|
+
if (!rules?.length) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const rule of rules) {
|
|
24
|
+
const normalizedValue = value.trim();
|
|
25
|
+
|
|
26
|
+
if (rule.kind === 'required' && normalizedValue.length === 0) {
|
|
27
|
+
return rule.message ?? 'This field is required.';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (
|
|
31
|
+
rule.kind === 'email' &&
|
|
32
|
+
normalizedValue.length > 0 &&
|
|
33
|
+
!emailPattern.test(normalizedValue)
|
|
34
|
+
) {
|
|
35
|
+
return rule.message ?? 'Enter a valid email address.';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (rule.kind === 'minLength' && value.length < rule.value) {
|
|
39
|
+
return rule.message ?? `Enter at least ${rule.value} characters.`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (rule.kind === 'pattern') {
|
|
43
|
+
try {
|
|
44
|
+
const pattern = new RegExp(rule.value);
|
|
45
|
+
|
|
46
|
+
if (normalizedValue.length > 0 && !pattern.test(value)) {
|
|
47
|
+
return rule.message ?? 'Enter a valid value.';
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
return rule.message ?? 'Enter a valid value.';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function validateField<TName extends string>(
|
|
59
|
+
field: FormFieldConfig<TName>,
|
|
60
|
+
values: FormValues<TName>,
|
|
61
|
+
): string | undefined {
|
|
62
|
+
return validateValue(values[field.name], field.rules);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validateFields<TName extends string>(
|
|
66
|
+
fields: readonly FormFieldConfig<TName>[],
|
|
67
|
+
values: FormValues<TName>,
|
|
68
|
+
): FormValidationResult<TName> {
|
|
69
|
+
const errors: FormValidationErrors<TName> = {};
|
|
70
|
+
|
|
71
|
+
for (const field of fields) {
|
|
72
|
+
const error = validateField(field, values);
|
|
73
|
+
|
|
74
|
+
if (error) {
|
|
75
|
+
errors[field.name] = error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
errors,
|
|
81
|
+
valid: Object.keys(errors).length === 0,
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,35 @@ export type { CheckboxGroupOption, CheckboxGroupProps, CheckboxProps } from './c
|
|
|
8
8
|
export { Checkbox, CheckboxGroup } from './components/checkbox';
|
|
9
9
|
export type { DrawerProps } from './components/drawer';
|
|
10
10
|
export { Drawer } from './components/drawer';
|
|
11
|
+
export type {
|
|
12
|
+
FormActionsProps,
|
|
13
|
+
FormErrorProps,
|
|
14
|
+
FormErrors,
|
|
15
|
+
FormFieldConfig,
|
|
16
|
+
FormFieldControlProps,
|
|
17
|
+
FormFieldInputType,
|
|
18
|
+
FormFieldProps,
|
|
19
|
+
FormFieldValue,
|
|
20
|
+
FormFieldWrapperProps,
|
|
21
|
+
FormProps,
|
|
22
|
+
FormValidationErrors,
|
|
23
|
+
FormValidationResult,
|
|
24
|
+
FormValues,
|
|
25
|
+
UseFormControllerOptions,
|
|
26
|
+
UseFormControllerResult,
|
|
27
|
+
ValidationRule,
|
|
28
|
+
} from './components/form';
|
|
29
|
+
export {
|
|
30
|
+
Form,
|
|
31
|
+
FormActions,
|
|
32
|
+
FormError,
|
|
33
|
+
FormField,
|
|
34
|
+
hasRequiredRule,
|
|
35
|
+
useFormController,
|
|
36
|
+
validateField,
|
|
37
|
+
validateFields,
|
|
38
|
+
validateValue,
|
|
39
|
+
} from './components/form';
|
|
11
40
|
export type { IconProps } from './components/icon';
|
|
12
41
|
export { Icon } from './components/icon';
|
|
13
42
|
export type { IconButtonProps } from './components/icon-button';
|
|
@@ -42,6 +71,20 @@ export type { SidebarLayoutProps } from './layout/sidebar-layout';
|
|
|
42
71
|
export { SidebarLayout } from './layout/sidebar-layout';
|
|
43
72
|
export type { TopbarLayoutProps } from './layout/topbar-layout';
|
|
44
73
|
export { TopbarLayout } from './layout/topbar-layout';
|
|
74
|
+
export type {
|
|
75
|
+
AuthFormBaseProps,
|
|
76
|
+
AuthIdentifierKind,
|
|
77
|
+
ForgotPasswordFormProps,
|
|
78
|
+
ForgotPasswordFormValues,
|
|
79
|
+
OtpFormProps,
|
|
80
|
+
OtpFormValues,
|
|
81
|
+
SignInFormProps,
|
|
82
|
+
SignInFormValues,
|
|
83
|
+
SignUpFormField,
|
|
84
|
+
SignUpFormProps,
|
|
85
|
+
SignUpFormValues,
|
|
86
|
+
} from './patterns/auth';
|
|
87
|
+
export { ForgotPasswordForm, OtpForm, SignInForm, SignUpForm } from './patterns/auth';
|
|
45
88
|
export type {
|
|
46
89
|
CollectionEditorProps,
|
|
47
90
|
CollectionEditorRenderItemProps,
|
|
@@ -53,8 +96,6 @@ export type { DisclosureSectionProps } from './patterns/disclosure-section';
|
|
|
53
96
|
export { DisclosureSection } from './patterns/disclosure-section';
|
|
54
97
|
export type { EmptyStateAction, EmptyStateProps } from './patterns/empty-state';
|
|
55
98
|
export { EmptyState } from './patterns/empty-state';
|
|
56
|
-
export type { FormFieldProps } from './patterns/form-field';
|
|
57
|
-
export { FormField } from './patterns/form-field';
|
|
58
99
|
export type { InspectorFieldProps } from './patterns/inspector-field';
|
|
59
100
|
export { InspectorField } from './patterns/inspector-field';
|
|
60
101
|
export type { NoticeProps } from './patterns/notice';
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Stack } from '@ankhorage/surface';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import { Button } from '../../components/button';
|
|
5
|
+
import { Form, type FormFieldConfig, type FormValues } from '../../components/form';
|
|
6
|
+
import type { ForgotPasswordFormProps } from './types';
|
|
7
|
+
import {
|
|
8
|
+
defaultIdentifiers,
|
|
9
|
+
normalizeIdentifierKind,
|
|
10
|
+
resolveIdentifierLabel,
|
|
11
|
+
resolveIdentifierRules,
|
|
12
|
+
resolveIdentifierType,
|
|
13
|
+
} from './utils';
|
|
14
|
+
|
|
15
|
+
type ForgotPasswordFieldName = 'identifier';
|
|
16
|
+
|
|
17
|
+
export function ForgotPasswordForm({
|
|
18
|
+
identifiers = defaultIdentifiers,
|
|
19
|
+
identifierLabel,
|
|
20
|
+
signInLabel = 'Sign in',
|
|
21
|
+
loading = false,
|
|
22
|
+
disabled = false,
|
|
23
|
+
error,
|
|
24
|
+
submitLabel = 'Send code',
|
|
25
|
+
onSubmit,
|
|
26
|
+
onSignIn,
|
|
27
|
+
testID,
|
|
28
|
+
}: ForgotPasswordFormProps) {
|
|
29
|
+
const [values, setValues] = React.useState<FormValues<ForgotPasswordFieldName>>({
|
|
30
|
+
identifier: '',
|
|
31
|
+
});
|
|
32
|
+
const fields = React.useMemo<readonly FormFieldConfig<ForgotPasswordFieldName>[]>(
|
|
33
|
+
() => [
|
|
34
|
+
{
|
|
35
|
+
name: 'identifier',
|
|
36
|
+
label: identifierLabel ?? resolveIdentifierLabel(identifiers),
|
|
37
|
+
type: resolveIdentifierType(identifiers),
|
|
38
|
+
autoCapitalize: 'none',
|
|
39
|
+
rules: resolveIdentifierRules(identifiers),
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
[identifierLabel, identifiers],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const handleSubmit = React.useCallback(
|
|
46
|
+
(formValues: FormValues<ForgotPasswordFieldName>) =>
|
|
47
|
+
onSubmit({
|
|
48
|
+
identifier: formValues.identifier.trim(),
|
|
49
|
+
identifierKind: normalizeIdentifierKind(formValues.identifier, identifiers),
|
|
50
|
+
}),
|
|
51
|
+
[identifiers, onSubmit],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Form
|
|
56
|
+
actions={
|
|
57
|
+
onSignIn ? (
|
|
58
|
+
<Stack direction="row" gap="s" wrap="wrap">
|
|
59
|
+
<Button
|
|
60
|
+
disabled={disabled || loading}
|
|
61
|
+
emphasis="ghost"
|
|
62
|
+
onPress={() => {
|
|
63
|
+
void onSignIn();
|
|
64
|
+
}}
|
|
65
|
+
size="s"
|
|
66
|
+
tone="neutral"
|
|
67
|
+
>
|
|
68
|
+
{signInLabel}
|
|
69
|
+
</Button>
|
|
70
|
+
</Stack>
|
|
71
|
+
) : undefined
|
|
72
|
+
}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
error={error}
|
|
75
|
+
fields={fields}
|
|
76
|
+
loading={loading}
|
|
77
|
+
onChange={setValues}
|
|
78
|
+
onSubmit={handleSubmit}
|
|
79
|
+
submitLabel={submitLabel}
|
|
80
|
+
testID={testID}
|
|
81
|
+
values={values}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}
|