@boostdev/design-system-react-native 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/LICENSE +15 -0
- package/README.md +104 -2
- package/dist/index.cjs +106 -60
- package/dist/index.d.cts +61 -6
- package/dist/index.d.ts +61 -6
- package/dist/index.js +106 -60
- package/package.json +30 -10
- package/src/components/interaction/Button/Button.mdx +91 -0
- package/src/components/interaction/Button/Button.spec.tsx +55 -0
- package/src/components/interaction/Button/Button.stories.tsx +141 -0
- package/src/components/interaction/Button/Button.tsx +105 -0
- package/src/components/interaction/Dialog/Dialog.mdx +61 -0
- package/src/components/interaction/Dialog/Dialog.spec.tsx +73 -0
- package/src/components/interaction/Dialog/Dialog.stories.tsx +53 -0
- package/src/components/interaction/Dialog/Dialog.tsx +128 -0
- package/src/components/interaction/DropdownMenu/DropdownMenu.mdx +74 -0
- package/src/components/interaction/DropdownMenu/DropdownMenu.spec.tsx +78 -0
- package/src/components/interaction/DropdownMenu/DropdownMenu.stories.tsx +51 -0
- package/src/components/interaction/DropdownMenu/DropdownMenu.tsx +254 -0
- package/src/components/interaction/Popover/Popover.mdx +61 -0
- package/src/components/interaction/Popover/Popover.spec.tsx +73 -0
- package/src/components/interaction/Popover/Popover.stories.tsx +44 -0
- package/src/components/interaction/Popover/Popover.tsx +87 -0
- package/src/components/interaction/Rating/Rating.mdx +55 -0
- package/src/components/interaction/Rating/Rating.spec.tsx +38 -0
- package/src/components/interaction/Rating/Rating.stories.tsx +37 -0
- package/src/components/interaction/Rating/Rating.tsx +50 -0
- package/src/components/interaction/SideSheet/SideSheet.mdx +60 -0
- package/src/components/interaction/SideSheet/SideSheet.spec.tsx +80 -0
- package/src/components/interaction/SideSheet/SideSheet.stories.tsx +33 -0
- package/src/components/interaction/SideSheet/SideSheet.tsx +175 -0
- package/src/components/interaction/Toast/Toast.mdx +81 -0
- package/src/components/interaction/Toast/Toast.spec.tsx +80 -0
- package/src/components/interaction/Toast/Toast.stories.tsx +47 -0
- package/src/components/interaction/Toast/Toast.tsx +202 -0
- package/src/components/interaction/form/Checkbox/Checkbox.mdx +74 -0
- package/src/components/interaction/form/Checkbox/Checkbox.spec.tsx +42 -0
- package/src/components/interaction/form/Checkbox/Checkbox.stories.tsx +34 -0
- package/src/components/interaction/form/Checkbox/Checkbox.tsx +80 -0
- package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.mdx +78 -0
- package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.spec.tsx +57 -0
- package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.stories.tsx +96 -0
- package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.tsx +61 -0
- package/src/components/interaction/form/Combobox/Combobox.mdx +86 -0
- package/src/components/interaction/form/Combobox/Combobox.spec.tsx +60 -0
- package/src/components/interaction/form/Combobox/Combobox.stories.tsx +99 -0
- package/src/components/interaction/form/Combobox/Combobox.tsx +212 -0
- package/src/components/interaction/form/FileInput/FileInput.mdx +86 -0
- package/src/components/interaction/form/FileInput/FileInput.spec.tsx +73 -0
- package/src/components/interaction/form/FileInput/FileInput.stories.tsx +69 -0
- package/src/components/interaction/form/FileInput/FileInput.tsx +129 -0
- package/src/components/interaction/form/FormInput/FormInput.mdx +81 -0
- package/src/components/interaction/form/FormInput/FormInput.spec.tsx +81 -0
- package/src/components/interaction/form/FormInput/FormInput.stories.tsx +54 -0
- package/src/components/interaction/form/FormInput/FormInput.tsx +123 -0
- package/src/components/interaction/form/NumberInput/NumberInput.mdx +87 -0
- package/src/components/interaction/form/NumberInput/NumberInput.spec.tsx +63 -0
- package/src/components/interaction/form/NumberInput/NumberInput.stories.tsx +63 -0
- package/src/components/interaction/form/NumberInput/NumberInput.tsx +199 -0
- package/src/components/interaction/form/Radio/Radio.spec.tsx +79 -0
- package/src/components/interaction/form/Radio/Radio.stories.tsx +44 -0
- package/src/components/interaction/form/Radio/Radio.tsx +79 -0
- package/src/components/interaction/form/RadioGroup/RadioGroup.mdx +79 -0
- package/src/components/interaction/form/RadioGroup/RadioGroup.spec.tsx +66 -0
- package/src/components/interaction/form/RadioGroup/RadioGroup.stories.tsx +96 -0
- package/src/components/interaction/form/RadioGroup/RadioGroup.tsx +61 -0
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.mdx +78 -0
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.spec.tsx +82 -0
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.stories.tsx +99 -0
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.tsx +121 -0
- package/src/components/interaction/form/Select/Select.mdx +85 -0
- package/src/components/interaction/form/Select/Select.spec.tsx +64 -0
- package/src/components/interaction/form/Select/Select.stories.tsx +75 -0
- package/src/components/interaction/form/Select/Select.tsx +252 -0
- package/src/components/interaction/form/Slider/Slider.mdx +83 -0
- package/src/components/interaction/form/Slider/Slider.spec.tsx +51 -0
- package/src/components/interaction/form/Slider/Slider.stories.tsx +58 -0
- package/src/components/interaction/form/Slider/Slider.tsx +188 -0
- package/src/components/interaction/form/Switch/Switch.spec.tsx +60 -0
- package/src/components/interaction/form/Switch/Switch.stories.tsx +33 -0
- package/src/components/interaction/form/Switch/Switch.tsx +59 -0
- package/src/components/interaction/form/Textarea/Textarea.mdx +76 -0
- package/src/components/interaction/form/Textarea/Textarea.spec.tsx +50 -0
- package/src/components/interaction/form/Textarea/Textarea.stories.tsx +50 -0
- package/src/components/interaction/form/Textarea/Textarea.tsx +108 -0
- package/src/components/interaction/form/atoms/Label.spec.tsx +39 -0
- package/src/components/interaction/form/atoms/Label.stories.tsx +18 -0
- package/src/components/interaction/form/atoms/Label.tsx +29 -0
- package/src/components/interaction/form/atoms/Message.spec.tsx +30 -0
- package/src/components/interaction/form/atoms/Message.stories.tsx +22 -0
- package/src/components/interaction/form/atoms/Message.tsx +34 -0
- package/src/components/layout/ButtonGroup/ButtonGroup.spec.tsx +83 -0
- package/src/components/layout/ButtonGroup/ButtonGroup.stories.tsx +44 -0
- package/src/components/layout/ButtonGroup/ButtonGroup.tsx +44 -0
- package/src/components/layout/Card/Card.spec.tsx +102 -0
- package/src/components/layout/Card/Card.stories.tsx +51 -0
- package/src/components/layout/Card/Card.tsx +89 -0
- package/src/components/layout/IconWrapper/IconWrapper.spec.tsx +39 -0
- package/src/components/layout/IconWrapper/IconWrapper.stories.tsx +37 -0
- package/src/components/layout/IconWrapper/IconWrapper.tsx +20 -0
- package/src/components/layout/SectionHeader/SectionHeader.spec.tsx +51 -0
- package/src/components/layout/SectionHeader/SectionHeader.stories.tsx +36 -0
- package/src/components/layout/SectionHeader/SectionHeader.tsx +79 -0
- package/src/components/ui/Accordion/Accordion.mdx +65 -0
- package/src/components/ui/Accordion/Accordion.spec.tsx +69 -0
- package/src/components/ui/Accordion/Accordion.stories.tsx +52 -0
- package/src/components/ui/Accordion/Accordion.tsx +141 -0
- package/src/components/ui/Alert/Alert.mdx +62 -0
- package/src/components/ui/Alert/Alert.spec.tsx +76 -0
- package/src/components/ui/Alert/Alert.stories.tsx +41 -0
- package/src/components/ui/Alert/Alert.tsx +94 -0
- package/src/components/ui/Avatar/Avatar.mdx +50 -0
- package/src/components/ui/Avatar/Avatar.spec.tsx +47 -0
- package/src/components/ui/Avatar/Avatar.stories.tsx +32 -0
- package/src/components/ui/Avatar/Avatar.tsx +85 -0
- package/src/components/ui/Badge/Badge.mdx +54 -0
- package/src/components/ui/Badge/Badge.spec.tsx +45 -0
- package/src/components/ui/Badge/Badge.stories.tsx +34 -0
- package/src/components/ui/Badge/Badge.tsx +50 -0
- package/src/components/ui/Breadcrumb/Breadcrumb.mdx +52 -0
- package/src/components/ui/Breadcrumb/Breadcrumb.spec.tsx +49 -0
- package/src/components/ui/Breadcrumb/Breadcrumb.stories.tsx +46 -0
- package/src/components/ui/Breadcrumb/Breadcrumb.tsx +83 -0
- package/src/components/ui/Calendar/Calendar.mdx +58 -0
- package/src/components/ui/Calendar/Calendar.spec.tsx +92 -0
- package/src/components/ui/Calendar/Calendar.stories.tsx +45 -0
- package/src/components/ui/Calendar/Calendar.tsx +294 -0
- package/src/components/ui/Carousel/Carousel.mdx +55 -0
- package/src/components/ui/Carousel/Carousel.spec.tsx +55 -0
- package/src/components/ui/Carousel/Carousel.stories.tsx +59 -0
- package/src/components/ui/Carousel/Carousel.tsx +184 -0
- package/src/components/ui/Collapsible/Collapsible.mdx +53 -0
- package/src/components/ui/Collapsible/Collapsible.spec.tsx +68 -0
- package/src/components/ui/Collapsible/Collapsible.stories.tsx +48 -0
- package/src/components/ui/Collapsible/Collapsible.tsx +139 -0
- package/src/components/ui/DescriptionList/DescriptionList.mdx +50 -0
- package/src/components/ui/DescriptionList/DescriptionList.spec.tsx +37 -0
- package/src/components/ui/DescriptionList/DescriptionList.stories.tsx +48 -0
- package/src/components/ui/DescriptionList/DescriptionList.tsx +54 -0
- package/src/components/ui/Link/Link.mdx +53 -0
- package/src/components/ui/Link/Link.spec.tsx +35 -0
- package/src/components/ui/Link/Link.stories.tsx +46 -0
- package/src/components/ui/Link/Link.tsx +48 -0
- package/src/components/ui/Loading/Loading.spec.tsx +24 -0
- package/src/components/ui/Loading/Loading.stories.tsx +31 -0
- package/src/components/ui/Loading/Loading.tsx +29 -0
- package/src/components/ui/NotificationBanner/NotificationBanner.spec.tsx +85 -0
- package/src/components/ui/NotificationBanner/NotificationBanner.stories.tsx +37 -0
- package/src/components/ui/NotificationBanner/NotificationBanner.tsx +76 -0
- package/src/components/ui/Pagination/Pagination.mdx +62 -0
- package/src/components/ui/Pagination/Pagination.spec.tsx +86 -0
- package/src/components/ui/Pagination/Pagination.stories.tsx +47 -0
- package/src/components/ui/Pagination/Pagination.tsx +157 -0
- package/src/components/ui/Progress/Progress.spec.tsx +56 -0
- package/src/components/ui/Progress/Progress.stories.tsx +34 -0
- package/src/components/ui/Progress/Progress.tsx +84 -0
- package/src/components/ui/ProgressCircle/ProgressCircle.mdx +63 -0
- package/src/components/ui/ProgressCircle/ProgressCircle.spec.tsx +39 -0
- package/src/components/ui/ProgressCircle/ProgressCircle.stories.tsx +36 -0
- package/src/components/ui/ProgressCircle/ProgressCircle.tsx +176 -0
- package/src/components/ui/Separator/Separator.spec.tsx +39 -0
- package/src/components/ui/Separator/Separator.stories.tsx +38 -0
- package/src/components/ui/Separator/Separator.tsx +32 -0
- package/src/components/ui/Skeleton/Skeleton.spec.tsx +39 -0
- package/src/components/ui/Skeleton/Skeleton.stories.tsx +31 -0
- package/src/components/ui/Skeleton/Skeleton.tsx +42 -0
- package/src/components/ui/Table/Table.mdx +72 -0
- package/src/components/ui/Table/Table.spec.tsx +83 -0
- package/src/components/ui/Table/Table.stories.tsx +55 -0
- package/src/components/ui/Table/Table.tsx +196 -0
- package/src/components/ui/Tabs/Tabs.mdx +65 -0
- package/src/components/ui/Tabs/Tabs.spec.tsx +65 -0
- package/src/components/ui/Tabs/Tabs.stories.tsx +57 -0
- package/src/components/ui/Tabs/Tabs.tsx +133 -0
- package/src/components/ui/Tooltip/Tooltip.mdx +47 -0
- package/src/components/ui/Tooltip/Tooltip.spec.tsx +37 -0
- package/src/components/ui/Tooltip/Tooltip.stories.tsx +38 -0
- package/src/components/ui/Tooltip/Tooltip.tsx +57 -0
- package/src/components/ui/Typography/Typography.mdx +56 -0
- package/src/components/ui/Typography/Typography.spec.tsx +49 -0
- package/src/components/ui/Typography/Typography.stories.tsx +36 -0
- package/src/components/ui/Typography/Typography.tsx +65 -0
- package/src/index.ts +86 -0
- package/src/native/ReactNavigationTheme.mdx +140 -0
- package/src/native/ReactNavigationTheme.spec.tsx +35 -0
- package/src/native/ReactNavigationTheme.ts +102 -0
- package/src/native/ThemeContext.spec.tsx +57 -0
- package/src/native/ThemeContext.tsx +48 -0
- package/src/native/tokens.ts +13 -0
- package/src/test/setup.ts +1 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { ThemeProvider } from '../../../../native/ThemeContext';
|
|
3
|
+
import { FormInput } from './FormInput';
|
|
4
|
+
|
|
5
|
+
function renderWithTheme(ui: React.ReactElement) {
|
|
6
|
+
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('FormInput (native)', () => {
|
|
10
|
+
it('renders the label', () => {
|
|
11
|
+
renderWithTheme(<FormInput label="Full name" name="fullName" />);
|
|
12
|
+
expect(screen.getByText('Full name')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('renders with placeholder text', () => {
|
|
16
|
+
renderWithTheme(
|
|
17
|
+
<FormInput label="Email" name="email" placeholder="you@example.com" />,
|
|
18
|
+
);
|
|
19
|
+
expect(screen.getByPlaceholderText('you@example.com')).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders with a value', () => {
|
|
23
|
+
renderWithTheme(
|
|
24
|
+
<FormInput label="Name" name="name" value="Jane" />,
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByDisplayValue('Jane')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('shows error message', () => {
|
|
30
|
+
renderWithTheme(
|
|
31
|
+
<FormInput label="Email" name="email" error="Invalid email." />,
|
|
32
|
+
);
|
|
33
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email.');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('shows hint message', () => {
|
|
37
|
+
renderWithTheme(
|
|
38
|
+
<FormInput label="Username" name="username" hint="At least 3 characters." />,
|
|
39
|
+
);
|
|
40
|
+
expect(screen.getByText('At least 3 characters.')).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('applies disabled opacity', () => {
|
|
44
|
+
const { container } = renderWithTheme(
|
|
45
|
+
<FormInput label="Name" name="name" disabled />,
|
|
46
|
+
);
|
|
47
|
+
// The TextInput should have opacity 0.4 when disabled
|
|
48
|
+
const inputs = container.querySelectorAll('input, [style*="opacity"]');
|
|
49
|
+
expect(inputs.length).toBeGreaterThan(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Style-contract invariant: single-line TextInput must NOT have lineHeight set.
|
|
54
|
+
*
|
|
55
|
+
* On iOS, an explicit lineHeight on a single-line TextInput causes the text to
|
|
56
|
+
* render vertically clipped — descenders and ascenders are cut off at the line box
|
|
57
|
+
* boundary. The minHeight: 44 token satisfies the touch-target requirement without
|
|
58
|
+
* needing lineHeight. This test pins that invariant so it can't silently regress.
|
|
59
|
+
*
|
|
60
|
+
* react-native-web applies token-based styles as atomic CSS classes. getComputedStyle
|
|
61
|
+
* resolves those classes in jsdom and returns "normal" when lineHeight is absent.
|
|
62
|
+
*/
|
|
63
|
+
it('does not set lineHeight on the TextInput (iOS clipping prevention)', () => {
|
|
64
|
+
renderWithTheme(<FormInput label="Email" name="email" value="bjorn@boonen.eu" />);
|
|
65
|
+
const input = screen.getByDisplayValue('bjorn@boonen.eu');
|
|
66
|
+
// "normal" means no explicit lineHeight was applied; a numeric value means it was.
|
|
67
|
+
expect(getComputedStyle(input).lineHeight).toBe('normal');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Touch-target invariant: single-line input must meet the 44pt minimum touch target.
|
|
72
|
+
* This is the replacement constraint that makes lineHeight safe to omit.
|
|
73
|
+
*/
|
|
74
|
+
it('has minHeight of at least 44pt on the TextInput', () => {
|
|
75
|
+
renderWithTheme(<FormInput label="Email" name="email" />);
|
|
76
|
+
const input = screen.getByRole('textbox');
|
|
77
|
+
// getComputedStyle resolves the atomic CSS class to a pixel value.
|
|
78
|
+
const minHeightPx = parseFloat(getComputedStyle(input).minHeight);
|
|
79
|
+
expect(minHeightPx).toBeGreaterThanOrEqual(44);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { ThemeProvider } from '../../../../native/ThemeContext';
|
|
4
|
+
import { FormInput } from './FormInput';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
tags: ['!stable', 'alpha'],
|
|
8
|
+
title: 'React Native/Form/FormInput',
|
|
9
|
+
component: FormInput,
|
|
10
|
+
decorators: [(Story) => <ThemeProvider colorScheme="light"><Story /></ThemeProvider>],
|
|
11
|
+
parameters: { layout: 'padded' },
|
|
12
|
+
} satisfies Meta<typeof FormInput>;
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
export const Default: Story = {
|
|
18
|
+
args: { label: 'Full name', name: 'fullName' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const WithPlaceholder: Story = {
|
|
22
|
+
args: { label: 'Email', name: 'email', type: 'email', placeholder: 'you@example.com' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const Password: Story = {
|
|
26
|
+
args: { label: 'Password', name: 'password', type: 'password' },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const WithError: Story = {
|
|
30
|
+
args: { label: 'Email', name: 'email', type: 'email', error: 'Please enter a valid email address.' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const WithHint: Story = {
|
|
34
|
+
args: { label: 'Username', name: 'username', hint: 'Must be at least 3 characters.' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const Disabled: Story = {
|
|
38
|
+
args: { label: 'Full name', name: 'fullName', disabled: true, value: 'Jane Doe' },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const Interactive: Story = {
|
|
42
|
+
render: () => {
|
|
43
|
+
const [value, setValue] = useState('');
|
|
44
|
+
return (
|
|
45
|
+
<FormInput
|
|
46
|
+
label="Your name"
|
|
47
|
+
name="name"
|
|
48
|
+
placeholder="Enter your name"
|
|
49
|
+
value={value}
|
|
50
|
+
onChange={setValue}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ReactNode, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
StyleSheet,
|
|
4
|
+
View,
|
|
5
|
+
TextInput,
|
|
6
|
+
KeyboardTypeOptions,
|
|
7
|
+
StyleProp,
|
|
8
|
+
ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { spacing, font, border } from '../../../../native/tokens';
|
|
11
|
+
import { useTheme } from '../../../../native/ThemeContext';
|
|
12
|
+
import { Label } from '../atoms/Label';
|
|
13
|
+
import { Message } from '../atoms/Message';
|
|
14
|
+
|
|
15
|
+
type InputType = 'text' | 'email' | 'password' | 'tel' | 'url' | 'number' | 'search';
|
|
16
|
+
|
|
17
|
+
interface FormInputProps {
|
|
18
|
+
label: ReactNode;
|
|
19
|
+
name: string;
|
|
20
|
+
type?: InputType;
|
|
21
|
+
value?: string;
|
|
22
|
+
defaultValue?: string;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
error?: string;
|
|
25
|
+
hint?: string;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
onChange?: (value: string) => void;
|
|
28
|
+
onBlur?: () => void;
|
|
29
|
+
onFocus?: () => void;
|
|
30
|
+
style?: StyleProp<ViewStyle>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const keyboardTypeMap: Record<InputType, KeyboardTypeOptions> = {
|
|
34
|
+
text: 'default',
|
|
35
|
+
email: 'email-address',
|
|
36
|
+
password: 'default',
|
|
37
|
+
tel: 'phone-pad',
|
|
38
|
+
url: 'url',
|
|
39
|
+
number: 'numeric',
|
|
40
|
+
search: 'default',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const styles = StyleSheet.create({
|
|
44
|
+
container: {
|
|
45
|
+
gap: spacing.xxs,
|
|
46
|
+
},
|
|
47
|
+
input: {
|
|
48
|
+
fontFamily: font.family.body,
|
|
49
|
+
fontSize: font.size.body,
|
|
50
|
+
// No lineHeight on single-line TextInput: iOS clips descenders/ascenders when
|
|
51
|
+
// lineHeight is set explicitly. minHeight: 44 satisfies the touch-target requirement.
|
|
52
|
+
paddingHorizontal: spacing.s,
|
|
53
|
+
paddingVertical: spacing.xs,
|
|
54
|
+
borderWidth: 1,
|
|
55
|
+
borderRadius: border.radius.full,
|
|
56
|
+
minHeight: 44,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export function FormInput({
|
|
61
|
+
label,
|
|
62
|
+
name,
|
|
63
|
+
type = 'text',
|
|
64
|
+
value,
|
|
65
|
+
defaultValue,
|
|
66
|
+
placeholder,
|
|
67
|
+
error,
|
|
68
|
+
hint,
|
|
69
|
+
disabled = false,
|
|
70
|
+
onChange,
|
|
71
|
+
onBlur,
|
|
72
|
+
onFocus,
|
|
73
|
+
style,
|
|
74
|
+
}: Readonly<FormInputProps>) {
|
|
75
|
+
const { colors } = useTheme();
|
|
76
|
+
const [focused, setFocused] = useState(false);
|
|
77
|
+
|
|
78
|
+
const borderColor = error
|
|
79
|
+
? colors.colorError
|
|
80
|
+
: focused
|
|
81
|
+
? colors.colorInteractive
|
|
82
|
+
: colors.colorOnBg;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<View style={[styles.container, style]}>
|
|
86
|
+
<Label label={label} />
|
|
87
|
+
<TextInput
|
|
88
|
+
accessibilityLabel={typeof label === 'string' ? label : name}
|
|
89
|
+
accessibilityState={{ disabled }}
|
|
90
|
+
accessibilityHint={hint}
|
|
91
|
+
value={value}
|
|
92
|
+
defaultValue={defaultValue}
|
|
93
|
+
placeholder={placeholder}
|
|
94
|
+
placeholderTextColor={colors.colorOnBgSubtle}
|
|
95
|
+
keyboardType={keyboardTypeMap[type]}
|
|
96
|
+
secureTextEntry={type === 'password'}
|
|
97
|
+
autoCapitalize={type === 'email' || type === 'url' ? 'none' : 'sentences'}
|
|
98
|
+
autoComplete={type === 'email' ? 'email' : type === 'tel' ? 'tel' : undefined}
|
|
99
|
+
editable={!disabled}
|
|
100
|
+
onChangeText={onChange}
|
|
101
|
+
onFocus={() => {
|
|
102
|
+
setFocused(true);
|
|
103
|
+
onFocus?.();
|
|
104
|
+
}}
|
|
105
|
+
onBlur={() => {
|
|
106
|
+
setFocused(false);
|
|
107
|
+
onBlur?.();
|
|
108
|
+
}}
|
|
109
|
+
style={[
|
|
110
|
+
styles.input,
|
|
111
|
+
{
|
|
112
|
+
borderColor,
|
|
113
|
+
color: colors.colorOnBg,
|
|
114
|
+
backgroundColor: colors.colorBg,
|
|
115
|
+
},
|
|
116
|
+
disabled && { opacity: 0.4 },
|
|
117
|
+
]}
|
|
118
|
+
/>
|
|
119
|
+
<Message type="error" message={error} />
|
|
120
|
+
<Message type="hint" message={hint} />
|
|
121
|
+
</View>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
|
|
2
|
+
import * as Stories from './NumberInput.stories';
|
|
3
|
+
|
|
4
|
+
<Meta of={Stories} />
|
|
5
|
+
|
|
6
|
+
# NumberInput (React Native)
|
|
7
|
+
|
|
8
|
+
Numeric input with stepper buttons on each side. Supports min/max clamping, configurable step, and controlled or uncontrolled usage.
|
|
9
|
+
|
|
10
|
+
> **Status: alpha** — API may change before stable release.
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
import { NumberInput } from '@boostdev/design-system-components';
|
|
16
|
+
import { useState } from 'react';
|
|
17
|
+
|
|
18
|
+
function Example() {
|
|
19
|
+
const [qty, setQty] = useState(1);
|
|
20
|
+
return (
|
|
21
|
+
<NumberInput
|
|
22
|
+
label="Quantity"
|
|
23
|
+
name="qty"
|
|
24
|
+
value={qty}
|
|
25
|
+
min={1}
|
|
26
|
+
max={20}
|
|
27
|
+
onChange={setQty}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Examples
|
|
34
|
+
|
|
35
|
+
### Default
|
|
36
|
+
<Canvas of={Stories.Default} />
|
|
37
|
+
|
|
38
|
+
### With min and max
|
|
39
|
+
<Canvas of={Stories.WithMinMax} />
|
|
40
|
+
|
|
41
|
+
### With step
|
|
42
|
+
<Canvas of={Stories.WithStep} />
|
|
43
|
+
|
|
44
|
+
### At minimum
|
|
45
|
+
<Canvas of={Stories.AtMinimum} />
|
|
46
|
+
|
|
47
|
+
### At maximum
|
|
48
|
+
<Canvas of={Stories.AtMaximum} />
|
|
49
|
+
|
|
50
|
+
### With error
|
|
51
|
+
<Canvas of={Stories.WithError} />
|
|
52
|
+
|
|
53
|
+
### With hint
|
|
54
|
+
<Canvas of={Stories.WithHint} />
|
|
55
|
+
|
|
56
|
+
### Disabled
|
|
57
|
+
<Canvas of={Stories.Disabled} />
|
|
58
|
+
|
|
59
|
+
### Interactive
|
|
60
|
+
<Canvas of={Stories.Interactive} />
|
|
61
|
+
|
|
62
|
+
## Props
|
|
63
|
+
|
|
64
|
+
<ArgTypes of={Stories} />
|
|
65
|
+
|
|
66
|
+
## Props reference
|
|
67
|
+
|
|
68
|
+
| Prop | Type | Default | Description |
|
|
69
|
+
|------|------|---------|-------------|
|
|
70
|
+
| `label` | string | — | Visible label text |
|
|
71
|
+
| `name` | string | — | Field name |
|
|
72
|
+
| `value` | number | — | Controlled value |
|
|
73
|
+
| `defaultValue` | number | — | Uncontrolled default value (defaults to `0`) |
|
|
74
|
+
| `min` | number | — | Minimum allowed value |
|
|
75
|
+
| `max` | number | — | Maximum allowed value |
|
|
76
|
+
| `step` | number | `1` | Increment/decrement amount |
|
|
77
|
+
| `disabled` | boolean | `false` | Prevents interaction, reduces opacity |
|
|
78
|
+
| `error` | string | — | Error message displayed below the input |
|
|
79
|
+
| `hint` | string | — | Hint text displayed below the input |
|
|
80
|
+
| `onChange` | `(value: number) => void` | — | Called with the clamped value |
|
|
81
|
+
|
|
82
|
+
## Accessibility
|
|
83
|
+
|
|
84
|
+
- Decrease and Increase buttons have `accessibilityLabel` and `accessibilityRole="button"`
|
|
85
|
+
- Stepper buttons are automatically disabled at min/max boundaries
|
|
86
|
+
- `accessibilityValue` on the input provides `min`, `max`, `now`, and `text`
|
|
87
|
+
- `disabled` communicates the disabled state via `accessibilityState`
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { ThemeProvider } from '../../../../native/ThemeContext';
|
|
3
|
+
import { NumberInput } from './NumberInput';
|
|
4
|
+
|
|
5
|
+
function renderWithTheme(ui: React.ReactElement) {
|
|
6
|
+
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('NumberInput (native)', () => {
|
|
10
|
+
it('renders the label', () => {
|
|
11
|
+
renderWithTheme(<NumberInput label="Quantity" name="qty" />);
|
|
12
|
+
expect(screen.getByText('Quantity')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('renders the current value in the input', () => {
|
|
16
|
+
renderWithTheme(<NumberInput label="Quantity" name="qty" value={5} />);
|
|
17
|
+
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('renders decrease and increase buttons', () => {
|
|
21
|
+
renderWithTheme(<NumberInput label="Quantity" name="qty" value={5} />);
|
|
22
|
+
expect(screen.getByRole('button', { name: 'Decrease' })).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByRole('button', { name: 'Increase' })).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('disables decrease button at min boundary', () => {
|
|
27
|
+
renderWithTheme(
|
|
28
|
+
<NumberInput label="Quantity" name="qty" value={0} min={0} max={10} />,
|
|
29
|
+
);
|
|
30
|
+
expect(screen.getByRole('button', { name: 'Decrease' })).toBeDisabled();
|
|
31
|
+
expect(screen.getByRole('button', { name: 'Increase' })).not.toBeDisabled();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('disables increase button at max boundary', () => {
|
|
35
|
+
renderWithTheme(
|
|
36
|
+
<NumberInput label="Quantity" name="qty" value={10} min={0} max={10} />,
|
|
37
|
+
);
|
|
38
|
+
expect(screen.getByRole('button', { name: 'Increase' })).toBeDisabled();
|
|
39
|
+
expect(screen.getByRole('button', { name: 'Decrease' })).not.toBeDisabled();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('shows error message', () => {
|
|
43
|
+
renderWithTheme(
|
|
44
|
+
<NumberInput label="Quantity" name="qty" error="Minimum is 1." />,
|
|
45
|
+
);
|
|
46
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Minimum is 1.');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('shows hint message', () => {
|
|
50
|
+
renderWithTheme(
|
|
51
|
+
<NumberInput label="Guests" name="guests" hint="Including yourself." />,
|
|
52
|
+
);
|
|
53
|
+
expect(screen.getByText('Including yourself.')).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('disables all controls when disabled', () => {
|
|
57
|
+
renderWithTheme(
|
|
58
|
+
<NumberInput label="Quantity" name="qty" value={5} disabled />,
|
|
59
|
+
);
|
|
60
|
+
expect(screen.getByRole('button', { name: 'Decrease' })).toBeDisabled();
|
|
61
|
+
expect(screen.getByRole('button', { name: 'Increase' })).toBeDisabled();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { ThemeProvider } from '../../../../native/ThemeContext';
|
|
4
|
+
import { NumberInput } from './NumberInput';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
tags: ['!stable', 'alpha'],
|
|
8
|
+
title: 'React Native/Form/NumberInput',
|
|
9
|
+
component: NumberInput,
|
|
10
|
+
decorators: [(Story) => <ThemeProvider colorScheme="light"><Story /></ThemeProvider>],
|
|
11
|
+
parameters: { layout: 'padded' },
|
|
12
|
+
} satisfies Meta<typeof NumberInput>;
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
export const Default: Story = {
|
|
18
|
+
args: { label: 'Quantity', name: 'qty', value: 1 },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const WithMinMax: Story = {
|
|
22
|
+
args: { label: 'Quantity', name: 'qty', value: 1, min: 0, max: 10 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const WithStep: Story = {
|
|
26
|
+
args: { label: 'Amount', name: 'amount', value: 0, step: 5, min: 0, max: 100 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const AtMinimum: Story = {
|
|
30
|
+
args: { label: 'Quantity', name: 'qty', value: 0, min: 0, max: 10 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const AtMaximum: Story = {
|
|
34
|
+
args: { label: 'Quantity', name: 'qty', value: 10, min: 0, max: 10 },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const WithError: Story = {
|
|
38
|
+
args: { label: 'Quantity', name: 'qty', value: 0, error: 'Minimum quantity is 1.' },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const WithHint: Story = {
|
|
42
|
+
args: { label: 'Guests', name: 'guests', value: 2, hint: 'Including yourself.' },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const Disabled: Story = {
|
|
46
|
+
args: { label: 'Quantity', name: 'qty', value: 3, disabled: true },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const Interactive: Story = {
|
|
50
|
+
render: () => {
|
|
51
|
+
const [value, setValue] = useState(1);
|
|
52
|
+
return (
|
|
53
|
+
<NumberInput
|
|
54
|
+
label="Guests"
|
|
55
|
+
name="guests"
|
|
56
|
+
value={value}
|
|
57
|
+
min={1}
|
|
58
|
+
max={20}
|
|
59
|
+
onChange={setValue}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
StyleSheet,
|
|
4
|
+
View,
|
|
5
|
+
TextInput,
|
|
6
|
+
Text,
|
|
7
|
+
Pressable,
|
|
8
|
+
StyleProp,
|
|
9
|
+
ViewStyle,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
import { spacing, font, border } from '../../../../native/tokens';
|
|
12
|
+
import { useTheme } from '../../../../native/ThemeContext';
|
|
13
|
+
import { Label } from '../atoms/Label';
|
|
14
|
+
import { Message } from '../atoms/Message';
|
|
15
|
+
|
|
16
|
+
interface NumberInputProps {
|
|
17
|
+
label: string;
|
|
18
|
+
name: string;
|
|
19
|
+
value?: number;
|
|
20
|
+
defaultValue?: number;
|
|
21
|
+
min?: number;
|
|
22
|
+
max?: number;
|
|
23
|
+
step?: number;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
hint?: string;
|
|
27
|
+
onChange?: (value: number) => void;
|
|
28
|
+
style?: StyleProp<ViewStyle>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const STEPPER_SIZE = 44;
|
|
32
|
+
|
|
33
|
+
const styles = StyleSheet.create({
|
|
34
|
+
container: {
|
|
35
|
+
gap: spacing.xxs,
|
|
36
|
+
},
|
|
37
|
+
inputRow: {
|
|
38
|
+
flexDirection: 'row',
|
|
39
|
+
alignItems: 'center',
|
|
40
|
+
},
|
|
41
|
+
stepper: {
|
|
42
|
+
width: STEPPER_SIZE,
|
|
43
|
+
height: STEPPER_SIZE,
|
|
44
|
+
alignItems: 'center',
|
|
45
|
+
justifyContent: 'center',
|
|
46
|
+
borderWidth: 1,
|
|
47
|
+
},
|
|
48
|
+
stepperStart: {
|
|
49
|
+
borderTopStartRadius: border.radius.full,
|
|
50
|
+
borderBottomStartRadius: border.radius.full,
|
|
51
|
+
borderEndWidth: 0,
|
|
52
|
+
},
|
|
53
|
+
stepperEnd: {
|
|
54
|
+
borderTopEndRadius: border.radius.full,
|
|
55
|
+
borderBottomEndRadius: border.radius.full,
|
|
56
|
+
borderStartWidth: 0,
|
|
57
|
+
},
|
|
58
|
+
stepperText: {
|
|
59
|
+
fontSize: 20,
|
|
60
|
+
fontWeight: font.weight.semibold,
|
|
61
|
+
lineHeight: 24,
|
|
62
|
+
},
|
|
63
|
+
input: {
|
|
64
|
+
flex: 1,
|
|
65
|
+
fontFamily: font.family.body,
|
|
66
|
+
fontSize: font.size.body,
|
|
67
|
+
// No lineHeight on single-line TextInput: iOS clips descenders/ascenders when
|
|
68
|
+
// lineHeight is set explicitly. minHeight: STEPPER_SIZE (44) covers the touch target.
|
|
69
|
+
textAlign: 'center',
|
|
70
|
+
paddingHorizontal: spacing.xs,
|
|
71
|
+
paddingVertical: spacing.xs,
|
|
72
|
+
borderTopWidth: 1,
|
|
73
|
+
borderBottomWidth: 1,
|
|
74
|
+
minHeight: STEPPER_SIZE,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export function NumberInput({
|
|
79
|
+
label,
|
|
80
|
+
name: _name, // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
81
|
+
value,
|
|
82
|
+
defaultValue,
|
|
83
|
+
min,
|
|
84
|
+
max,
|
|
85
|
+
step = 1,
|
|
86
|
+
disabled = false,
|
|
87
|
+
error,
|
|
88
|
+
hint,
|
|
89
|
+
onChange,
|
|
90
|
+
style,
|
|
91
|
+
}: Readonly<NumberInputProps>) {
|
|
92
|
+
const { colors } = useTheme();
|
|
93
|
+
const isControlled = value !== undefined;
|
|
94
|
+
const [internalValue, setInternalValue] = useState(defaultValue ?? 0);
|
|
95
|
+
|
|
96
|
+
const currentValue = isControlled ? value : internalValue;
|
|
97
|
+
|
|
98
|
+
const clamp = (v: number): number => {
|
|
99
|
+
const withMin = min !== undefined ? Math.max(min, v) : v;
|
|
100
|
+
return max !== undefined ? Math.min(max, withMin) : withMin;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const update = (next: number) => {
|
|
104
|
+
const clamped = clamp(next);
|
|
105
|
+
if (!isControlled) setInternalValue(clamped);
|
|
106
|
+
onChange?.(clamped);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const canDecrease = min === undefined || currentValue > min;
|
|
110
|
+
const canIncrease = max === undefined || currentValue < max;
|
|
111
|
+
|
|
112
|
+
const borderColor = error ? colors.colorError : colors.colorOnBg;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<View style={[styles.container, style]}>
|
|
116
|
+
<Label label={label} />
|
|
117
|
+
<View style={[styles.inputRow, disabled && { opacity: 0.4 }]}>
|
|
118
|
+
<Pressable
|
|
119
|
+
accessibilityRole="button"
|
|
120
|
+
accessibilityLabel="Decrease"
|
|
121
|
+
accessibilityState={{ disabled: disabled || !canDecrease }}
|
|
122
|
+
disabled={disabled || !canDecrease}
|
|
123
|
+
onPress={() => update(currentValue - step)}
|
|
124
|
+
style={({ pressed }) => [
|
|
125
|
+
styles.stepper,
|
|
126
|
+
styles.stepperStart,
|
|
127
|
+
{
|
|
128
|
+
borderColor,
|
|
129
|
+
backgroundColor: colors.colorBgSubtle,
|
|
130
|
+
},
|
|
131
|
+
pressed && { opacity: 0.7 },
|
|
132
|
+
(disabled || !canDecrease) && { opacity: 0.4 },
|
|
133
|
+
]}
|
|
134
|
+
>
|
|
135
|
+
<Text
|
|
136
|
+
style={[styles.stepperText, { color: colors.colorOnBg }]}
|
|
137
|
+
aria-hidden
|
|
138
|
+
>
|
|
139
|
+
{'\u2212'}
|
|
140
|
+
</Text>
|
|
141
|
+
</Pressable>
|
|
142
|
+
|
|
143
|
+
<TextInput
|
|
144
|
+
accessibilityLabel={label}
|
|
145
|
+
accessibilityState={{ disabled }}
|
|
146
|
+
accessibilityValue={{
|
|
147
|
+
min,
|
|
148
|
+
max,
|
|
149
|
+
now: currentValue,
|
|
150
|
+
text: String(currentValue),
|
|
151
|
+
}}
|
|
152
|
+
keyboardType="numeric"
|
|
153
|
+
value={String(currentValue)}
|
|
154
|
+
editable={!disabled}
|
|
155
|
+
onChangeText={(text) => {
|
|
156
|
+
const parsed = parseFloat(text);
|
|
157
|
+
const safe = isNaN(parsed) ? 0 : parsed;
|
|
158
|
+
update(safe);
|
|
159
|
+
}}
|
|
160
|
+
style={[
|
|
161
|
+
styles.input,
|
|
162
|
+
{
|
|
163
|
+
borderColor,
|
|
164
|
+
color: colors.colorOnBg,
|
|
165
|
+
backgroundColor: colors.colorBg,
|
|
166
|
+
},
|
|
167
|
+
]}
|
|
168
|
+
/>
|
|
169
|
+
|
|
170
|
+
<Pressable
|
|
171
|
+
accessibilityRole="button"
|
|
172
|
+
accessibilityLabel="Increase"
|
|
173
|
+
accessibilityState={{ disabled: disabled || !canIncrease }}
|
|
174
|
+
disabled={disabled || !canIncrease}
|
|
175
|
+
onPress={() => update(currentValue + step)}
|
|
176
|
+
style={({ pressed }) => [
|
|
177
|
+
styles.stepper,
|
|
178
|
+
styles.stepperEnd,
|
|
179
|
+
{
|
|
180
|
+
borderColor,
|
|
181
|
+
backgroundColor: colors.colorBgSubtle,
|
|
182
|
+
},
|
|
183
|
+
pressed && { opacity: 0.7 },
|
|
184
|
+
(disabled || !canIncrease) && { opacity: 0.4 },
|
|
185
|
+
]}
|
|
186
|
+
>
|
|
187
|
+
<Text
|
|
188
|
+
style={[styles.stepperText, { color: colors.colorOnBg }]}
|
|
189
|
+
aria-hidden
|
|
190
|
+
>
|
|
191
|
+
+
|
|
192
|
+
</Text>
|
|
193
|
+
</Pressable>
|
|
194
|
+
</View>
|
|
195
|
+
<Message type="error" message={error} />
|
|
196
|
+
<Message type="hint" message={hint} />
|
|
197
|
+
</View>
|
|
198
|
+
);
|
|
199
|
+
}
|