@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,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 font-sans 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);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
|
|
2
|
+
|
|
3
|
+
import { IconEye, IconEyeOff, IconSearch } from '@tabler/icons-react-native';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { type ComponentMatrixCellProps, Story } from '../_internal/Story';
|
|
7
|
+
import { InputGroup } from './InputGroup';
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
component: InputGroup,
|
|
11
|
+
parameters: { layout: 'fullscreen' },
|
|
12
|
+
title: 'components/form/InputGroup',
|
|
13
|
+
} satisfies Meta<typeof InputGroup>;
|
|
14
|
+
|
|
15
|
+
export default meta;
|
|
16
|
+
type StoryDefinition = StoryObj<typeof meta>;
|
|
17
|
+
|
|
18
|
+
const STATE_COLUMNS = [
|
|
19
|
+
{ key: 'default', label: 'Default' },
|
|
20
|
+
{ key: 'disabled', label: 'Disabled' },
|
|
21
|
+
{ key: 'invalid', label: 'Invalid' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const PATTERN_ROWS = [
|
|
25
|
+
{ description: 'Leading icon addon', key: 'search', label: 'Search' },
|
|
26
|
+
{
|
|
27
|
+
description: 'Trailing visibility toggle',
|
|
28
|
+
key: 'password',
|
|
29
|
+
label: 'Password',
|
|
30
|
+
},
|
|
31
|
+
{ description: 'Leading text addon', key: 'currency', label: 'Currency' },
|
|
32
|
+
{ description: 'Trailing text addon', key: 'domain', label: 'Domain' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function InputGroupMatrixCell({ column, row }: ComponentMatrixCellProps) {
|
|
36
|
+
const inputProps = {
|
|
37
|
+
disabled: column.key === 'disabled',
|
|
38
|
+
invalid: column.key === 'invalid',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (row.key === 'search') {
|
|
42
|
+
return (
|
|
43
|
+
<InputGroup>
|
|
44
|
+
<InputGroup.Addon>
|
|
45
|
+
<Story.Icon icon={IconSearch} />
|
|
46
|
+
</InputGroup.Addon>
|
|
47
|
+
<InputGroup.Input
|
|
48
|
+
accessibilityLabel="Search"
|
|
49
|
+
inputMode="search"
|
|
50
|
+
placeholder="Search…"
|
|
51
|
+
{...inputProps}
|
|
52
|
+
/>
|
|
53
|
+
</InputGroup>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (row.key === 'password') {
|
|
58
|
+
return <PasswordInputGroupCell {...inputProps} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (row.key === 'currency') {
|
|
62
|
+
return (
|
|
63
|
+
<InputGroup>
|
|
64
|
+
<InputGroup.Addon>$</InputGroup.Addon>
|
|
65
|
+
<InputGroup.Input
|
|
66
|
+
accessibilityLabel="Amount in USD"
|
|
67
|
+
defaultValue="1200"
|
|
68
|
+
inputMode="decimal"
|
|
69
|
+
{...inputProps}
|
|
70
|
+
/>
|
|
71
|
+
</InputGroup>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<InputGroup>
|
|
77
|
+
<InputGroup.Input
|
|
78
|
+
accessibilityLabel="Pickle subdomain ending in .dev"
|
|
79
|
+
defaultValue="pickle"
|
|
80
|
+
{...inputProps}
|
|
81
|
+
/>
|
|
82
|
+
<InputGroup.Addon align="inline-end">.dev</InputGroup.Addon>
|
|
83
|
+
</InputGroup>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function PasswordInputGroupCell({
|
|
88
|
+
disabled,
|
|
89
|
+
invalid,
|
|
90
|
+
}: {
|
|
91
|
+
disabled: boolean;
|
|
92
|
+
invalid: boolean;
|
|
93
|
+
}) {
|
|
94
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<InputGroup>
|
|
98
|
+
<InputGroup.Input
|
|
99
|
+
accessibilityLabel="Password with toggle visibility"
|
|
100
|
+
disabled={disabled}
|
|
101
|
+
invalid={invalid}
|
|
102
|
+
placeholder="Enter your password"
|
|
103
|
+
secureTextEntry={!showPassword}
|
|
104
|
+
/>
|
|
105
|
+
<InputGroup.Button
|
|
106
|
+
accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
|
|
107
|
+
disabled={disabled}
|
|
108
|
+
onPress={() => setShowPassword((visible) => !visible)}
|
|
109
|
+
>
|
|
110
|
+
<Story.Icon icon={showPassword ? IconEyeOff : IconEye} />
|
|
111
|
+
</InputGroup.Button>
|
|
112
|
+
</InputGroup>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const All: StoryDefinition = {
|
|
117
|
+
render: () => (
|
|
118
|
+
<Story.Layout className="max-w-6xl" title="InputGroup">
|
|
119
|
+
<Story.Section title="Patterns and states">
|
|
120
|
+
<Story.Matrix
|
|
121
|
+
Cell={InputGroupMatrixCell}
|
|
122
|
+
cellClassName="items-stretch"
|
|
123
|
+
columns={STATE_COLUMNS}
|
|
124
|
+
rows={PATTERN_ROWS}
|
|
125
|
+
/>
|
|
126
|
+
</Story.Section>
|
|
127
|
+
</Story.Layout>
|
|
128
|
+
),
|
|
129
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react-native';
|
|
2
|
+
import { Text } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { InputGroup } from './InputGroup';
|
|
5
|
+
|
|
6
|
+
describe('InputGroup', () => {
|
|
7
|
+
it('places addons by align, whatever their order in the code', async () => {
|
|
8
|
+
await render(
|
|
9
|
+
<InputGroup testID="group">
|
|
10
|
+
<InputGroup.Addon align="inline-end">
|
|
11
|
+
<Text>.dev</Text>
|
|
12
|
+
</InputGroup.Addon>
|
|
13
|
+
<InputGroup.Input accessibilityLabel="Subdomain" />
|
|
14
|
+
<InputGroup.Addon>
|
|
15
|
+
<Text>https://</Text>
|
|
16
|
+
</InputGroup.Addon>
|
|
17
|
+
</InputGroup>,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const texts = screen
|
|
21
|
+
.getByTestId('group')
|
|
22
|
+
.children.map((child) =>
|
|
23
|
+
typeof child === 'string'
|
|
24
|
+
? child
|
|
25
|
+
: (child.props.accessibilityLabel ??
|
|
26
|
+
child.children
|
|
27
|
+
.map((c) => (typeof c === 'string' ? c : c.props.children))
|
|
28
|
+
.join('')),
|
|
29
|
+
);
|
|
30
|
+
expect(texts).toEqual(['https://', 'Subdomain', '.dev']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('pads the input on the sides that have an addon', async () => {
|
|
34
|
+
await render(
|
|
35
|
+
<InputGroup>
|
|
36
|
+
<InputGroup.Addon>$</InputGroup.Addon>
|
|
37
|
+
<InputGroup.Input accessibilityLabel="Amount" />
|
|
38
|
+
</InputGroup>,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(screen.getByLabelText('Amount')).toHaveProp(
|
|
42
|
+
'className',
|
|
43
|
+
expect.stringContaining('ps-2'),
|
|
44
|
+
);
|
|
45
|
+
expect(screen.getByLabelText('Amount')).not.toHaveProp(
|
|
46
|
+
'className',
|
|
47
|
+
expect.stringContaining('pe-2'),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('frames the group while its input has focus', async () => {
|
|
52
|
+
await render(
|
|
53
|
+
<InputGroup testID="group">
|
|
54
|
+
<InputGroup.Input accessibilityLabel="Search" />
|
|
55
|
+
</InputGroup>,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await fireEvent(screen.getByLabelText('Search'), 'focus');
|
|
59
|
+
|
|
60
|
+
expect(screen.getByTestId('group')).toHaveProp(
|
|
61
|
+
'className',
|
|
62
|
+
expect.stringContaining('border-ring'),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('marks the frame from the input’s invalid and disabled props', async () => {
|
|
67
|
+
await render(
|
|
68
|
+
<InputGroup testID="group">
|
|
69
|
+
<InputGroup.Input accessibilityLabel="Email" disabled invalid />
|
|
70
|
+
</InputGroup>,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(screen.getByTestId('group')).toHaveProp(
|
|
74
|
+
'className',
|
|
75
|
+
expect.stringMatching(/border-destructive\/36.*opacity-64/),
|
|
76
|
+
);
|
|
77
|
+
expect(screen.getByLabelText('Email')).toBeDisabled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('renders InputGroup.Button as a ghost button after the input', async () => {
|
|
81
|
+
const onPress = jest.fn();
|
|
82
|
+
await render(
|
|
83
|
+
<InputGroup>
|
|
84
|
+
<InputGroup.Button accessibilityLabel="Clear" onPress={onPress}>
|
|
85
|
+
<Text>×</Text>
|
|
86
|
+
</InputGroup.Button>
|
|
87
|
+
<InputGroup.Input accessibilityLabel="Search" />
|
|
88
|
+
</InputGroup>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await fireEvent.press(screen.getByRole('button', { name: 'Clear' }));
|
|
92
|
+
|
|
93
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
});
|