@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,141 @@
|
|
|
1
|
+
import { Pressable, Text, View } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn';
|
|
4
|
+
import { useControllableState } from '../_internal/useControllableState';
|
|
5
|
+
|
|
6
|
+
// Same box sizes as `Checkbox` in @pickle-ui/react. `hitSlop` stretches the
|
|
7
|
+
// box to a 44pt touch target, vertically only when a label already widens it.
|
|
8
|
+
const boxClasses = { md: 'size-4.5', sm: 'size-3.5' } as const;
|
|
9
|
+
const boxHitSlop = { md: 13, sm: 15 } as const;
|
|
10
|
+
const fieldHitSlop = {
|
|
11
|
+
md: { bottom: 13, top: 13 },
|
|
12
|
+
sm: { bottom: 15, top: 15 },
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
// The web draws Tabler's check and minus icons. Without SVG, the check is the
|
|
16
|
+
// bottom and right borders of a rotated box, and the minus a bar.
|
|
17
|
+
const checkClasses = {
|
|
18
|
+
md: 'h-[9px] w-[5px] border-b-[1.5px] border-r-[1.5px]',
|
|
19
|
+
sm: 'h-[7px] w-[4px] border-b-[1.25px] border-r-[1.25px]',
|
|
20
|
+
} as const;
|
|
21
|
+
const minusClasses = { md: 'h-[1.5px] w-2', sm: 'h-[1.25px] w-1.5' } as const;
|
|
22
|
+
|
|
23
|
+
export type CheckboxProps = Omit<
|
|
24
|
+
React.ComponentProps<typeof Pressable>,
|
|
25
|
+
'children' | 'disabled'
|
|
26
|
+
> & {
|
|
27
|
+
checked?: boolean;
|
|
28
|
+
/** Class overrides for the row around the box and label, the check and the label. */
|
|
29
|
+
classNames?: { field?: string; indicator?: string; label?: string };
|
|
30
|
+
defaultChecked?: boolean;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
/** Shows a minus instead of the check, for a partly selected group. */
|
|
33
|
+
indeterminate?: boolean;
|
|
34
|
+
/** Marks the value as invalid, like the web's `aria-invalid`. */
|
|
35
|
+
invalid?: boolean;
|
|
36
|
+
/** Text next to the box; pressing it toggles the checkbox and names it. */
|
|
37
|
+
label?: React.ReactNode;
|
|
38
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
39
|
+
size?: 'md' | 'sm';
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A checkbox, like `Checkbox` in @pickle-ui/react. Give it a `label`, or an
|
|
44
|
+
* `accessibilityLabel` when visible text elsewhere names it.
|
|
45
|
+
*/
|
|
46
|
+
export function Checkbox({
|
|
47
|
+
accessibilityState,
|
|
48
|
+
checked: checkedProp,
|
|
49
|
+
className,
|
|
50
|
+
classNames,
|
|
51
|
+
defaultChecked = false,
|
|
52
|
+
disabled = false,
|
|
53
|
+
indeterminate = false,
|
|
54
|
+
invalid = false,
|
|
55
|
+
label,
|
|
56
|
+
onCheckedChange,
|
|
57
|
+
size = 'md',
|
|
58
|
+
...props
|
|
59
|
+
}: CheckboxProps) {
|
|
60
|
+
const [checked, setChecked] = useControllableState(
|
|
61
|
+
checkedProp,
|
|
62
|
+
defaultChecked,
|
|
63
|
+
onCheckedChange,
|
|
64
|
+
);
|
|
65
|
+
const filled = checked && !indeterminate;
|
|
66
|
+
|
|
67
|
+
const box = (
|
|
68
|
+
<View
|
|
69
|
+
className={cn(
|
|
70
|
+
'items-center justify-center rounded-[4px] border shadow-xs shadow-black/5',
|
|
71
|
+
boxClasses[size],
|
|
72
|
+
// A checked box is filled in both themes, so the dark input
|
|
73
|
+
// background must not apply to it.
|
|
74
|
+
filled
|
|
75
|
+
? 'border-primary bg-primary'
|
|
76
|
+
: cn(
|
|
77
|
+
'border-input bg-background dark:bg-input/32',
|
|
78
|
+
invalid && 'border-destructive/36',
|
|
79
|
+
),
|
|
80
|
+
(checked || indeterminate || invalid || disabled) && 'shadow-none',
|
|
81
|
+
disabled && 'opacity-64',
|
|
82
|
+
className,
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{indeterminate ? (
|
|
86
|
+
<View
|
|
87
|
+
className={cn(
|
|
88
|
+
'bg-foreground rounded-full',
|
|
89
|
+
minusClasses[size],
|
|
90
|
+
classNames?.indicator,
|
|
91
|
+
)}
|
|
92
|
+
/>
|
|
93
|
+
) : checked ? (
|
|
94
|
+
<View
|
|
95
|
+
className={cn(
|
|
96
|
+
'border-primary-foreground -mt-px rotate-45',
|
|
97
|
+
checkClasses[size],
|
|
98
|
+
classNames?.indicator,
|
|
99
|
+
)}
|
|
100
|
+
/>
|
|
101
|
+
) : null}
|
|
102
|
+
</View>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
// `role` and `aria-*` are React Native's current accessibility props;
|
|
107
|
+
// `accessibilityState` keeps any extra state the caller passes.
|
|
108
|
+
<Pressable
|
|
109
|
+
accessibilityState={{
|
|
110
|
+
...accessibilityState,
|
|
111
|
+
checked: indeterminate ? 'mixed' : checked,
|
|
112
|
+
disabled,
|
|
113
|
+
}}
|
|
114
|
+
aria-checked={indeterminate ? 'mixed' : checked}
|
|
115
|
+
aria-disabled={disabled}
|
|
116
|
+
className={
|
|
117
|
+
label
|
|
118
|
+
? cn('flex-row items-center gap-2', classNames?.field)
|
|
119
|
+
: 'self-start'
|
|
120
|
+
}
|
|
121
|
+
disabled={disabled}
|
|
122
|
+
hitSlop={label ? fieldHitSlop[size] : boxHitSlop[size]}
|
|
123
|
+
onPress={() => setChecked(!checked)}
|
|
124
|
+
role="checkbox"
|
|
125
|
+
{...props}
|
|
126
|
+
>
|
|
127
|
+
{box}
|
|
128
|
+
{label ? (
|
|
129
|
+
<Text
|
|
130
|
+
className={cn(
|
|
131
|
+
'text-foreground text-sm leading-none font-medium',
|
|
132
|
+
disabled && 'opacity-50',
|
|
133
|
+
classNames?.label,
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{label}
|
|
137
|
+
</Text>
|
|
138
|
+
) : null}
|
|
139
|
+
</Pressable>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createContext, isValidElement, type ReactNode, use } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
useFormContext,
|
|
4
|
+
type UseFormReturn,
|
|
5
|
+
useFormState,
|
|
6
|
+
} from 'react-hook-form';
|
|
7
|
+
|
|
8
|
+
export type FormFieldContextValue = {
|
|
9
|
+
/** The field's label, used as its control's accessibility label when it is text. */
|
|
10
|
+
label?: ReactNode;
|
|
11
|
+
/** The `nativeID` of the field's label, for `aria-labelledby`. */
|
|
12
|
+
labelId?: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
/** Registers the control the label focuses when pressed. */
|
|
15
|
+
setControl: (control: FocusTarget | null) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type FocusTarget = { focus: () => void };
|
|
19
|
+
|
|
20
|
+
export const FormFieldContext = createContext<FormFieldContextValue | null>(
|
|
21
|
+
null,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/** True inside `Form.InputGroup`, where `Form.Input` renders `InputGroup.Input`. */
|
|
25
|
+
export const FormInputGroupContext = createContext(false);
|
|
26
|
+
|
|
27
|
+
/** Submits the form, like pressing a submit button or Enter on the web. */
|
|
28
|
+
export const FormSubmitContext = createContext<() => void>(() => {});
|
|
29
|
+
|
|
30
|
+
export function getErrorMessage(error: unknown): ReactNode {
|
|
31
|
+
if (!error || typeof error !== 'object') return undefined;
|
|
32
|
+
|
|
33
|
+
const message = (error as { message?: unknown }).message;
|
|
34
|
+
|
|
35
|
+
return isValidElement(message) ||
|
|
36
|
+
typeof message === 'string' ||
|
|
37
|
+
typeof message === 'number'
|
|
38
|
+
? message
|
|
39
|
+
: undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* React Hook Form tracks field state but has no DOM to label: this maps the
|
|
44
|
+
* current `Form.Field` onto its control, naming it by the field's label and
|
|
45
|
+
* marking it `invalid` while the field has an error.
|
|
46
|
+
*/
|
|
47
|
+
export function useFormControlProps() {
|
|
48
|
+
const field = use(FormFieldContext);
|
|
49
|
+
const { control, getFieldState } = useFormMethods();
|
|
50
|
+
const name = field?.name;
|
|
51
|
+
const formState = useFormState({
|
|
52
|
+
control,
|
|
53
|
+
disabled: !name,
|
|
54
|
+
exact: true,
|
|
55
|
+
name,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
accessibilityLabel:
|
|
60
|
+
typeof field?.label === 'string' ? field.label : undefined,
|
|
61
|
+
'aria-labelledby': field?.labelId,
|
|
62
|
+
invalid: name ? getFieldState(name, formState).invalid : false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useFormMethods() {
|
|
67
|
+
const methods = useFormContext() as null | UseFormReturn;
|
|
68
|
+
|
|
69
|
+
if (!methods) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'Form components must be rendered inside <Form form={form}>.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return methods;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function useRequiredFieldName(
|
|
79
|
+
name: string | undefined,
|
|
80
|
+
componentName: string,
|
|
81
|
+
) {
|
|
82
|
+
const fieldName = name ?? use(FormFieldContext)?.name;
|
|
83
|
+
|
|
84
|
+
if (!fieldName) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`${componentName} requires a name prop or a parent <Form.Field name="...">.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return fieldName;
|
|
91
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { useForm } from 'react-hook-form';
|
|
6
|
+
import { z } from 'zod/v4';
|
|
7
|
+
|
|
8
|
+
import { Story } from '../_internal/Story';
|
|
9
|
+
import { Text } from '../Text';
|
|
10
|
+
import { Form } from './Form';
|
|
11
|
+
import { InputGroup } from './InputGroup';
|
|
12
|
+
|
|
13
|
+
const meta = {
|
|
14
|
+
component: Form,
|
|
15
|
+
parameters: { layout: 'fullscreen' },
|
|
16
|
+
title: 'components/Form',
|
|
17
|
+
} satisfies Meta<typeof Form>;
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
|
|
21
|
+
type StoryDefinition = StoryObj;
|
|
22
|
+
|
|
23
|
+
const notificationChannels: {
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
label: string;
|
|
26
|
+
value: string;
|
|
27
|
+
}[] = [
|
|
28
|
+
{ label: 'Email', value: 'email' },
|
|
29
|
+
{ label: 'SMS', value: 'sms' },
|
|
30
|
+
{ disabled: true, label: 'Push (coming soon)', value: 'push' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const signUpSchema = z.object({
|
|
34
|
+
bio: z.string().max(160, 'Keep it under 160 characters.'),
|
|
35
|
+
email: z.email('Enter a valid email address.'),
|
|
36
|
+
firstName: z.string().trim().min(1, 'Enter your first name.'),
|
|
37
|
+
lastName: z.string().trim().min(1, 'Enter your last name.'),
|
|
38
|
+
notificationChannel: z.enum(['email', 'sms']),
|
|
39
|
+
password: z.string().min(8, 'Use at least 8 characters.'),
|
|
40
|
+
terms: z.boolean().refine(Boolean, 'Accept the terms to continue.'),
|
|
41
|
+
username: z.string().trim().min(1, 'Enter a username.'),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
type SignUpValues = z.infer<typeof signUpSchema>;
|
|
45
|
+
|
|
46
|
+
function SignUpForm() {
|
|
47
|
+
const form = useForm<SignUpValues>({
|
|
48
|
+
defaultValues: {
|
|
49
|
+
bio: '',
|
|
50
|
+
email: '',
|
|
51
|
+
firstName: '',
|
|
52
|
+
lastName: '',
|
|
53
|
+
notificationChannel: 'email',
|
|
54
|
+
password: '',
|
|
55
|
+
terms: false,
|
|
56
|
+
username: '',
|
|
57
|
+
},
|
|
58
|
+
resolver: zodResolver(signUpSchema),
|
|
59
|
+
});
|
|
60
|
+
const [submitted, setSubmitted] = useState<null | SignUpValues>(null);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Form className="max-w-md" form={form} onSubmit={setSubmitted}>
|
|
64
|
+
<Form.Field
|
|
65
|
+
description="We'll send a confirmation link to this address."
|
|
66
|
+
label="Email"
|
|
67
|
+
name="email"
|
|
68
|
+
>
|
|
69
|
+
<Form.Input
|
|
70
|
+
autoCapitalize="none"
|
|
71
|
+
autoComplete="email"
|
|
72
|
+
inputMode="email"
|
|
73
|
+
/>
|
|
74
|
+
</Form.Field>
|
|
75
|
+
|
|
76
|
+
<Form.Field label="Username" name="username">
|
|
77
|
+
<Form.InputGroup>
|
|
78
|
+
<InputGroup.Addon>@</InputGroup.Addon>
|
|
79
|
+
<Form.Input autoCapitalize="none" autoComplete="username" />
|
|
80
|
+
</Form.InputGroup>
|
|
81
|
+
</Form.Field>
|
|
82
|
+
|
|
83
|
+
<Form.Field
|
|
84
|
+
description="At least 8 characters."
|
|
85
|
+
label="Password"
|
|
86
|
+
name="password"
|
|
87
|
+
>
|
|
88
|
+
<Form.Input autoComplete="new-password" secureTextEntry />
|
|
89
|
+
</Form.Field>
|
|
90
|
+
|
|
91
|
+
<Form.Field label="Full name">
|
|
92
|
+
<Form.InputGroup>
|
|
93
|
+
<Form.Input
|
|
94
|
+
accessibilityLabel="First name"
|
|
95
|
+
name="firstName"
|
|
96
|
+
placeholder="First"
|
|
97
|
+
/>
|
|
98
|
+
<Form.Input
|
|
99
|
+
accessibilityLabel="Last name"
|
|
100
|
+
name="lastName"
|
|
101
|
+
placeholder="Last"
|
|
102
|
+
/>
|
|
103
|
+
</Form.InputGroup>
|
|
104
|
+
</Form.Field>
|
|
105
|
+
|
|
106
|
+
<Form.Field
|
|
107
|
+
description="A sentence or two for your public profile."
|
|
108
|
+
label="Bio"
|
|
109
|
+
name="bio"
|
|
110
|
+
>
|
|
111
|
+
<Form.Textarea placeholder="Tell us about yourself" />
|
|
112
|
+
</Form.Field>
|
|
113
|
+
|
|
114
|
+
<Form.Field
|
|
115
|
+
description="How should we reach you for account alerts?"
|
|
116
|
+
label="Notification channel"
|
|
117
|
+
name="notificationChannel"
|
|
118
|
+
>
|
|
119
|
+
<Form.RadioGroup>
|
|
120
|
+
{notificationChannels.map((channel) => (
|
|
121
|
+
<Form.RadioGroup.Item
|
|
122
|
+
disabled={channel.disabled}
|
|
123
|
+
key={channel.value}
|
|
124
|
+
label={channel.label}
|
|
125
|
+
value={channel.value}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</Form.RadioGroup>
|
|
129
|
+
</Form.Field>
|
|
130
|
+
|
|
131
|
+
<Form.Field name="terms">
|
|
132
|
+
<Form.Checkbox label="I agree to the terms of service and privacy policy" />
|
|
133
|
+
</Form.Field>
|
|
134
|
+
|
|
135
|
+
<Form.Button>Create account</Form.Button>
|
|
136
|
+
|
|
137
|
+
{submitted ? (
|
|
138
|
+
<Text tone="muted">Account created for {submitted.email}.</Text>
|
|
139
|
+
) : null}
|
|
140
|
+
</Form>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const SignUp: StoryDefinition = {
|
|
145
|
+
render: () => (
|
|
146
|
+
<Story.Layout className="max-w-lg" title="Form">
|
|
147
|
+
<Story.Section title="Sign up">
|
|
148
|
+
<SignUpForm />
|
|
149
|
+
</Story.Section>
|
|
150
|
+
</Story.Layout>
|
|
151
|
+
),
|
|
152
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fireEvent,
|
|
3
|
+
render,
|
|
4
|
+
screen,
|
|
5
|
+
waitFor,
|
|
6
|
+
} from '@testing-library/react-native';
|
|
7
|
+
import { useEffect } from 'react';
|
|
8
|
+
import { useForm } from 'react-hook-form';
|
|
9
|
+
|
|
10
|
+
import { Form } from './Form';
|
|
11
|
+
import { InputGroup } from './InputGroup';
|
|
12
|
+
|
|
13
|
+
type TestFormValues = {
|
|
14
|
+
channel: string;
|
|
15
|
+
email: string;
|
|
16
|
+
message: string;
|
|
17
|
+
terms: boolean;
|
|
18
|
+
username: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const defaultValues: TestFormValues = {
|
|
22
|
+
channel: 'email',
|
|
23
|
+
email: '',
|
|
24
|
+
message: '',
|
|
25
|
+
terms: false,
|
|
26
|
+
username: '',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function TestForm({
|
|
30
|
+
fieldErrors,
|
|
31
|
+
onSubmit,
|
|
32
|
+
}: {
|
|
33
|
+
fieldErrors?: Partial<Record<keyof TestFormValues, string>>;
|
|
34
|
+
onSubmit: (values: TestFormValues) => void;
|
|
35
|
+
}) {
|
|
36
|
+
const form = useForm<TestFormValues>({ defaultValues });
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
for (const [name, message] of Object.entries(fieldErrors ?? {}) as [
|
|
40
|
+
keyof TestFormValues,
|
|
41
|
+
string,
|
|
42
|
+
][]) {
|
|
43
|
+
form.setError(name, { message, type: 'manual' });
|
|
44
|
+
}
|
|
45
|
+
}, [fieldErrors, form]);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Form form={form} onSubmit={onSubmit}>
|
|
49
|
+
<Form.Field
|
|
50
|
+
description="We'll send a confirmation link."
|
|
51
|
+
label="Email"
|
|
52
|
+
name="email"
|
|
53
|
+
>
|
|
54
|
+
<Form.Input autoComplete="email" />
|
|
55
|
+
</Form.Field>
|
|
56
|
+
|
|
57
|
+
<Form.Field label="Username" name="username">
|
|
58
|
+
<Form.InputGroup testID="username-group">
|
|
59
|
+
<InputGroup.Addon>@</InputGroup.Addon>
|
|
60
|
+
<Form.Input />
|
|
61
|
+
</Form.InputGroup>
|
|
62
|
+
</Form.Field>
|
|
63
|
+
|
|
64
|
+
<Form.Field label="Message" name="message">
|
|
65
|
+
<Form.Textarea />
|
|
66
|
+
</Form.Field>
|
|
67
|
+
|
|
68
|
+
<Form.Field label="Notification channel" name="channel">
|
|
69
|
+
<Form.RadioGroup>
|
|
70
|
+
<Form.RadioGroup.Item label="Email" value="email" />
|
|
71
|
+
<Form.RadioGroup.Item label="SMS" value="sms" />
|
|
72
|
+
</Form.RadioGroup>
|
|
73
|
+
</Form.Field>
|
|
74
|
+
|
|
75
|
+
<Form.Field name="terms">
|
|
76
|
+
<Form.Checkbox label="Accept terms" />
|
|
77
|
+
</Form.Field>
|
|
78
|
+
|
|
79
|
+
<Form.Button>Submit</Form.Button>
|
|
80
|
+
</Form>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('Form', () => {
|
|
85
|
+
it('binds fields and submits React Hook Form values', async () => {
|
|
86
|
+
const handleSubmit = jest.fn();
|
|
87
|
+
await render(<TestForm onSubmit={handleSubmit} />);
|
|
88
|
+
|
|
89
|
+
await fireEvent.changeText(
|
|
90
|
+
screen.getByLabelText('Email'),
|
|
91
|
+
'ugo@example.com',
|
|
92
|
+
);
|
|
93
|
+
await fireEvent.changeText(screen.getByLabelText('Username'), 'ugo');
|
|
94
|
+
await fireEvent.changeText(screen.getByLabelText('Message'), 'Hello');
|
|
95
|
+
await fireEvent.press(
|
|
96
|
+
screen.getByRole('checkbox', { name: 'Accept terms' }),
|
|
97
|
+
);
|
|
98
|
+
await fireEvent.press(screen.getByRole('radio', { name: 'SMS' }));
|
|
99
|
+
await fireEvent.press(screen.getByRole('button', { name: 'Submit' }));
|
|
100
|
+
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
expect(handleSubmit).toHaveBeenCalledWith(
|
|
103
|
+
{
|
|
104
|
+
channel: 'sms',
|
|
105
|
+
email: 'ugo@example.com',
|
|
106
|
+
message: 'Hello',
|
|
107
|
+
terms: true,
|
|
108
|
+
username: 'ugo',
|
|
109
|
+
},
|
|
110
|
+
undefined,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('submits from the return key, like Enter on the web', async () => {
|
|
116
|
+
const handleSubmit = jest.fn();
|
|
117
|
+
await render(<TestForm onSubmit={handleSubmit} />);
|
|
118
|
+
|
|
119
|
+
await fireEvent(screen.getByLabelText('Email'), 'submitEditing');
|
|
120
|
+
|
|
121
|
+
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('shows the description until the field has an error', async () => {
|
|
125
|
+
await render(
|
|
126
|
+
<TestForm
|
|
127
|
+
fieldErrors={{ email: 'Enter a valid email address.' }}
|
|
128
|
+
onSubmit={jest.fn()}
|
|
129
|
+
/>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(
|
|
133
|
+
await screen.findByText('Enter a valid email address.'),
|
|
134
|
+
).toBeOnTheScreen();
|
|
135
|
+
expect(screen.queryByText("We'll send a confirmation link.")).toBeNull();
|
|
136
|
+
expect(screen.getByLabelText('Email').parent).toHaveProp(
|
|
137
|
+
'className',
|
|
138
|
+
expect.stringContaining('border-destructive/36'),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('frames an input group as invalid from the field', async () => {
|
|
143
|
+
await render(
|
|
144
|
+
<TestForm
|
|
145
|
+
fieldErrors={{ username: 'Pick another username.' }}
|
|
146
|
+
onSubmit={jest.fn()}
|
|
147
|
+
/>,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(await screen.findByText('Pick another username.')).toBeOnTheScreen();
|
|
151
|
+
expect(screen.getByTestId('username-group')).toHaveProp(
|
|
152
|
+
'className',
|
|
153
|
+
expect.stringContaining('border-destructive/36'),
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('marks a checkbox invalid from React Hook Form state', async () => {
|
|
158
|
+
await render(
|
|
159
|
+
<TestForm
|
|
160
|
+
fieldErrors={{ terms: 'Accept the terms to continue.' }}
|
|
161
|
+
onSubmit={jest.fn()}
|
|
162
|
+
/>,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(
|
|
166
|
+
await screen.findByText('Accept the terms to continue.'),
|
|
167
|
+
).toBeOnTheScreen();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('names the radio group by the field label', async () => {
|
|
171
|
+
await render(<TestForm onSubmit={jest.fn()} />);
|
|
172
|
+
|
|
173
|
+
expect(screen.getByLabelText('Notification channel')).toHaveProp(
|
|
174
|
+
'role',
|
|
175
|
+
'radiogroup',
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('throws when a binding has no field name', async () => {
|
|
180
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
181
|
+
|
|
182
|
+
function Unnamed() {
|
|
183
|
+
const form = useForm();
|
|
184
|
+
return (
|
|
185
|
+
<Form form={form}>
|
|
186
|
+
<Form.Input />
|
|
187
|
+
</Form>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await expect(render(<Unnamed />)).rejects.toThrow(
|
|
192
|
+
'Form.Input requires a name prop or a parent <Form.Field name="...">.',
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { use } from 'react';
|
|
2
|
+
|
|
3
|
+
import { Button, type ButtonProps } from '../Button';
|
|
4
|
+
import { FormSubmitContext } from './Form.context';
|
|
5
|
+
import { FormCheckbox, type FormCheckboxProps } from './FormCheckbox';
|
|
6
|
+
import { FormDescription, type FormDescriptionProps } from './FormDescription';
|
|
7
|
+
import { FormError, type FormErrorProps } from './FormError';
|
|
8
|
+
import { FormField, type FormFieldProps } from './FormField';
|
|
9
|
+
import { FormInput, type FormInputProps } from './FormInput';
|
|
10
|
+
import { FormInputGroup, type FormInputGroupProps } from './FormInputGroup';
|
|
11
|
+
import { FormLabel, type FormLabelProps } from './FormLabel';
|
|
12
|
+
import { FormRadioGroup, type FormRadioGroupProps } from './FormRadioGroup';
|
|
13
|
+
import { type FormProps, FormRoot } from './FormRoot';
|
|
14
|
+
import { FormTextarea, type FormTextareaProps } from './FormTextarea';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A `Button` that submits the form when pressed, like the web's submit
|
|
18
|
+
* button. Pass `onPress` to give it another action.
|
|
19
|
+
*/
|
|
20
|
+
function FormButton({ onPress, ...props }: ButtonProps) {
|
|
21
|
+
const submit = use(FormSubmitContext);
|
|
22
|
+
|
|
23
|
+
return <Button onPress={onPress ?? submit} {...props} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const Form = Object.assign(FormRoot, {
|
|
27
|
+
Button: FormButton,
|
|
28
|
+
Checkbox: FormCheckbox,
|
|
29
|
+
Description: FormDescription,
|
|
30
|
+
Error: FormError,
|
|
31
|
+
Field: FormField,
|
|
32
|
+
Input: FormInput,
|
|
33
|
+
InputGroup: FormInputGroup,
|
|
34
|
+
Label: FormLabel,
|
|
35
|
+
RadioGroup: FormRadioGroup,
|
|
36
|
+
Textarea: FormTextarea,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export { Form };
|
|
40
|
+
export type {
|
|
41
|
+
FormCheckboxProps,
|
|
42
|
+
FormDescriptionProps,
|
|
43
|
+
FormErrorProps,
|
|
44
|
+
FormFieldProps,
|
|
45
|
+
FormInputGroupProps,
|
|
46
|
+
FormInputProps,
|
|
47
|
+
FormLabelProps,
|
|
48
|
+
FormProps,
|
|
49
|
+
FormRadioGroupProps,
|
|
50
|
+
FormTextareaProps,
|
|
51
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useController } from 'react-hook-form';
|
|
2
|
+
|
|
3
|
+
import { Checkbox, type CheckboxProps } from './Checkbox';
|
|
4
|
+
import {
|
|
5
|
+
useFormControlProps,
|
|
6
|
+
useFormMethods,
|
|
7
|
+
useRequiredFieldName,
|
|
8
|
+
} from './Form.context';
|
|
9
|
+
|
|
10
|
+
export type FormCheckboxProps = Omit<CheckboxProps, 'checked' | 'invalid'> & {
|
|
11
|
+
name?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** A `Checkbox` bound to the field's boolean value. */
|
|
15
|
+
export function FormCheckbox({
|
|
16
|
+
name: nameProp,
|
|
17
|
+
onCheckedChange,
|
|
18
|
+
...props
|
|
19
|
+
}: FormCheckboxProps) {
|
|
20
|
+
const name = useRequiredFieldName(nameProp, 'Form.Checkbox');
|
|
21
|
+
const { control } = useFormMethods();
|
|
22
|
+
const { field } = useController({
|
|
23
|
+
control,
|
|
24
|
+
defaultValue: props.defaultChecked ?? false,
|
|
25
|
+
name,
|
|
26
|
+
});
|
|
27
|
+
const { accessibilityLabel, invalid } = useFormControlProps();
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Checkbox
|
|
31
|
+
// A `label` already names the checkbox.
|
|
32
|
+
accessibilityLabel={props.label ? undefined : accessibilityLabel}
|
|
33
|
+
{...props}
|
|
34
|
+
checked={Boolean(field.value)}
|
|
35
|
+
invalid={invalid}
|
|
36
|
+
onCheckedChange={(checked) => {
|
|
37
|
+
field.onChange(checked);
|
|
38
|
+
onCheckedChange?.(checked);
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Text } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type FormDescriptionProps = React.ComponentProps<typeof Text>;
|
|
6
|
+
|
|
7
|
+
export function FormDescription({ className, ...props }: FormDescriptionProps) {
|
|
8
|
+
return (
|
|
9
|
+
<Text
|
|
10
|
+
className={cn('text-muted-foreground text-sm', className)}
|
|
11
|
+
{...props}
|
|
12
|
+
/>
|
|
13
|
+
);
|
|
14
|
+
}
|