@pickle-ui/react-native 1.0.0-beta.0 → 1.0.0-beta.2
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/README.md +70 -11
- package/dist/components/Badge.d.ts +6 -3
- package/dist/components/Badge.d.ts.map +1 -1
- package/dist/components/Badge.js +36 -7
- package/dist/components/Badge.js.map +1 -1
- package/dist/components/Button.d.ts +11 -4
- package/dist/components/Button.d.ts.map +1 -1
- package/dist/components/Button.js +100 -15
- package/dist/components/Button.js.map +1 -1
- package/dist/components/Card.d.ts +15 -5
- package/dist/components/Card.d.ts.map +1 -1
- package/dist/components/Card.js +28 -10
- package/dist/components/Card.js.map +1 -1
- package/dist/components/Flex.d.ts +25 -0
- package/dist/components/Flex.d.ts.map +1 -0
- package/dist/components/Flex.js +24 -0
- package/dist/components/Flex.js.map +1 -0
- package/dist/components/Grid.d.ts +36 -0
- package/dist/components/Grid.d.ts.map +1 -0
- package/dist/components/Grid.js +68 -0
- package/dist/components/Grid.js.map +1 -0
- package/dist/components/Separator.d.ts +11 -0
- package/dist/components/Separator.d.ts.map +1 -0
- package/dist/components/Separator.js +12 -0
- package/dist/components/Separator.js.map +1 -0
- package/dist/components/Spinner.d.ts +14 -0
- package/dist/components/Spinner.d.ts.map +1 -0
- package/dist/components/Spinner.js +41 -0
- package/dist/components/Spinner.js.map +1 -0
- package/dist/components/Text.d.ts +7 -3
- package/dist/components/Text.d.ts.map +1 -1
- package/dist/components/Text.js +23 -11
- package/dist/components/Text.js.map +1 -1
- package/dist/components/XStack.d.ts +4 -0
- package/dist/components/XStack.d.ts.map +1 -0
- package/dist/components/XStack.js +6 -0
- package/dist/components/XStack.js.map +1 -0
- package/dist/components/YStack.d.ts +4 -0
- package/dist/components/YStack.d.ts.map +1 -0
- package/dist/components/YStack.js +6 -0
- package/dist/components/YStack.js.map +1 -0
- package/dist/components/_internal/InputFrame.d.ts +13 -0
- package/dist/components/_internal/InputFrame.d.ts.map +1 -0
- package/dist/components/_internal/InputFrame.js +14 -0
- package/dist/components/_internal/InputFrame.js.map +1 -0
- package/dist/components/_internal/StoryItem.d.ts +6 -0
- package/dist/components/_internal/StoryItem.d.ts.map +1 -0
- package/dist/components/_internal/StoryItem.js +9 -0
- package/dist/components/_internal/StoryItem.js.map +1 -0
- package/dist/components/_internal/layout.d.ts +79 -0
- package/dist/components/_internal/layout.d.ts.map +1 -0
- package/dist/components/_internal/layout.js +73 -0
- package/dist/components/_internal/layout.js.map +1 -0
- package/dist/components/_internal/useControllableState.d.ts +7 -0
- package/dist/components/_internal/useControllableState.d.ts.map +1 -0
- package/dist/components/_internal/useControllableState.js +17 -0
- package/dist/components/_internal/useControllableState.js.map +1 -0
- package/dist/components/form/Checkbox.d.ts +26 -0
- package/dist/components/form/Checkbox.d.ts.map +1 -0
- package/dist/components/form/Checkbox.js +41 -0
- package/dist/components/form/Checkbox.js.map +1 -0
- package/dist/components/form/Form.context.d.ts +34 -0
- package/dist/components/form/Form.context.d.ts.map +1 -0
- package/dist/components/form/Form.context.js +53 -0
- package/dist/components/form/Form.context.js.map +1 -0
- package/dist/components/form/Form.d.ts +33 -0
- package/dist/components/form/Form.d.ts.map +1 -0
- package/dist/components/form/Form.js +36 -0
- package/dist/components/form/Form.js.map +1 -0
- package/dist/components/form/FormCheckbox.d.ts +7 -0
- package/dist/components/form/FormCheckbox.d.ts.map +1 -0
- package/dist/components/form/FormCheckbox.js +24 -0
- package/dist/components/form/FormCheckbox.js.map +1 -0
- package/dist/components/form/FormDescription.d.ts +4 -0
- package/dist/components/form/FormDescription.d.ts.map +1 -0
- package/dist/components/form/FormDescription.js +7 -0
- package/dist/components/form/FormDescription.js.map +1 -0
- package/dist/components/form/FormError.d.ts +5 -0
- package/dist/components/form/FormError.d.ts.map +1 -0
- package/dist/components/form/FormError.js +8 -0
- package/dist/components/form/FormError.js.map +1 -0
- package/dist/components/form/FormField.d.ts +17 -0
- package/dist/components/form/FormField.d.ts.map +1 -0
- package/dist/components/form/FormField.js +36 -0
- package/dist/components/form/FormField.js.map +1 -0
- package/dist/components/form/FormInput.d.ts +10 -0
- package/dist/components/form/FormInput.d.ts.map +1 -0
- package/dist/components/form/FormInput.js +36 -0
- package/dist/components/form/FormInput.js.map +1 -0
- package/dist/components/form/FormInputGroup.d.ts +8 -0
- package/dist/components/form/FormInputGroup.d.ts.map +1 -0
- package/dist/components/form/FormInputGroup.js +18 -0
- package/dist/components/form/FormInputGroup.js.map +1 -0
- package/dist/components/form/FormLabel.d.ts +4 -0
- package/dist/components/form/FormLabel.d.ts.map +1 -0
- package/dist/components/form/FormLabel.js +7 -0
- package/dist/components/form/FormLabel.js.map +1 -0
- package/dist/components/form/FormRadioGroup.d.ts +11 -0
- package/dist/components/form/FormRadioGroup.d.ts.map +1 -0
- package/dist/components/form/FormRadioGroup.js +24 -0
- package/dist/components/form/FormRadioGroup.js.map +1 -0
- package/dist/components/form/FormRoot.d.ts +13 -0
- package/dist/components/form/FormRoot.d.ts.map +1 -0
- package/dist/components/form/FormRoot.js +19 -0
- package/dist/components/form/FormRoot.js.map +1 -0
- package/dist/components/form/FormTextarea.d.ts +7 -0
- package/dist/components/form/FormTextarea.d.ts.map +1 -0
- package/dist/components/form/FormTextarea.js +27 -0
- package/dist/components/form/FormTextarea.js.map +1 -0
- package/dist/components/form/Input.d.ts +17 -0
- package/dist/components/form/Input.d.ts.map +1 -0
- package/dist/components/form/Input.js +32 -0
- package/dist/components/form/Input.js.map +1 -0
- package/dist/components/form/InputGroup.context.d.ts +10 -0
- package/dist/components/form/InputGroup.context.d.ts.map +1 -0
- package/dist/components/form/InputGroup.context.js +8 -0
- package/dist/components/form/InputGroup.context.js.map +1 -0
- package/dist/components/form/InputGroup.d.ts +31 -0
- package/dist/components/form/InputGroup.d.ts.map +1 -0
- package/dist/components/form/InputGroup.js +123 -0
- package/dist/components/form/InputGroup.js.map +1 -0
- package/dist/components/form/RadioGroup.d.ts +29 -0
- package/dist/components/form/RadioGroup.d.ts.map +1 -0
- package/dist/components/form/RadioGroup.js +42 -0
- package/dist/components/form/RadioGroup.js.map +1 -0
- package/dist/components/form/Textarea.d.ts +19 -0
- package/dist/components/form/Textarea.d.ts.map +1 -0
- package/dist/components/form/Textarea.js +26 -0
- package/dist/components/form/Textarea.js.map +1 -0
- package/dist/entries/card.d.ts +1 -1
- package/dist/entries/card.d.ts.map +1 -1
- package/dist/entries/checkbox.d.ts +3 -0
- package/dist/entries/checkbox.d.ts.map +1 -0
- package/dist/entries/checkbox.js +2 -0
- package/dist/entries/checkbox.js.map +1 -0
- package/dist/entries/flex.d.ts +3 -0
- package/dist/entries/flex.d.ts.map +1 -0
- package/dist/entries/flex.js +2 -0
- package/dist/entries/flex.js.map +1 -0
- package/dist/entries/form.d.ts +3 -0
- package/dist/entries/form.d.ts.map +1 -0
- package/dist/entries/form.js +2 -0
- package/dist/entries/form.js.map +1 -0
- package/dist/entries/grid.d.ts +3 -0
- package/dist/entries/grid.d.ts.map +1 -0
- package/dist/entries/grid.js +2 -0
- package/dist/entries/grid.js.map +1 -0
- package/dist/entries/input-group.d.ts +3 -0
- package/dist/entries/input-group.d.ts.map +1 -0
- package/dist/entries/input-group.js +2 -0
- package/dist/entries/input-group.js.map +1 -0
- package/dist/entries/input.d.ts +3 -0
- package/dist/entries/input.d.ts.map +1 -0
- package/dist/entries/input.js +2 -0
- package/dist/entries/input.js.map +1 -0
- package/dist/entries/radio-group.d.ts +3 -0
- package/dist/entries/radio-group.d.ts.map +1 -0
- package/dist/entries/radio-group.js +2 -0
- package/dist/entries/radio-group.js.map +1 -0
- package/dist/entries/separator.d.ts +3 -0
- package/dist/entries/separator.d.ts.map +1 -0
- package/dist/entries/separator.js +2 -0
- package/dist/entries/separator.js.map +1 -0
- package/dist/entries/spinner.d.ts +3 -0
- package/dist/entries/spinner.d.ts.map +1 -0
- package/dist/entries/spinner.js +2 -0
- package/dist/entries/spinner.js.map +1 -0
- package/dist/entries/textarea.d.ts +3 -0
- package/dist/entries/textarea.d.ts.map +1 -0
- package/dist/entries/textarea.js +2 -0
- package/dist/entries/textarea.js.map +1 -0
- package/dist/entries/theme.d.ts +3 -0
- package/dist/entries/theme.d.ts.map +1 -0
- package/dist/entries/theme.js +2 -0
- package/dist/entries/theme.js.map +1 -0
- package/dist/entries/x-stack.d.ts +3 -0
- package/dist/entries/x-stack.d.ts.map +1 -0
- package/dist/entries/x-stack.js +2 -0
- package/dist/entries/x-stack.js.map +1 -0
- package/dist/entries/y-stack.d.ts +3 -0
- package/dist/entries/y-stack.d.ts.map +1 -0
- package/dist/entries/y-stack.js +2 -0
- package/dist/entries/y-stack.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.d.ts.map +1 -1
- package/dist/lib/cn.js +13 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +10 -3
- package/src/base.css +7 -2
- package/src/components/Badge.stories.tsx +51 -6
- package/src/components/Badge.test.tsx +15 -0
- package/src/components/Badge.tsx +73 -17
- package/src/components/Button.stories.tsx +96 -8
- package/src/components/Button.test.tsx +69 -0
- package/src/components/Button.tsx +159 -18
- package/src/components/Card.stories.tsx +17 -4
- package/src/components/Card.test.tsx +24 -1
- package/src/components/Card.tsx +71 -13
- package/src/components/Flex.stories.tsx +58 -0
- package/src/components/Flex.test.tsx +57 -0
- package/src/components/Flex.tsx +52 -0
- package/src/components/Grid.stories.tsx +55 -0
- package/src/components/Grid.test.tsx +140 -0
- package/src/components/Grid.tsx +166 -0
- package/src/components/Separator.stories.tsx +39 -0
- package/src/components/Separator.test.tsx +25 -0
- package/src/components/Separator.tsx +30 -0
- package/src/components/Spinner.stories.tsx +35 -0
- package/src/components/Spinner.test.tsx +30 -0
- package/src/components/Spinner.tsx +75 -0
- package/src/components/Text.stories.tsx +52 -2
- package/src/components/Text.test.tsx +35 -3
- package/src/components/Text.tsx +42 -12
- package/src/components/XStack.stories.tsx +46 -0
- package/src/components/XStack.test.tsx +13 -0
- package/src/components/XStack.tsx +7 -0
- package/src/components/YStack.stories.tsx +46 -0
- package/src/components/YStack.test.tsx +13 -0
- package/src/components/YStack.tsx +7 -0
- package/src/components/_internal/InputFrame.tsx +50 -0
- package/src/components/_internal/Story.tsx +34 -0
- package/src/components/_internal/StoryItem.tsx +24 -0
- package/src/components/_internal/layout.ts +103 -0
- package/src/components/_internal/useControllableState.ts +25 -0
- package/src/components/form/Checkbox.stories.tsx +67 -0
- package/src/components/form/Checkbox.test.tsx +88 -0
- package/src/components/form/Checkbox.tsx +141 -0
- package/src/components/form/Form.context.ts +91 -0
- package/src/components/form/Form.stories.tsx +152 -0
- package/src/components/form/Form.test.tsx +195 -0
- package/src/components/form/Form.tsx +51 -0
- package/src/components/form/FormCheckbox.tsx +42 -0
- package/src/components/form/FormDescription.tsx +14 -0
- package/src/components/form/FormError.tsx +17 -0
- package/src/components/form/FormField.tsx +82 -0
- package/src/components/form/FormInput.tsx +70 -0
- package/src/components/form/FormInputGroup.tsx +30 -0
- package/src/components/form/FormLabel.tsx +17 -0
- package/src/components/form/FormRadioGroup.tsx +48 -0
- package/src/components/form/FormRoot.tsx +41 -0
- package/src/components/form/FormTextarea.tsx +56 -0
- package/src/components/form/Input.stories.tsx +105 -0
- package/src/components/form/Input.test.tsx +76 -0
- package/src/components/form/Input.tsx +81 -0
- package/src/components/form/InputGroup.context.ts +11 -0
- package/src/components/form/InputGroup.stories.tsx +129 -0
- package/src/components/form/InputGroup.test.tsx +95 -0
- package/src/components/form/InputGroup.tsx +266 -0
- package/src/components/form/RadioGroup.stories.tsx +62 -0
- package/src/components/form/RadioGroup.test.tsx +98 -0
- package/src/components/form/RadioGroup.tsx +151 -0
- package/src/components/form/Textarea.stories.tsx +61 -0
- package/src/components/form/Textarea.test.tsx +35 -0
- package/src/components/form/Textarea.tsx +74 -0
- package/src/entries/card.ts +2 -0
- package/src/entries/checkbox.ts +2 -0
- package/src/entries/flex.ts +2 -0
- package/src/entries/form.ts +13 -0
- package/src/entries/grid.ts +2 -0
- package/src/entries/input-group.ts +8 -0
- package/src/entries/input.ts +2 -0
- package/src/entries/radio-group.ts +5 -0
- package/src/entries/separator.ts +2 -0
- package/src/entries/spinner.ts +2 -0
- package/src/entries/textarea.ts +2 -0
- package/src/entries/theme.ts +7 -0
- package/src/entries/x-stack.ts +2 -0
- package/src/entries/y-stack.ts +2 -0
- package/src/index.ts +13 -0
- package/src/lib/cn.test.ts +6 -0
- package/src/lib/cn.ts +14 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Text } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type FormErrorProps = React.ComponentProps<typeof Text>;
|
|
6
|
+
|
|
7
|
+
/** Announced by screen readers when it appears, like the web's live error. */
|
|
8
|
+
export function FormError({ className, ...props }: FormErrorProps) {
|
|
9
|
+
return (
|
|
10
|
+
<Text
|
|
11
|
+
accessibilityLiveRegion="polite"
|
|
12
|
+
className={cn('text-destructive text-sm font-medium', className)}
|
|
13
|
+
role="alert"
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type ReactNode, useId, useMemo, useRef } from 'react';
|
|
2
|
+
import { useFormState } from 'react-hook-form';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../../lib/cn';
|
|
6
|
+
import {
|
|
7
|
+
FormFieldContext,
|
|
8
|
+
getErrorMessage,
|
|
9
|
+
useFormMethods,
|
|
10
|
+
} from './Form.context';
|
|
11
|
+
import { FormDescription } from './FormDescription';
|
|
12
|
+
import { FormError } from './FormError';
|
|
13
|
+
import { FormLabel } from './FormLabel';
|
|
14
|
+
|
|
15
|
+
export type FormFieldProps = Omit<
|
|
16
|
+
React.ComponentProps<typeof View>,
|
|
17
|
+
'children'
|
|
18
|
+
> & {
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
description?: ReactNode;
|
|
21
|
+
/** Replaces the error message React Hook Form holds for the field. */
|
|
22
|
+
error?: ReactNode;
|
|
23
|
+
/** Names the control and focuses it when pressed, like the web's `<label>`. */
|
|
24
|
+
label?: ReactNode;
|
|
25
|
+
name?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A labelled field bound to `name`, like `Form.Field` in @pickle-ui/react:
|
|
30
|
+
* the label, the control, then the field's error or its description.
|
|
31
|
+
*/
|
|
32
|
+
export function FormField({
|
|
33
|
+
children,
|
|
34
|
+
className,
|
|
35
|
+
description,
|
|
36
|
+
error,
|
|
37
|
+
label,
|
|
38
|
+
name,
|
|
39
|
+
...props
|
|
40
|
+
}: FormFieldProps) {
|
|
41
|
+
const { control, getFieldState } = useFormMethods();
|
|
42
|
+
const formState = useFormState({
|
|
43
|
+
control,
|
|
44
|
+
disabled: !name,
|
|
45
|
+
exact: true,
|
|
46
|
+
name,
|
|
47
|
+
});
|
|
48
|
+
const fieldError = name ? getFieldState(name, formState).error : undefined;
|
|
49
|
+
const errorMessage = error ?? getErrorMessage(fieldError);
|
|
50
|
+
const labelId = useId();
|
|
51
|
+
const controlRef = useRef<null | { focus: () => void }>(null);
|
|
52
|
+
const context = useMemo(
|
|
53
|
+
() => ({
|
|
54
|
+
label,
|
|
55
|
+
labelId: label ? labelId : undefined,
|
|
56
|
+
name,
|
|
57
|
+
setControl: (target: null | { focus: () => void }) => {
|
|
58
|
+
controlRef.current = target;
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
[label, labelId, name],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<View className={cn('gap-2.5', className)} {...props}>
|
|
66
|
+
{label ? (
|
|
67
|
+
<FormLabel
|
|
68
|
+
nativeID={labelId}
|
|
69
|
+
onPress={() => controlRef.current?.focus()}
|
|
70
|
+
>
|
|
71
|
+
{label}
|
|
72
|
+
</FormLabel>
|
|
73
|
+
) : null}
|
|
74
|
+
<FormFieldContext value={context}>{children}</FormFieldContext>
|
|
75
|
+
{errorMessage ? (
|
|
76
|
+
<FormError>{errorMessage}</FormError>
|
|
77
|
+
) : description ? (
|
|
78
|
+
<FormDescription>{description}</FormDescription>
|
|
79
|
+
) : null}
|
|
80
|
+
</View>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { use, useImperativeHandle, useRef } from 'react';
|
|
2
|
+
import { useController } from 'react-hook-form';
|
|
3
|
+
import { type TextInput } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
FormFieldContext,
|
|
7
|
+
FormInputGroupContext,
|
|
8
|
+
FormSubmitContext,
|
|
9
|
+
useFormControlProps,
|
|
10
|
+
useFormMethods,
|
|
11
|
+
useRequiredFieldName,
|
|
12
|
+
} from './Form.context';
|
|
13
|
+
import { Input, type InputProps } from './Input';
|
|
14
|
+
import { InputGroup } from './InputGroup';
|
|
15
|
+
|
|
16
|
+
export type FormInputProps = Omit<
|
|
17
|
+
InputProps,
|
|
18
|
+
'defaultValue' | 'invalid' | 'value'
|
|
19
|
+
> & { name?: string };
|
|
20
|
+
|
|
21
|
+
type TextInputInstance = React.ComponentRef<typeof TextInput>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An `Input` bound to the field's value. The return key submits the form,
|
|
25
|
+
* like Enter on the web, unless `onSubmitEditing` is set.
|
|
26
|
+
*/
|
|
27
|
+
export function FormInput({
|
|
28
|
+
name: nameProp,
|
|
29
|
+
onBlur,
|
|
30
|
+
onChangeText,
|
|
31
|
+
onSubmitEditing,
|
|
32
|
+
ref,
|
|
33
|
+
...props
|
|
34
|
+
}: FormInputProps) {
|
|
35
|
+
const name = useRequiredFieldName(nameProp, 'Form.Input');
|
|
36
|
+
const { control } = useFormMethods();
|
|
37
|
+
const { field } = useController({ control, name });
|
|
38
|
+
const controlProps = useFormControlProps();
|
|
39
|
+
const fieldContext = use(FormFieldContext);
|
|
40
|
+
const isInsideInputGroup = use(FormInputGroupContext);
|
|
41
|
+
const submit = use(FormSubmitContext);
|
|
42
|
+
const inputRef = useRef<null | TextInputInstance>(null);
|
|
43
|
+
useImperativeHandle(ref, () => inputRef.current as TextInputInstance);
|
|
44
|
+
|
|
45
|
+
const InputComponent = isInsideInputGroup ? InputGroup.Input : Input;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<InputComponent
|
|
49
|
+
{...controlProps}
|
|
50
|
+
{...props}
|
|
51
|
+
onBlur={(event) => {
|
|
52
|
+
field.onBlur();
|
|
53
|
+
onBlur?.(event);
|
|
54
|
+
}}
|
|
55
|
+
onChangeText={(text) => {
|
|
56
|
+
field.onChange(text);
|
|
57
|
+
onChangeText?.(text);
|
|
58
|
+
}}
|
|
59
|
+
onSubmitEditing={onSubmitEditing ?? submit}
|
|
60
|
+
ref={(node: null | TextInputInstance) => {
|
|
61
|
+
inputRef.current = node;
|
|
62
|
+
// React Hook Form focuses the first invalid field after a submit,
|
|
63
|
+
// and the field's label focuses its input when pressed.
|
|
64
|
+
field.ref(node);
|
|
65
|
+
fieldContext?.setControl(node);
|
|
66
|
+
}}
|
|
67
|
+
value={field.value ?? ''}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Children, isValidElement } from 'react';
|
|
2
|
+
|
|
3
|
+
import { FormInputGroupContext, useFormControlProps } from './Form.context';
|
|
4
|
+
import { FormInput, type FormInputProps } from './FormInput';
|
|
5
|
+
import { InputGroup } from './InputGroup';
|
|
6
|
+
import { InputGroupStateContext } from './InputGroup.context';
|
|
7
|
+
|
|
8
|
+
export type FormInputGroupProps = React.ComponentProps<typeof InputGroup>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An `InputGroup` whose `Form.Input` renders `InputGroup.Input`, framed as
|
|
12
|
+
* invalid while the field has an error.
|
|
13
|
+
*/
|
|
14
|
+
export function FormInputGroup({ children, ...props }: FormInputGroupProps) {
|
|
15
|
+
const { invalid } = useFormControlProps();
|
|
16
|
+
const disabled = Children.toArray(children).some(
|
|
17
|
+
(child) =>
|
|
18
|
+
isValidElement<FormInputProps>(child) &&
|
|
19
|
+
child.type === FormInput &&
|
|
20
|
+
Boolean(child.props.disabled),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<FormInputGroupContext value>
|
|
25
|
+
<InputGroupStateContext value={{ disabled, invalid }}>
|
|
26
|
+
<InputGroup {...props}>{children}</InputGroup>
|
|
27
|
+
</InputGroupStateContext>
|
|
28
|
+
</FormInputGroupContext>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Text } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type FormLabelProps = React.ComponentProps<typeof Text>;
|
|
6
|
+
|
|
7
|
+
export function FormLabel({ className, ...props }: FormLabelProps) {
|
|
8
|
+
return (
|
|
9
|
+
<Text
|
|
10
|
+
className={cn(
|
|
11
|
+
'text-foreground text-sm leading-none font-medium',
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useController } from 'react-hook-form';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useFormControlProps,
|
|
5
|
+
useFormMethods,
|
|
6
|
+
useRequiredFieldName,
|
|
7
|
+
} from './Form.context';
|
|
8
|
+
import { RadioGroup, type RadioGroupProps } from './RadioGroup';
|
|
9
|
+
|
|
10
|
+
export type FormRadioGroupProps = Omit<RadioGroupProps, 'value'> & {
|
|
11
|
+
name?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** A `RadioGroup` bound to the field's value. */
|
|
15
|
+
function FormRadioGroupRoot({
|
|
16
|
+
name: nameProp,
|
|
17
|
+
onValueChange,
|
|
18
|
+
...props
|
|
19
|
+
}: FormRadioGroupProps) {
|
|
20
|
+
const name = useRequiredFieldName(nameProp, 'Form.RadioGroup');
|
|
21
|
+
const { control } = useFormMethods();
|
|
22
|
+
const { field } = useController({
|
|
23
|
+
control,
|
|
24
|
+
defaultValue: props.defaultValue,
|
|
25
|
+
name,
|
|
26
|
+
});
|
|
27
|
+
const { accessibilityLabel, 'aria-labelledby': labelledBy } =
|
|
28
|
+
useFormControlProps();
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<RadioGroup
|
|
32
|
+
accessibilityLabel={accessibilityLabel}
|
|
33
|
+
aria-labelledby={labelledBy}
|
|
34
|
+
{...props}
|
|
35
|
+
onValueChange={(value) => {
|
|
36
|
+
field.onChange(value);
|
|
37
|
+
onValueChange?.(value);
|
|
38
|
+
}}
|
|
39
|
+
value={field.value}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const FormRadioGroup = Object.assign(FormRadioGroupRoot, {
|
|
45
|
+
Item: RadioGroup.Item,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export { FormRadioGroup };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
type FieldValues,
|
|
4
|
+
FormProvider,
|
|
5
|
+
type SubmitHandler,
|
|
6
|
+
type UseFormReturn,
|
|
7
|
+
} from 'react-hook-form';
|
|
8
|
+
import { View } from 'react-native';
|
|
9
|
+
|
|
10
|
+
import { cn } from '../../lib/cn';
|
|
11
|
+
import { FormSubmitContext } from './Form.context';
|
|
12
|
+
|
|
13
|
+
export type FormProps<FormValues extends FieldValues = FieldValues> =
|
|
14
|
+
React.ComponentProps<typeof View> & {
|
|
15
|
+
form: UseFormReturn<FormValues>;
|
|
16
|
+
onSubmit?: SubmitHandler<FormValues>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* React Native has no `<form>`, so the root shares a submit action:
|
|
21
|
+
* `Form.Button` and the return key in `Form.Input` call it, like a submit
|
|
22
|
+
* button and Enter on the web.
|
|
23
|
+
*/
|
|
24
|
+
export function FormRoot<FormValues extends FieldValues = FieldValues>({
|
|
25
|
+
className,
|
|
26
|
+
form,
|
|
27
|
+
onSubmit,
|
|
28
|
+
...props
|
|
29
|
+
}: FormProps<FormValues>) {
|
|
30
|
+
const submit = useCallback(() => {
|
|
31
|
+
if (onSubmit) void form.handleSubmit(onSubmit)();
|
|
32
|
+
}, [form, onSubmit]);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<FormProvider {...form}>
|
|
36
|
+
<FormSubmitContext value={submit}>
|
|
37
|
+
<View className={cn('gap-6', className)} {...props} />
|
|
38
|
+
</FormSubmitContext>
|
|
39
|
+
</FormProvider>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { use, useImperativeHandle, useRef } from 'react';
|
|
2
|
+
import { useController } from 'react-hook-form';
|
|
3
|
+
import { type TextInput } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
FormFieldContext,
|
|
7
|
+
useFormControlProps,
|
|
8
|
+
useFormMethods,
|
|
9
|
+
useRequiredFieldName,
|
|
10
|
+
} from './Form.context';
|
|
11
|
+
import { Textarea, type TextareaProps } from './Textarea';
|
|
12
|
+
|
|
13
|
+
export type FormTextareaProps = Omit<
|
|
14
|
+
TextareaProps,
|
|
15
|
+
'defaultValue' | 'invalid' | 'value'
|
|
16
|
+
> & { name?: string };
|
|
17
|
+
|
|
18
|
+
type TextInputInstance = React.ComponentRef<typeof TextInput>;
|
|
19
|
+
|
|
20
|
+
/** A `Textarea` bound to the field's value. */
|
|
21
|
+
export function FormTextarea({
|
|
22
|
+
name: nameProp,
|
|
23
|
+
onBlur,
|
|
24
|
+
onChangeText,
|
|
25
|
+
ref,
|
|
26
|
+
...props
|
|
27
|
+
}: FormTextareaProps) {
|
|
28
|
+
const name = useRequiredFieldName(nameProp, 'Form.Textarea');
|
|
29
|
+
const { control } = useFormMethods();
|
|
30
|
+
const { field } = useController({ control, name });
|
|
31
|
+
const controlProps = useFormControlProps();
|
|
32
|
+
const fieldContext = use(FormFieldContext);
|
|
33
|
+
const inputRef = useRef<null | TextInputInstance>(null);
|
|
34
|
+
useImperativeHandle(ref, () => inputRef.current as TextInputInstance);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Textarea
|
|
38
|
+
{...controlProps}
|
|
39
|
+
{...props}
|
|
40
|
+
onBlur={(event) => {
|
|
41
|
+
field.onBlur();
|
|
42
|
+
onBlur?.(event);
|
|
43
|
+
}}
|
|
44
|
+
onChangeText={(text) => {
|
|
45
|
+
field.onChange(text);
|
|
46
|
+
onChangeText?.(text);
|
|
47
|
+
}}
|
|
48
|
+
ref={(node: null | TextInputInstance) => {
|
|
49
|
+
inputRef.current = node;
|
|
50
|
+
field.ref(node);
|
|
51
|
+
fieldContext?.setControl(node);
|
|
52
|
+
}}
|
|
53
|
+
value={field.value ?? ''}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
|
|
2
|
+
|
|
3
|
+
import { type ComponentMatrixCellProps, Story } from '../_internal/Story';
|
|
4
|
+
import { Button } from '../Button';
|
|
5
|
+
import { XStack } from '../XStack';
|
|
6
|
+
import { Input } from './Input';
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
component: Input,
|
|
10
|
+
parameters: { layout: 'fullscreen' },
|
|
11
|
+
title: 'components/form/Input',
|
|
12
|
+
} satisfies Meta<typeof Input>;
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type StoryDefinition = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
// No focus column: React Native can't force a focus state. Tap a field.
|
|
18
|
+
const STATE_COLUMNS = [
|
|
19
|
+
{ key: 'empty', label: 'Empty' },
|
|
20
|
+
{ key: 'filled', label: 'Filled' },
|
|
21
|
+
{ key: 'disabled', label: 'Disabled' },
|
|
22
|
+
{ key: 'invalid', label: 'Invalid' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const TYPE_ROWS = [
|
|
26
|
+
{ description: 'General text input', key: 'text', label: 'Text' },
|
|
27
|
+
{ description: 'Keyboard for email', key: 'email', label: 'Email' },
|
|
28
|
+
{ description: 'Masked text entry', key: 'password', label: 'Password' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function InputMatrixCell({ column, row }: ComponentMatrixCellProps) {
|
|
32
|
+
const isInvalid = column.key === 'invalid';
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Input
|
|
36
|
+
accessibilityLabel={`${row.label} ${column.label}`}
|
|
37
|
+
defaultValue={
|
|
38
|
+
column.key === 'filled'
|
|
39
|
+
? row.key === 'email'
|
|
40
|
+
? 'hello@pickle.dev'
|
|
41
|
+
: 'Pickle UI'
|
|
42
|
+
: undefined
|
|
43
|
+
}
|
|
44
|
+
disabled={column.key === 'disabled'}
|
|
45
|
+
invalid={isInvalid}
|
|
46
|
+
keyboardType={row.key === 'email' ? 'email-address' : 'default'}
|
|
47
|
+
placeholder={isInvalid ? 'Check this value' : row.label}
|
|
48
|
+
secureTextEntry={row.key === 'password'}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SIZE_COLUMNS = [
|
|
54
|
+
{ key: 'sm', label: 'Small' },
|
|
55
|
+
{ key: 'md', label: 'Medium' },
|
|
56
|
+
{ key: 'lg', label: 'Large' },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function InputSizeCell({ column }: ComponentMatrixCellProps) {
|
|
60
|
+
const size = column.key as 'lg' | 'md' | 'sm';
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<XStack gap={2}>
|
|
64
|
+
<Input
|
|
65
|
+
accessibilityLabel={`${column.label} email`}
|
|
66
|
+
className="flex-1"
|
|
67
|
+
placeholder="Email"
|
|
68
|
+
size={size}
|
|
69
|
+
/>
|
|
70
|
+
<Button size={size} variant="outline">
|
|
71
|
+
Subscribe
|
|
72
|
+
</Button>
|
|
73
|
+
</XStack>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const All: StoryDefinition = {
|
|
78
|
+
render: () => (
|
|
79
|
+
<Story.Layout className="max-w-6xl" title="Input">
|
|
80
|
+
<Story.Section title="Types and states">
|
|
81
|
+
<Story.Matrix
|
|
82
|
+
Cell={InputMatrixCell}
|
|
83
|
+
cellClassName="items-stretch"
|
|
84
|
+
columns={STATE_COLUMNS}
|
|
85
|
+
rows={TYPE_ROWS}
|
|
86
|
+
/>
|
|
87
|
+
</Story.Section>
|
|
88
|
+
|
|
89
|
+
<Story.Section title="Sizes">
|
|
90
|
+
<Story.Matrix
|
|
91
|
+
Cell={InputSizeCell}
|
|
92
|
+
cellClassName="items-stretch"
|
|
93
|
+
columns={SIZE_COLUMNS}
|
|
94
|
+
rows={[
|
|
95
|
+
{
|
|
96
|
+
description: 'Matches the Button of the same size',
|
|
97
|
+
key: 'with-button',
|
|
98
|
+
label: 'With button',
|
|
99
|
+
},
|
|
100
|
+
]}
|
|
101
|
+
/>
|
|
102
|
+
</Story.Section>
|
|
103
|
+
</Story.Layout>
|
|
104
|
+
),
|
|
105
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react-native';
|
|
2
|
+
|
|
3
|
+
import { Input } from './Input';
|
|
4
|
+
|
|
5
|
+
describe('Input', () => {
|
|
6
|
+
it('renders a text field named by its accessibility label', async () => {
|
|
7
|
+
await render(<Input accessibilityLabel="Email" />);
|
|
8
|
+
|
|
9
|
+
expect(screen.getByLabelText('Email')).toBeOnTheScreen();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it.each([
|
|
13
|
+
['sm', 'h-7'],
|
|
14
|
+
['md', 'h-8'],
|
|
15
|
+
['lg', 'h-9'],
|
|
16
|
+
] as const)('matches the Button height at size %s', async (size, height) => {
|
|
17
|
+
await render(<Input accessibilityLabel="Email" size={size} />);
|
|
18
|
+
|
|
19
|
+
expect(screen.getByLabelText('Email').parent).toHaveProp(
|
|
20
|
+
'className',
|
|
21
|
+
expect.stringContaining(height),
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('styles the control with className and the field with classNames.input', async () => {
|
|
26
|
+
await render(
|
|
27
|
+
<Input
|
|
28
|
+
accessibilityLabel="Email"
|
|
29
|
+
className="control-x"
|
|
30
|
+
classNames={{ input: 'input-x' }}
|
|
31
|
+
/>,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const field = screen.getByLabelText('Email');
|
|
35
|
+
expect(field).toHaveProp('className', expect.stringContaining('input-x'));
|
|
36
|
+
expect(field.parent).toHaveProp(
|
|
37
|
+
'className',
|
|
38
|
+
expect.stringContaining('control-x'),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('stops editing when disabled', async () => {
|
|
43
|
+
await render(<Input accessibilityLabel="Email" disabled />);
|
|
44
|
+
|
|
45
|
+
expect(screen.getByLabelText('Email')).toHaveProp('editable', false);
|
|
46
|
+
expect(screen.getByLabelText('Email')).toBeDisabled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('shows a focus ring while focused and still calls onFocus', async () => {
|
|
50
|
+
const onFocus = jest.fn();
|
|
51
|
+
await render(<Input accessibilityLabel="Email" onFocus={onFocus} />);
|
|
52
|
+
|
|
53
|
+
const control = () => screen.getByLabelText('Email').parent;
|
|
54
|
+
expect(control()).not.toHaveProp(
|
|
55
|
+
'className',
|
|
56
|
+
expect.stringContaining('border-ring'),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
await fireEvent(screen.getByLabelText('Email'), 'focus');
|
|
60
|
+
|
|
61
|
+
expect(onFocus).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(control()).toHaveProp(
|
|
63
|
+
'className',
|
|
64
|
+
expect.stringContaining('border-ring'),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders the bare field when unstyled', async () => {
|
|
69
|
+
await render(<Input accessibilityLabel="Search" className="x" unstyled />);
|
|
70
|
+
|
|
71
|
+
expect(screen.getByLabelText('Search')).toHaveProp(
|
|
72
|
+
'className',
|
|
73
|
+
expect.stringMatching(/h-8.*x$/),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { TextInput } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../lib/cn';
|
|
5
|
+
import { InputFrame } from '../_internal/InputFrame';
|
|
6
|
+
|
|
7
|
+
// Same heights as `Button` for the same size, so they line up side by side,
|
|
8
|
+
// and the web's paddings minus the 1pt border.
|
|
9
|
+
const heightClasses = { lg: 'h-9', md: 'h-8', sm: 'h-7' } as const;
|
|
10
|
+
const paddingClasses = {
|
|
11
|
+
lg: 'px-[13px]',
|
|
12
|
+
md: 'px-[11px]',
|
|
13
|
+
sm: 'px-[9px]',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type InputProps = React.ComponentProps<typeof TextInput> & {
|
|
17
|
+
/** Class overrides for the inner `TextInput`; `className` styles the control around it. */
|
|
18
|
+
classNames?: { input?: string };
|
|
19
|
+
/** Dims the field and stops editing, like the web's `disabled`. */
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
/** Marks the value as invalid, like the web's `aria-invalid`. */
|
|
22
|
+
invalid?: boolean;
|
|
23
|
+
size?: 'lg' | 'md' | 'sm';
|
|
24
|
+
/** Renders the bare `TextInput`, without the border and background. */
|
|
25
|
+
unstyled?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A single-line text field, like `Input` in @pickle-ui/react. */
|
|
29
|
+
export function Input({
|
|
30
|
+
className,
|
|
31
|
+
classNames,
|
|
32
|
+
disabled = false,
|
|
33
|
+
editable = true,
|
|
34
|
+
invalid = false,
|
|
35
|
+
onBlur,
|
|
36
|
+
onFocus,
|
|
37
|
+
size = 'md',
|
|
38
|
+
unstyled = false,
|
|
39
|
+
...props
|
|
40
|
+
}: InputProps) {
|
|
41
|
+
const [focused, setFocused] = useState(false);
|
|
42
|
+
// The web's 14px from `sm` up: the 16px it uses on phones only stops iOS
|
|
43
|
+
// Safari zooming into a focused field, which native apps don't do.
|
|
44
|
+
// `text-[14px]` sets the size without `text-sm`'s line height, which
|
|
45
|
+
// pushes a single-line field's text off center on iOS.
|
|
46
|
+
const input = (
|
|
47
|
+
<TextInput
|
|
48
|
+
accessibilityState={{ disabled }}
|
|
49
|
+
className={cn(
|
|
50
|
+
'text-foreground flex-1 text-[14px] outline-none',
|
|
51
|
+
paddingClasses[size],
|
|
52
|
+
unstyled ? cn(heightClasses[size], disabled && 'opacity-64') : 'h-full',
|
|
53
|
+
unstyled ? className : classNames?.input,
|
|
54
|
+
)}
|
|
55
|
+
editable={editable && !disabled}
|
|
56
|
+
onBlur={(event) => {
|
|
57
|
+
setFocused(false);
|
|
58
|
+
onBlur?.(event);
|
|
59
|
+
}}
|
|
60
|
+
onFocus={(event) => {
|
|
61
|
+
setFocused(true);
|
|
62
|
+
onFocus?.(event);
|
|
63
|
+
}}
|
|
64
|
+
placeholderTextColorClassName="accent-muted-foreground/72"
|
|
65
|
+
{...props}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (unstyled) return input;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<InputFrame
|
|
73
|
+
className={cn(heightClasses[size], className)}
|
|
74
|
+
disabled={disabled}
|
|
75
|
+
focused={focused}
|
|
76
|
+
invalid={invalid}
|
|
77
|
+
>
|
|
78
|
+
{input}
|
|
79
|
+
</InputFrame>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lets a wrapper mark the frame `disabled` or `invalid` when its input isn't
|
|
5
|
+
* a direct `InputGroup.Input` child, like `Form.InputGroup` does with
|
|
6
|
+
* `Form.Input`, whose invalid state comes from the form.
|
|
7
|
+
*/
|
|
8
|
+
export const InputGroupStateContext = createContext<null | {
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
invalid?: boolean;
|
|
11
|
+
}>(null);
|