@pickle-ui/react-native 1.0.0-beta.1 → 1.0.0-beta.3
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 +62 -24
- package/dist/components/Badge.js +1 -1
- package/dist/components/Badge.js.map +1 -1
- package/dist/components/Button.d.ts.map +1 -1
- package/dist/components/Button.js +27 -23
- package/dist/components/Button.js.map +1 -1
- package/dist/components/Card.d.ts.map +1 -1
- package/dist/components/Card.js +7 -4
- 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.map +1 -1
- package/dist/components/Text.js +5 -4
- 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 +43 -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/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/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 +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/package.json +8 -3
- package/src/base.css +23 -5
- package/src/components/Badge.tsx +1 -1
- package/src/components/Button.tsx +27 -23
- package/src/components/Card.tsx +12 -8
- 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 +49 -0
- package/src/components/Text.tsx +5 -4
- 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/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 +167 -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 +20 -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/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/x-stack.ts +2 -0
- package/src/entries/y-stack.ts +2 -0
- package/src/fonts.test.tsx +244 -0
- package/src/index.ts +12 -0
- package/src/styles.css +12 -0
- package/dist/components/_internal/Spinner.d.ts +0 -9
- package/dist/components/_internal/Spinner.d.ts.map +0 -1
- package/dist/components/_internal/Spinner.js +0 -31
- package/dist/components/_internal/Spinner.js.map +0 -1
- package/src/components/_internal/Spinner.tsx +0 -49
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { View } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type InputFrameProps = React.ComponentProps<typeof View> & {
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
focused: boolean;
|
|
8
|
+
invalid?: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The border, background and focus ring the web's `Input`, `Textarea` and
|
|
13
|
+
* `InputGroup` draw around their field. React Native has no `ring`, so a
|
|
14
|
+
* 3pt outline sits outside the border while the field has focus.
|
|
15
|
+
*/
|
|
16
|
+
export function InputFrame({
|
|
17
|
+
children,
|
|
18
|
+
className,
|
|
19
|
+
disabled,
|
|
20
|
+
focused,
|
|
21
|
+
invalid,
|
|
22
|
+
...props
|
|
23
|
+
}: InputFrameProps) {
|
|
24
|
+
return (
|
|
25
|
+
<View
|
|
26
|
+
className={cn(
|
|
27
|
+
'border-input bg-background dark:bg-input/32 w-full flex-row rounded-lg border shadow-xs shadow-black/5',
|
|
28
|
+
invalid && 'border-destructive/36',
|
|
29
|
+
focused && (invalid ? 'border-destructive/64' : 'border-ring'),
|
|
30
|
+
(disabled || focused || invalid) && 'shadow-none',
|
|
31
|
+
disabled && 'opacity-64',
|
|
32
|
+
className,
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
{focused ? (
|
|
38
|
+
<View
|
|
39
|
+
className={cn(
|
|
40
|
+
'absolute -inset-1 rounded-[calc(var(--radius)+3px)] border-[3px]',
|
|
41
|
+
invalid
|
|
42
|
+
? 'border-destructive/16 dark:border-destructive/24'
|
|
43
|
+
: 'border-ring/24',
|
|
44
|
+
)}
|
|
45
|
+
pointerEvents="none"
|
|
46
|
+
/>
|
|
47
|
+
) : null}
|
|
48
|
+
</View>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { View } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn';
|
|
4
|
+
import { Text } from '../Text';
|
|
5
|
+
|
|
6
|
+
/** A labelled box for the layout stories. */
|
|
7
|
+
export function StoryItem({
|
|
8
|
+
children,
|
|
9
|
+
className,
|
|
10
|
+
}: {
|
|
11
|
+
children: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<View
|
|
16
|
+
className={cn(
|
|
17
|
+
'border-border bg-muted items-center justify-center rounded-md border px-4 py-3',
|
|
18
|
+
className,
|
|
19
|
+
)}
|
|
20
|
+
>
|
|
21
|
+
<Text>{children}</Text>
|
|
22
|
+
</View>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { cn } from '../../lib/cn';
|
|
2
|
+
|
|
3
|
+
// Same props and spacing scale as the layout helper of @pickle-ui/react. Each
|
|
4
|
+
// class is written out in full so Uniwind finds it in the source.
|
|
5
|
+
|
|
6
|
+
const alignClasses = {
|
|
7
|
+
baseline: 'items-baseline',
|
|
8
|
+
center: 'items-center',
|
|
9
|
+
end: 'items-end',
|
|
10
|
+
start: 'items-start',
|
|
11
|
+
stretch: 'items-stretch',
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
const gapClasses = {
|
|
15
|
+
0: 'gap-0',
|
|
16
|
+
0.5: 'gap-0.5',
|
|
17
|
+
1: 'gap-1',
|
|
18
|
+
1.5: 'gap-1.5',
|
|
19
|
+
2: 'gap-2',
|
|
20
|
+
2.5: 'gap-2.5',
|
|
21
|
+
3: 'gap-3',
|
|
22
|
+
3.5: 'gap-3.5',
|
|
23
|
+
4: 'gap-4',
|
|
24
|
+
5: 'gap-5',
|
|
25
|
+
6: 'gap-6',
|
|
26
|
+
8: 'gap-8',
|
|
27
|
+
10: 'gap-10',
|
|
28
|
+
12: 'gap-12',
|
|
29
|
+
16: 'gap-16',
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export const gapXClasses = {
|
|
33
|
+
0: 'gap-x-0',
|
|
34
|
+
0.5: 'gap-x-0.5',
|
|
35
|
+
1: 'gap-x-1',
|
|
36
|
+
1.5: 'gap-x-1.5',
|
|
37
|
+
2: 'gap-x-2',
|
|
38
|
+
2.5: 'gap-x-2.5',
|
|
39
|
+
3: 'gap-x-3',
|
|
40
|
+
3.5: 'gap-x-3.5',
|
|
41
|
+
4: 'gap-x-4',
|
|
42
|
+
5: 'gap-x-5',
|
|
43
|
+
6: 'gap-x-6',
|
|
44
|
+
8: 'gap-x-8',
|
|
45
|
+
10: 'gap-x-10',
|
|
46
|
+
12: 'gap-x-12',
|
|
47
|
+
16: 'gap-x-16',
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
export const gapYClasses = {
|
|
51
|
+
0: 'gap-y-0',
|
|
52
|
+
0.5: 'gap-y-0.5',
|
|
53
|
+
1: 'gap-y-1',
|
|
54
|
+
1.5: 'gap-y-1.5',
|
|
55
|
+
2: 'gap-y-2',
|
|
56
|
+
2.5: 'gap-y-2.5',
|
|
57
|
+
3: 'gap-y-3',
|
|
58
|
+
3.5: 'gap-y-3.5',
|
|
59
|
+
4: 'gap-y-4',
|
|
60
|
+
5: 'gap-y-5',
|
|
61
|
+
6: 'gap-y-6',
|
|
62
|
+
8: 'gap-y-8',
|
|
63
|
+
10: 'gap-y-10',
|
|
64
|
+
12: 'gap-y-12',
|
|
65
|
+
16: 'gap-y-16',
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
const justifyClasses = {
|
|
69
|
+
around: 'justify-around',
|
|
70
|
+
between: 'justify-between',
|
|
71
|
+
center: 'justify-center',
|
|
72
|
+
end: 'justify-end',
|
|
73
|
+
evenly: 'justify-evenly',
|
|
74
|
+
start: 'justify-start',
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
export type LayoutAlign = keyof typeof alignClasses;
|
|
78
|
+
export type LayoutGap = keyof typeof gapClasses;
|
|
79
|
+
export type LayoutJustify = keyof typeof justifyClasses;
|
|
80
|
+
|
|
81
|
+
export type LayoutStyleProps = {
|
|
82
|
+
align?: LayoutAlign;
|
|
83
|
+
gap?: LayoutGap;
|
|
84
|
+
gapX?: LayoutGap;
|
|
85
|
+
gapY?: LayoutGap;
|
|
86
|
+
justify?: LayoutJustify;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function getLayoutClasses({
|
|
90
|
+
align,
|
|
91
|
+
gap,
|
|
92
|
+
gapX,
|
|
93
|
+
gapY,
|
|
94
|
+
justify,
|
|
95
|
+
}: LayoutStyleProps) {
|
|
96
|
+
return cn(
|
|
97
|
+
align === undefined ? undefined : alignClasses[align],
|
|
98
|
+
gap === undefined ? undefined : gapClasses[gap],
|
|
99
|
+
gapX === undefined ? undefined : gapXClasses[gapX],
|
|
100
|
+
gapY === undefined ? undefined : gapYClasses[gapY],
|
|
101
|
+
justify === undefined ? undefined : justifyClasses[justify],
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* State a component owns unless its parent controls it, like Base UI's
|
|
5
|
+
* `checked` / `defaultChecked` / `onCheckedChange` trios: `value` wins when
|
|
6
|
+
* defined, and every change is reported through `onChange`.
|
|
7
|
+
*/
|
|
8
|
+
export function useControllableState<T>(
|
|
9
|
+
value: T | undefined,
|
|
10
|
+
defaultValue: T,
|
|
11
|
+
onChange?: (value: T) => void,
|
|
12
|
+
) {
|
|
13
|
+
const [uncontrolled, setUncontrolled] = useState(defaultValue);
|
|
14
|
+
const controlled = value !== undefined;
|
|
15
|
+
|
|
16
|
+
const setValue = useCallback(
|
|
17
|
+
(next: T) => {
|
|
18
|
+
if (!controlled) setUncontrolled(next);
|
|
19
|
+
onChange?.(next);
|
|
20
|
+
},
|
|
21
|
+
[controlled, onChange],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return [controlled ? value : uncontrolled, setValue] as const;
|
|
25
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
|
|
2
|
+
|
|
3
|
+
import { type ComponentMatrixCellProps, Story } from '../_internal/Story';
|
|
4
|
+
import { Checkbox, type CheckboxProps } from './Checkbox';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
component: Checkbox,
|
|
8
|
+
parameters: { layout: 'fullscreen' },
|
|
9
|
+
title: 'components/form/Checkbox',
|
|
10
|
+
} satisfies Meta<typeof Checkbox>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type CheckboxSize = NonNullable<CheckboxProps['size']>;
|
|
14
|
+
type StoryDefinition = StoryObj<typeof meta>;
|
|
15
|
+
|
|
16
|
+
const STATE_COLUMNS: {
|
|
17
|
+
key: string;
|
|
18
|
+
label: string;
|
|
19
|
+
props: Partial<CheckboxProps>;
|
|
20
|
+
}[] = [
|
|
21
|
+
{ key: 'unchecked', label: 'Unchecked', props: {} },
|
|
22
|
+
{ key: 'checked', label: 'Checked', props: { defaultChecked: true } },
|
|
23
|
+
{ key: 'indeterminate', label: 'Mixed', props: { indeterminate: true } },
|
|
24
|
+
{
|
|
25
|
+
key: 'invalid',
|
|
26
|
+
label: 'Invalid',
|
|
27
|
+
props: { defaultChecked: true, invalid: true },
|
|
28
|
+
},
|
|
29
|
+
{ key: 'disabled', label: 'Disabled', props: { disabled: true } },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const SIZE_ROWS: { key: CheckboxSize; label: string }[] = [
|
|
33
|
+
{ key: 'md', label: 'Medium' },
|
|
34
|
+
{ key: 'sm', label: 'Small' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const STATE_PROPS = Object.fromEntries(
|
|
38
|
+
STATE_COLUMNS.map((state) => [state.key, state.props]),
|
|
39
|
+
) as Record<string, Partial<CheckboxProps>>;
|
|
40
|
+
|
|
41
|
+
function CheckboxStateCell({ column, row }: ComponentMatrixCellProps) {
|
|
42
|
+
return (
|
|
43
|
+
<Checkbox
|
|
44
|
+
accessibilityLabel={`${row.label} ${column.label}`}
|
|
45
|
+
size={row.key as CheckboxSize}
|
|
46
|
+
{...STATE_PROPS[column.key]}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const All: StoryDefinition = {
|
|
52
|
+
render: () => (
|
|
53
|
+
<Story.Layout className="max-w-5xl" title="Checkbox">
|
|
54
|
+
<Story.Section title="Sizes and states">
|
|
55
|
+
<Story.Matrix
|
|
56
|
+
Cell={CheckboxStateCell}
|
|
57
|
+
columns={STATE_COLUMNS}
|
|
58
|
+
rows={SIZE_ROWS}
|
|
59
|
+
/>
|
|
60
|
+
</Story.Section>
|
|
61
|
+
|
|
62
|
+
<Story.Section title="Label">
|
|
63
|
+
<Checkbox label="Accept terms" />
|
|
64
|
+
</Story.Section>
|
|
65
|
+
</Story.Layout>
|
|
66
|
+
),
|
|
67
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react-native';
|
|
2
|
+
|
|
3
|
+
import { Checkbox } from './Checkbox';
|
|
4
|
+
|
|
5
|
+
describe('Checkbox', () => {
|
|
6
|
+
it('renders unchecked by default', async () => {
|
|
7
|
+
await render(<Checkbox accessibilityLabel="Accept" />);
|
|
8
|
+
|
|
9
|
+
expect(screen.getByRole('checkbox', { name: 'Accept' })).not.toBeChecked();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('toggles on press', async () => {
|
|
13
|
+
await render(<Checkbox accessibilityLabel="Accept" />);
|
|
14
|
+
const checkbox = screen.getByRole('checkbox', { name: 'Accept' });
|
|
15
|
+
|
|
16
|
+
await fireEvent.press(checkbox);
|
|
17
|
+
expect(checkbox).toBeChecked();
|
|
18
|
+
|
|
19
|
+
await fireEvent.press(checkbox);
|
|
20
|
+
expect(checkbox).not.toBeChecked();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('is named by its label, which toggles it', async () => {
|
|
24
|
+
const onCheckedChange = jest.fn();
|
|
25
|
+
await render(
|
|
26
|
+
<Checkbox label="Accept terms" onCheckedChange={onCheckedChange} />,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
await fireEvent.press(screen.getByText('Accept terms'));
|
|
30
|
+
|
|
31
|
+
expect(
|
|
32
|
+
screen.getByRole('checkbox', { name: 'Accept terms' }),
|
|
33
|
+
).toBeChecked();
|
|
34
|
+
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('follows the checked prop when controlled', async () => {
|
|
38
|
+
const onCheckedChange = jest.fn();
|
|
39
|
+
await render(
|
|
40
|
+
<Checkbox
|
|
41
|
+
accessibilityLabel="Accept"
|
|
42
|
+
checked={false}
|
|
43
|
+
onCheckedChange={onCheckedChange}
|
|
44
|
+
/>,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
await fireEvent.press(screen.getByRole('checkbox'));
|
|
48
|
+
|
|
49
|
+
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
|
50
|
+
expect(screen.getByRole('checkbox')).not.toBeChecked();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('reports the mixed state when indeterminate', async () => {
|
|
54
|
+
await render(<Checkbox accessibilityLabel="Partial" indeterminate />);
|
|
55
|
+
|
|
56
|
+
expect(
|
|
57
|
+
screen.getByRole('checkbox', { name: 'Partial' }),
|
|
58
|
+
).toBePartiallyChecked();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('is disabled and ignores presses', async () => {
|
|
62
|
+
await render(<Checkbox accessibilityLabel="Accept" disabled />);
|
|
63
|
+
const checkbox = screen.getByRole('checkbox', { name: 'Accept' });
|
|
64
|
+
|
|
65
|
+
await fireEvent.press(checkbox);
|
|
66
|
+
|
|
67
|
+
expect(checkbox).toBeDisabled();
|
|
68
|
+
expect(checkbox).not.toBeChecked();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reaches a 44pt touch target', async () => {
|
|
72
|
+
await render(
|
|
73
|
+
<>
|
|
74
|
+
<Checkbox accessibilityLabel="Box" />
|
|
75
|
+
<Checkbox label="Row" size="sm" />
|
|
76
|
+
</>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(screen.getByRole('checkbox', { name: 'Box' })).toHaveProp(
|
|
80
|
+
'hitSlop',
|
|
81
|
+
13,
|
|
82
|
+
);
|
|
83
|
+
expect(screen.getByRole('checkbox', { name: 'Row' })).toHaveProp(
|
|
84
|
+
'hitSlop',
|
|
85
|
+
{ bottom: 15, top: 15 },
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
return (
|
|
66
|
+
// `role` and `aria-*` are React Native's current accessibility props;
|
|
67
|
+
// `accessibilityState` keeps any extra state the caller passes.
|
|
68
|
+
<Pressable
|
|
69
|
+
accessibilityState={{
|
|
70
|
+
...accessibilityState,
|
|
71
|
+
checked: indeterminate ? 'mixed' : checked,
|
|
72
|
+
disabled,
|
|
73
|
+
}}
|
|
74
|
+
aria-checked={indeterminate ? 'mixed' : checked}
|
|
75
|
+
aria-disabled={disabled}
|
|
76
|
+
className={
|
|
77
|
+
label
|
|
78
|
+
? cn('flex-row items-center gap-2', classNames?.field)
|
|
79
|
+
: 'self-start'
|
|
80
|
+
}
|
|
81
|
+
disabled={disabled}
|
|
82
|
+
hitSlop={label ? fieldHitSlop[size] : boxHitSlop[size]}
|
|
83
|
+
onPress={() => setChecked(!checked)}
|
|
84
|
+
role="checkbox"
|
|
85
|
+
{...props}
|
|
86
|
+
>
|
|
87
|
+
<CheckboxBox
|
|
88
|
+
checked={checked}
|
|
89
|
+
className={className}
|
|
90
|
+
disabled={disabled}
|
|
91
|
+
indeterminate={indeterminate}
|
|
92
|
+
indicatorClassName={classNames?.indicator}
|
|
93
|
+
invalid={invalid}
|
|
94
|
+
size={size}
|
|
95
|
+
/>
|
|
96
|
+
{label ? (
|
|
97
|
+
<Text
|
|
98
|
+
className={cn(
|
|
99
|
+
'text-foreground font-sans text-sm leading-none font-medium',
|
|
100
|
+
disabled && 'opacity-50',
|
|
101
|
+
classNames?.label,
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{label}
|
|
105
|
+
</Text>
|
|
106
|
+
) : null}
|
|
107
|
+
</Pressable>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function CheckboxBox({
|
|
112
|
+
checked,
|
|
113
|
+
className,
|
|
114
|
+
disabled,
|
|
115
|
+
indeterminate,
|
|
116
|
+
indicatorClassName,
|
|
117
|
+
invalid,
|
|
118
|
+
size,
|
|
119
|
+
}: {
|
|
120
|
+
checked: boolean;
|
|
121
|
+
className?: string;
|
|
122
|
+
disabled: boolean;
|
|
123
|
+
indeterminate: boolean;
|
|
124
|
+
indicatorClassName?: string;
|
|
125
|
+
invalid: boolean;
|
|
126
|
+
size: 'md' | 'sm';
|
|
127
|
+
}) {
|
|
128
|
+
const filled = checked && !indeterminate;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<View
|
|
132
|
+
className={cn(
|
|
133
|
+
'items-center justify-center rounded-[4px] border shadow-xs shadow-black/5',
|
|
134
|
+
boxClasses[size],
|
|
135
|
+
// A checked box is filled in both themes, so the dark input
|
|
136
|
+
// background must not apply to it.
|
|
137
|
+
filled
|
|
138
|
+
? 'border-primary bg-primary'
|
|
139
|
+
: cn(
|
|
140
|
+
'border-input bg-background dark:bg-input/32',
|
|
141
|
+
invalid && 'border-destructive/36',
|
|
142
|
+
),
|
|
143
|
+
(checked || indeterminate || invalid || disabled) && 'shadow-none',
|
|
144
|
+
disabled && 'opacity-64',
|
|
145
|
+
className,
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
{indeterminate ? (
|
|
149
|
+
<View
|
|
150
|
+
className={cn(
|
|
151
|
+
'bg-foreground rounded-full',
|
|
152
|
+
minusClasses[size],
|
|
153
|
+
indicatorClassName,
|
|
154
|
+
)}
|
|
155
|
+
/>
|
|
156
|
+
) : checked ? (
|
|
157
|
+
<View
|
|
158
|
+
className={cn(
|
|
159
|
+
'border-primary-foreground -mt-px rotate-45',
|
|
160
|
+
checkClasses[size],
|
|
161
|
+
indicatorClassName,
|
|
162
|
+
)}
|
|
163
|
+
/>
|
|
164
|
+
) : null}
|
|
165
|
+
</View>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -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
|
+
}
|