@butternutbox/pawprint-native 0.0.1 → 0.1.0
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/.turbo/turbo-build.log +15 -15
- package/CHANGELOG.md +16 -0
- package/COMPONENT_GUIDELINES.md +111 -4
- package/dist/index.cjs +12370 -1455
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1110 -11
- package/dist/index.d.ts +1110 -11
- package/dist/index.js +12324 -1455
- package/dist/index.js.map +1 -1
- package/package.json +28 -9
- package/src/__mocks__/asset-stub.ts +1 -0
- package/src/__mocks__/emotion-native.tsx +18 -0
- package/src/__mocks__/react-native-gesture-handler.tsx +41 -0
- package/src/__mocks__/react-native-reanimated.tsx +79 -0
- package/src/__mocks__/react-native-safe-area-context.tsx +6 -0
- package/src/__mocks__/react-native-svg.tsx +27 -0
- package/src/__mocks__/react-native-worklets.tsx +11 -0
- package/src/__mocks__/react-native.tsx +338 -0
- package/src/__mocks__/rn-primitives/avatar.tsx +24 -0
- package/src/__mocks__/rn-primitives/checkbox.tsx +19 -0
- package/src/__mocks__/rn-primitives/select.tsx +116 -0
- package/src/__mocks__/rn-primitives/slider.tsx +40 -0
- package/src/__mocks__/rn-primitives/slot.tsx +30 -0
- package/src/__mocks__/rn-primitives/switch.tsx +24 -0
- package/src/__mocks__/rn-primitives/toggle.tsx +16 -0
- package/src/components/atoms/Avatar/Avatar.stories.tsx +57 -49
- package/src/components/atoms/Avatar/Avatar.test.tsx +269 -0
- package/src/components/atoms/Avatar/Avatar.tsx +68 -22
- package/src/components/atoms/Avatar/index.ts +1 -6
- package/src/components/atoms/Badge/Badge.stories.tsx +5 -29
- package/src/components/atoms/Badge/Badge.test.tsx +90 -0
- package/src/components/atoms/Button/Button.test.tsx +123 -0
- package/src/components/atoms/Button/Button.tsx +1 -1
- package/src/components/atoms/CarouselControls/CarouselControls.stories.tsx +217 -0
- package/src/components/atoms/CarouselControls/CarouselControls.tsx +127 -0
- package/src/components/atoms/CarouselControls/index.ts +2 -0
- package/src/components/atoms/Hint/Hint.test.tsx +36 -0
- package/src/components/atoms/Icon/Icon.test.tsx +98 -0
- package/src/components/atoms/Icon/Icon.tsx +5 -1
- package/src/components/atoms/IconButton/IconButton.test.tsx +101 -0
- package/src/components/atoms/Illustration/Illustration.test.tsx +55 -0
- package/src/components/atoms/Input/Input.stories.tsx +129 -86
- package/src/components/atoms/Input/Input.test.tsx +306 -0
- package/src/components/atoms/Input/Input.tsx +9 -1
- package/src/components/atoms/Input/InputField.tsx +226 -74
- package/src/components/atoms/Link/Link.test.tsx +89 -0
- package/src/components/atoms/Logo/Logo.registry.ts +30 -5
- package/src/components/atoms/Logo/Logo.stories.tsx +108 -0
- package/src/components/atoms/Logo/Logo.test.tsx +56 -0
- package/src/components/atoms/Logo/assets/BCorp.tsx +113 -0
- package/src/components/atoms/Logo/assets/ButternutFavicon.tsx +33 -0
- package/src/components/atoms/Logo/assets/ButternutPrimary.tsx +294 -0
- package/src/components/atoms/Logo/assets/ButternutTabbedBottom.tsx +294 -0
- package/src/components/atoms/Logo/assets/ButternutTabbedTop.tsx +294 -0
- package/src/components/atoms/Logo/assets/ButternutWordmark.tsx +274 -0
- package/src/components/atoms/Logo/assets/PsiBufetFavicon.tsx +45 -0
- package/src/components/atoms/Logo/assets/PsiBufetPrimary.tsx +218 -0
- package/src/components/atoms/Logo/assets/PsiBufetTabbedBottom.tsx +218 -0
- package/src/components/atoms/Logo/assets/PsiBufetTabbedTop.tsx +218 -0
- package/src/components/atoms/Logo/assets/PsiBufetWordmark.tsx +195 -0
- package/src/components/atoms/Logo/assets/index.ts +11 -0
- package/src/components/atoms/NumberInput/NumberInput.stories.tsx +183 -0
- package/src/components/atoms/NumberInput/NumberInput.test.tsx +261 -0
- package/src/components/atoms/NumberInput/NumberInput.tsx +129 -0
- package/src/components/atoms/NumberInput/NumberInputField.tsx +77 -0
- package/src/components/atoms/NumberInput/index.ts +4 -0
- package/src/components/atoms/Spinner/Spinner.test.tsx +46 -0
- package/src/components/atoms/Spinner/Spinner.tsx +14 -5
- package/src/components/atoms/Switch/Switch.test.tsx +92 -0
- package/src/components/atoms/Switch/Switch.tsx +16 -13
- package/src/components/atoms/Tag/Tag.test.tsx +70 -0
- package/src/components/atoms/TextArea/TextArea.stories.tsx +303 -0
- package/src/components/atoms/TextArea/TextArea.test.tsx +416 -0
- package/src/components/atoms/TextArea/TextArea.tsx +171 -0
- package/src/components/atoms/TextArea/TextAreaField.tsx +304 -0
- package/src/components/atoms/TextArea/TextAreaLabel.tsx +103 -0
- package/src/components/atoms/TextArea/index.ts +6 -0
- package/src/components/atoms/Typography/Typography.test.tsx +94 -0
- package/src/components/atoms/index.ts +3 -0
- package/src/components/molecules/Accordion/Accordion.stories.tsx +177 -0
- package/src/components/molecules/Accordion/Accordion.test.tsx +185 -0
- package/src/components/molecules/Accordion/Accordion.tsx +284 -0
- package/src/components/molecules/Accordion/index.ts +6 -0
- package/src/components/molecules/Animated/Animated.stories.tsx +254 -0
- package/src/components/molecules/Animated/Animated.tsx +283 -0
- package/src/components/molecules/Animated/index.ts +10 -0
- package/src/components/molecules/ButtonDock/ButtonDock.test.tsx +83 -0
- package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +8 -14
- package/src/components/molecules/ButtonGroup/ButtonGroup.test.tsx +73 -0
- package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +25 -3
- package/src/components/molecules/Checkbox/Checkbox.stories.tsx +72 -0
- package/src/components/molecules/Checkbox/Checkbox.test.tsx +117 -0
- package/src/components/molecules/Checkbox/Checkbox.tsx +101 -95
- package/src/components/molecules/CopyField/CopyField.stories.tsx +313 -0
- package/src/components/molecules/CopyField/CopyField.test.tsx +431 -0
- package/src/components/molecules/CopyField/CopyField.tsx +156 -0
- package/src/components/molecules/CopyField/CopyFieldInput.tsx +127 -0
- package/src/components/molecules/CopyField/hooks/index.ts +1 -0
- package/src/components/molecules/CopyField/hooks/useCopyField.ts +25 -0
- package/src/components/molecules/CopyField/index.ts +4 -0
- package/src/components/molecules/DatePicker/DatePicker.stories.tsx +298 -0
- package/src/components/molecules/DatePicker/DatePicker.test.tsx +201 -0
- package/src/components/molecules/DatePicker/DatePicker.tsx +590 -0
- package/src/components/molecules/DatePicker/index.ts +2 -0
- package/src/components/molecules/Drawer/Drawer.stories.tsx +285 -0
- package/src/components/molecules/Drawer/Drawer.test.tsx +180 -0
- package/src/components/molecules/Drawer/Drawer.tsx +187 -0
- package/src/components/molecules/Drawer/DrawerBody.tsx +80 -0
- package/src/components/molecules/Drawer/DrawerClose.tsx +76 -0
- package/src/components/molecules/Drawer/DrawerContent.tsx +339 -0
- package/src/components/molecules/Drawer/DrawerContext.ts +19 -0
- package/src/components/molecules/Drawer/DrawerDescription.tsx +31 -0
- package/src/components/molecules/Drawer/DrawerDragContext.ts +11 -0
- package/src/components/molecules/Drawer/DrawerFooter.tsx +49 -0
- package/src/components/molecules/Drawer/DrawerFooterContext.ts +6 -0
- package/src/components/molecules/Drawer/DrawerGrabber.tsx +62 -0
- package/src/components/molecules/Drawer/DrawerHeader.tsx +244 -0
- package/src/components/molecules/Drawer/DrawerHeaderContext.ts +13 -0
- package/src/components/molecules/Drawer/DrawerOverlay.tsx +53 -0
- package/src/components/molecules/Drawer/DrawerTitle.tsx +32 -0
- package/src/components/molecules/Drawer/index.ts +12 -0
- package/src/components/molecules/FilterTab/FilterTab.stories.tsx +210 -0
- package/src/components/molecules/FilterTab/FilterTab.tsx +310 -0
- package/src/components/molecules/FilterTab/index.ts +2 -0
- package/src/components/molecules/MessageCard/MessageCard.stories.tsx +169 -0
- package/src/components/molecules/MessageCard/MessageCard.tsx +362 -0
- package/src/components/molecules/MessageCard/index.ts +10 -0
- package/src/components/molecules/Notification/Notification.stories.tsx +219 -0
- package/src/components/molecules/Notification/Notification.tsx +426 -0
- package/src/components/molecules/Notification/index.ts +2 -0
- package/src/components/molecules/NumberField/NumberField.stories.tsx +231 -0
- package/src/components/molecules/NumberField/NumberField.tsx +186 -0
- package/src/components/molecules/NumberField/NumberFieldInput.tsx +287 -0
- package/src/components/molecules/NumberField/index.ts +2 -0
- package/src/components/molecules/PasswordField/PasswordField.stories.tsx +362 -0
- package/src/components/molecules/PasswordField/PasswordField.test.tsx +369 -0
- package/src/components/molecules/PasswordField/PasswordField.tsx +194 -0
- package/src/components/molecules/PasswordField/PasswordFieldError.tsx +52 -0
- package/src/components/molecules/PasswordField/PasswordFieldInput.tsx +73 -0
- package/src/components/molecules/PasswordField/PasswordFieldRequirements.tsx +92 -0
- package/src/components/molecules/PasswordField/hooks/index.ts +2 -0
- package/src/components/molecules/PasswordField/hooks/usePasswordField.ts +113 -0
- package/src/components/molecules/PasswordField/index.ts +10 -0
- package/src/components/molecules/PictureSelector/PictureSelector.stories.tsx +243 -0
- package/src/components/molecules/PictureSelector/PictureSelector.tsx +313 -0
- package/src/components/molecules/PictureSelector/index.ts +5 -0
- package/src/components/molecules/Progress/Progress.stories.tsx +145 -0
- package/src/components/molecules/Progress/Progress.tsx +184 -0
- package/src/components/molecules/Progress/index.ts +2 -0
- package/src/components/molecules/Radio/Radio.test.tsx +104 -0
- package/src/components/molecules/Radio/Radio.tsx +1 -2
- package/src/components/molecules/SearchField/SearchField.stories.tsx +242 -0
- package/src/components/molecules/SearchField/SearchField.test.tsx +318 -0
- package/src/components/molecules/SearchField/SearchField.tsx +143 -0
- package/src/components/molecules/SearchField/SearchFieldInput.tsx +63 -0
- package/src/components/molecules/SearchField/hooks/index.ts +1 -0
- package/src/components/molecules/SearchField/hooks/useSearchField.ts +56 -0
- package/src/components/molecules/SearchField/index.ts +4 -0
- package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +31 -8
- package/src/components/molecules/SegmentedControl/SegmentedControl.test.tsx +141 -0
- package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +237 -23
- package/src/components/molecules/SelectField/SelectField.stories.tsx +320 -0
- package/src/components/molecules/SelectField/SelectField.test.tsx +254 -0
- package/src/components/molecules/SelectField/SelectField.tsx +236 -0
- package/src/components/molecules/SelectField/SelectFieldContent.tsx +85 -0
- package/src/components/molecules/SelectField/SelectFieldItem.tsx +133 -0
- package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +170 -0
- package/src/components/molecules/SelectField/SelectFieldValue.tsx +31 -0
- package/src/components/molecules/SelectField/hooks/index.ts +2 -0
- package/src/components/molecules/SelectField/hooks/useSelectField.ts +84 -0
- package/src/components/molecules/SelectField/index.ts +10 -0
- package/src/components/molecules/Slider/Slider.test.tsx +102 -0
- package/src/components/molecules/Slider/Slider.tsx +293 -180
- package/src/components/molecules/Tooltip/Tooltip.stories.tsx +168 -0
- package/src/components/molecules/Tooltip/Tooltip.tsx +326 -0
- package/src/components/molecules/Tooltip/index.ts +2 -0
- package/src/components/molecules/index.ts +15 -0
- package/src/test-utils.tsx +20 -0
- package/tsconfig.json +1 -1
- package/tsup.config.ts +16 -2
- package/vitest.config.ts +114 -0
- package/vitest.setup.ts +16 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { View, StyleSheet } from "react-native"
|
|
3
|
+
import { NumberInput } from "./NumberInput"
|
|
4
|
+
import type { NumberInputProps } from "./NumberInput"
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
title: "Atoms/NumberInput",
|
|
8
|
+
component: NumberInput,
|
|
9
|
+
argTypes: {
|
|
10
|
+
label: {
|
|
11
|
+
control: { type: "text" },
|
|
12
|
+
description: "Label text"
|
|
13
|
+
},
|
|
14
|
+
description: {
|
|
15
|
+
control: { type: "text" },
|
|
16
|
+
description: "Help text below input"
|
|
17
|
+
},
|
|
18
|
+
error: {
|
|
19
|
+
control: { type: "text" },
|
|
20
|
+
description: "Error message"
|
|
21
|
+
},
|
|
22
|
+
optionalText: {
|
|
23
|
+
control: { type: "text" },
|
|
24
|
+
description: "Optional text to display next to label"
|
|
25
|
+
},
|
|
26
|
+
state: {
|
|
27
|
+
control: { type: "select" },
|
|
28
|
+
options: ["default", "error", "success"],
|
|
29
|
+
description: "Visual state of the input"
|
|
30
|
+
},
|
|
31
|
+
fieldText: {
|
|
32
|
+
control: { type: "text" },
|
|
33
|
+
description: "Unit label shown to the right of the input (e.g. kg, lbs)"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const Default = (args: NumberInputProps) => (
|
|
39
|
+
<View style={styles.container}>
|
|
40
|
+
<NumberInput {...args} />
|
|
41
|
+
</View>
|
|
42
|
+
)
|
|
43
|
+
Default.args = {
|
|
44
|
+
label: "Label",
|
|
45
|
+
placeholder: "0",
|
|
46
|
+
description: "Help text",
|
|
47
|
+
fieldText: "kg"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const States = () => (
|
|
51
|
+
<View style={styles.column}>
|
|
52
|
+
<NumberInput
|
|
53
|
+
label="Default State"
|
|
54
|
+
placeholder="0"
|
|
55
|
+
description="Normal input state"
|
|
56
|
+
/>
|
|
57
|
+
<NumberInput
|
|
58
|
+
label="Error State"
|
|
59
|
+
placeholder="0"
|
|
60
|
+
state="error"
|
|
61
|
+
description="Manually set to error state"
|
|
62
|
+
/>
|
|
63
|
+
<NumberInput
|
|
64
|
+
label="Error with Custom Message"
|
|
65
|
+
placeholder="0"
|
|
66
|
+
state="error"
|
|
67
|
+
description="Manually set to error state"
|
|
68
|
+
error="Custom error message"
|
|
69
|
+
/>
|
|
70
|
+
<NumberInput
|
|
71
|
+
label="Success State"
|
|
72
|
+
placeholder="0"
|
|
73
|
+
state="success"
|
|
74
|
+
description="Manually set to success state"
|
|
75
|
+
/>
|
|
76
|
+
<NumberInput
|
|
77
|
+
label="Disabled"
|
|
78
|
+
placeholder="0"
|
|
79
|
+
description="Help text"
|
|
80
|
+
editable={false}
|
|
81
|
+
/>
|
|
82
|
+
</View>
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
export const WithFieldText = () => (
|
|
86
|
+
<View style={styles.column}>
|
|
87
|
+
<NumberInput
|
|
88
|
+
label="Weight"
|
|
89
|
+
placeholder="0"
|
|
90
|
+
description="Enter weight in kilograms"
|
|
91
|
+
fieldText="kg"
|
|
92
|
+
/>
|
|
93
|
+
<NumberInput
|
|
94
|
+
label="Distance"
|
|
95
|
+
placeholder="0"
|
|
96
|
+
description="Enter distance"
|
|
97
|
+
fieldText="km"
|
|
98
|
+
/>
|
|
99
|
+
<NumberInput
|
|
100
|
+
label="Weight"
|
|
101
|
+
placeholder="0"
|
|
102
|
+
state="error"
|
|
103
|
+
error="Invalid weight"
|
|
104
|
+
fieldText="kg"
|
|
105
|
+
/>
|
|
106
|
+
</View>
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
export const WithOptionalText = () => (
|
|
110
|
+
<View style={styles.column}>
|
|
111
|
+
<NumberInput
|
|
112
|
+
label="Weight"
|
|
113
|
+
optionalText="(optional)"
|
|
114
|
+
placeholder="0"
|
|
115
|
+
description="Enter weight in kilograms"
|
|
116
|
+
/>
|
|
117
|
+
</View>
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
export const CustomStateValidationWithSuccess = () => {
|
|
121
|
+
const [quantity, setQuantity] = React.useState("")
|
|
122
|
+
const [quantityState, setQuantityState] =
|
|
123
|
+
React.useState<NumberInputProps["state"]>("default")
|
|
124
|
+
|
|
125
|
+
const handleChange = (value: string) => {
|
|
126
|
+
setQuantity(value)
|
|
127
|
+
|
|
128
|
+
if (!value) {
|
|
129
|
+
setQuantityState("default")
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const num = parseFloat(value)
|
|
134
|
+
const isValidNumber = !isNaN(num)
|
|
135
|
+
const isInRange = num >= 1 && num <= 100
|
|
136
|
+
const isWholeNumber = num % 1 === 0
|
|
137
|
+
|
|
138
|
+
setQuantityState(
|
|
139
|
+
isValidNumber && isInRange && isWholeNumber ? "success" : "error"
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<View style={styles.column}>
|
|
145
|
+
<NumberInput
|
|
146
|
+
label="Quantity"
|
|
147
|
+
placeholder="0"
|
|
148
|
+
value={quantity}
|
|
149
|
+
onValueChange={handleChange}
|
|
150
|
+
state={quantityState}
|
|
151
|
+
description="1-100 items, whole numbers only"
|
|
152
|
+
error="Must be a whole number between 1 and 100"
|
|
153
|
+
/>
|
|
154
|
+
</View>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const Controlled = () => {
|
|
159
|
+
const [value, setValue] = React.useState("")
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<View style={styles.column}>
|
|
163
|
+
<NumberInput
|
|
164
|
+
label="Controlled Number Input"
|
|
165
|
+
value={value}
|
|
166
|
+
onValueChange={setValue}
|
|
167
|
+
placeholder="0"
|
|
168
|
+
description={`Current value: ${value || "empty"}`}
|
|
169
|
+
/>
|
|
170
|
+
</View>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const styles = StyleSheet.create({
|
|
175
|
+
container: {
|
|
176
|
+
width: 320
|
|
177
|
+
},
|
|
178
|
+
column: {
|
|
179
|
+
flexDirection: "column",
|
|
180
|
+
gap: 24,
|
|
181
|
+
width: 320
|
|
182
|
+
}
|
|
183
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { TextInput } from "react-native"
|
|
3
|
+
import { screen } from "@testing-library/react"
|
|
4
|
+
import userEvent from "@testing-library/user-event"
|
|
5
|
+
import { describe, it, expect, vi } from "vitest"
|
|
6
|
+
import { renderWithTheme } from "../../../test-utils"
|
|
7
|
+
import { NumberInput } from "./NumberInput"
|
|
8
|
+
|
|
9
|
+
describe("NumberInput", () => {
|
|
10
|
+
describe("when using simple props API", () => {
|
|
11
|
+
it("renders label when provided", () => {
|
|
12
|
+
renderWithTheme(<NumberInput label="Weight" placeholder="0" />)
|
|
13
|
+
expect(screen.getByText("Weight")).toBeInTheDocument()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("renders description when provided", () => {
|
|
17
|
+
renderWithTheme(
|
|
18
|
+
<NumberInput
|
|
19
|
+
label="Weight"
|
|
20
|
+
placeholder="0"
|
|
21
|
+
description="Enter weight in kilograms"
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
expect(screen.getByText("Enter weight in kilograms")).toBeInTheDocument()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("renders optional text when provided", () => {
|
|
28
|
+
renderWithTheme(
|
|
29
|
+
<NumberInput label="Weight" placeholder="0" optionalText="(optional)" />
|
|
30
|
+
)
|
|
31
|
+
expect(screen.getByText("(optional)")).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("renders input with placeholder", () => {
|
|
35
|
+
renderWithTheme(<NumberInput label="Weight" placeholder="0" />)
|
|
36
|
+
expect(screen.getByPlaceholderText("0")).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("renders with default value", () => {
|
|
40
|
+
renderWithTheme(
|
|
41
|
+
<NumberInput label="Weight" placeholder="0" defaultValue="10" />
|
|
42
|
+
)
|
|
43
|
+
const input = screen.getByPlaceholderText("0") as HTMLInputElement
|
|
44
|
+
expect(input.value).toBe("10")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("renders fieldText when provided", () => {
|
|
48
|
+
renderWithTheme(
|
|
49
|
+
<NumberInput label="Weight" placeholder="0" fieldText="kg" />
|
|
50
|
+
)
|
|
51
|
+
expect(screen.getByText("kg")).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("does not render fieldText when not provided", () => {
|
|
55
|
+
renderWithTheme(<NumberInput label="Weight" placeholder="0" />)
|
|
56
|
+
expect(screen.queryByText("kg")).not.toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe("user interaction", () => {
|
|
61
|
+
it("accepts user input", async () => {
|
|
62
|
+
const user = userEvent.setup()
|
|
63
|
+
renderWithTheme(<NumberInput label="Weight" placeholder="0" />)
|
|
64
|
+
|
|
65
|
+
const input = screen.getByPlaceholderText("0")
|
|
66
|
+
await user.type(input, "42")
|
|
67
|
+
expect(input).toHaveValue("42")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("accepts decimal values", async () => {
|
|
71
|
+
const user = userEvent.setup()
|
|
72
|
+
renderWithTheme(<NumberInput label="Weight" placeholder="0" />)
|
|
73
|
+
|
|
74
|
+
const input = screen.getByPlaceholderText("0")
|
|
75
|
+
await user.type(input, "42.5")
|
|
76
|
+
expect(input).toHaveValue("42.5")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("accepts negative values", async () => {
|
|
80
|
+
const user = userEvent.setup()
|
|
81
|
+
renderWithTheme(<NumberInput label="Temperature" placeholder="0" />)
|
|
82
|
+
|
|
83
|
+
const input = screen.getByPlaceholderText("0")
|
|
84
|
+
await user.type(input, "-10")
|
|
85
|
+
expect(input).toHaveValue("-10")
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("when using controlled mode", () => {
|
|
90
|
+
it("updates value when parent updates", async () => {
|
|
91
|
+
const user = userEvent.setup()
|
|
92
|
+
|
|
93
|
+
function ControlledInput() {
|
|
94
|
+
const [value, setValue] = React.useState("")
|
|
95
|
+
return (
|
|
96
|
+
<NumberInput
|
|
97
|
+
label="Weight"
|
|
98
|
+
value={value}
|
|
99
|
+
onValueChange={setValue}
|
|
100
|
+
placeholder="0"
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
renderWithTheme(<ControlledInput />)
|
|
106
|
+
|
|
107
|
+
const input = screen.getByPlaceholderText("0")
|
|
108
|
+
await user.type(input, "42")
|
|
109
|
+
expect(input).toHaveValue("42")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("calls onValueChange when value changes", async () => {
|
|
113
|
+
const user = userEvent.setup()
|
|
114
|
+
const onValueChange = vi.fn()
|
|
115
|
+
|
|
116
|
+
renderWithTheme(
|
|
117
|
+
<NumberInput
|
|
118
|
+
label="Weight"
|
|
119
|
+
value=""
|
|
120
|
+
onValueChange={onValueChange}
|
|
121
|
+
placeholder="0"
|
|
122
|
+
/>
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const input = screen.getByPlaceholderText("0")
|
|
126
|
+
await user.type(input, "5")
|
|
127
|
+
expect(onValueChange).toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe("when disabled", () => {
|
|
132
|
+
it("disables the input", () => {
|
|
133
|
+
renderWithTheme(
|
|
134
|
+
<NumberInput label="Weight" placeholder="0" editable={false} />
|
|
135
|
+
)
|
|
136
|
+
expect(screen.getByPlaceholderText("0")).toBeDisabled()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("does not accept user input when disabled", async () => {
|
|
140
|
+
const user = userEvent.setup()
|
|
141
|
+
renderWithTheme(
|
|
142
|
+
<NumberInput label="Weight" placeholder="0" editable={false} />
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const input = screen.getByPlaceholderText("0")
|
|
146
|
+
await user.type(input, "42")
|
|
147
|
+
expect(input).toHaveValue("")
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe("validation states", () => {
|
|
152
|
+
it.each(["default", "error", "success"] as const)(
|
|
153
|
+
"renders %s state without errors",
|
|
154
|
+
(state) => {
|
|
155
|
+
renderWithTheme(
|
|
156
|
+
<NumberInput label="Weight" state={state} placeholder="0" />
|
|
157
|
+
)
|
|
158
|
+
expect(screen.getByPlaceholderText("0")).toBeInTheDocument()
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
it("shows error message with error state", () => {
|
|
163
|
+
renderWithTheme(
|
|
164
|
+
<NumberInput
|
|
165
|
+
label="Weight"
|
|
166
|
+
placeholder="0"
|
|
167
|
+
state="error"
|
|
168
|
+
error="Invalid weight"
|
|
169
|
+
/>
|
|
170
|
+
)
|
|
171
|
+
expect(screen.getByText("Invalid weight")).toBeInTheDocument()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("does not render error message when error prop is provided without state='error'", () => {
|
|
175
|
+
renderWithTheme(
|
|
176
|
+
<NumberInput label="Weight" placeholder="0" error="Invalid weight" />
|
|
177
|
+
)
|
|
178
|
+
expect(screen.queryByText("Invalid weight")).not.toBeInTheDocument()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("does not show state icons (hideStateIcons is always true)", () => {
|
|
182
|
+
const { container } = renderWithTheme(
|
|
183
|
+
<NumberInput label="Weight" placeholder="0" state="error" />
|
|
184
|
+
)
|
|
185
|
+
expect(container.querySelectorAll("svg")).toHaveLength(0)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe("when using compound component API", () => {
|
|
190
|
+
it("renders all compound components", () => {
|
|
191
|
+
renderWithTheme(
|
|
192
|
+
<NumberInput.Root>
|
|
193
|
+
<NumberInput.Label>Weight</NumberInput.Label>
|
|
194
|
+
<NumberInput.Field placeholder="0" />
|
|
195
|
+
<NumberInput.Description>Help text</NumberInput.Description>
|
|
196
|
+
</NumberInput.Root>
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
expect(screen.getByText("Weight")).toBeInTheDocument()
|
|
200
|
+
expect(screen.getByPlaceholderText("0")).toBeInTheDocument()
|
|
201
|
+
expect(screen.getByText("Help text")).toBeInTheDocument()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("renders with optional text in compound mode", () => {
|
|
205
|
+
renderWithTheme(
|
|
206
|
+
<NumberInput.Root>
|
|
207
|
+
<NumberInput.Label optionalText="(optional)">
|
|
208
|
+
Weight
|
|
209
|
+
</NumberInput.Label>
|
|
210
|
+
<NumberInput.Field placeholder="0" />
|
|
211
|
+
</NumberInput.Root>
|
|
212
|
+
)
|
|
213
|
+
expect(screen.getByText("(optional)")).toBeInTheDocument()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("renders error in compound mode", () => {
|
|
217
|
+
renderWithTheme(
|
|
218
|
+
<NumberInput.Root>
|
|
219
|
+
<NumberInput.Label state="error">Weight</NumberInput.Label>
|
|
220
|
+
<NumberInput.Field placeholder="0" state="error" />
|
|
221
|
+
<NumberInput.Error>Invalid weight</NumberInput.Error>
|
|
222
|
+
</NumberInput.Root>
|
|
223
|
+
)
|
|
224
|
+
expect(screen.getByText("Invalid weight")).toBeInTheDocument()
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe("compound component API with states", () => {
|
|
229
|
+
it("renders error state in compound mode", () => {
|
|
230
|
+
renderWithTheme(
|
|
231
|
+
<NumberInput.Root>
|
|
232
|
+
<NumberInput.Label state="error">Weight</NumberInput.Label>
|
|
233
|
+
<NumberInput.Field placeholder="0" state="error" />
|
|
234
|
+
<NumberInput.Error>Invalid weight</NumberInput.Error>
|
|
235
|
+
</NumberInput.Root>
|
|
236
|
+
)
|
|
237
|
+
expect(screen.getByText("Invalid weight")).toBeInTheDocument()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("renders success state in compound mode", () => {
|
|
241
|
+
renderWithTheme(
|
|
242
|
+
<NumberInput.Root>
|
|
243
|
+
<NumberInput.Label state="success">Weight</NumberInput.Label>
|
|
244
|
+
<NumberInput.Field placeholder="0" state="success" />
|
|
245
|
+
<NumberInput.Description state="success">
|
|
246
|
+
Weight verified
|
|
247
|
+
</NumberInput.Description>
|
|
248
|
+
</NumberInput.Root>
|
|
249
|
+
)
|
|
250
|
+
expect(screen.getByText("Weight verified")).toBeInTheDocument()
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe("when using with ref", () => {
|
|
255
|
+
it("forwards ref to input element", () => {
|
|
256
|
+
const ref = React.createRef<TextInput>()
|
|
257
|
+
renderWithTheme(<NumberInput ref={ref} label="Test" />)
|
|
258
|
+
expect(ref.current).toBeTruthy()
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { View, ViewProps } from "react-native"
|
|
3
|
+
import styled from "@emotion/native"
|
|
4
|
+
import { InputLabel } from "../Input/InputLabel"
|
|
5
|
+
import { InputDescription } from "../Input/InputDescription"
|
|
6
|
+
import { InputError } from "../Input/InputError"
|
|
7
|
+
import { NumberInputField } from "./NumberInputField"
|
|
8
|
+
import { type InputFieldProps } from "../Input/InputField"
|
|
9
|
+
import { type InputProps } from "../Input/Input"
|
|
10
|
+
|
|
11
|
+
type NumberInputOwnProps = Pick<
|
|
12
|
+
InputProps,
|
|
13
|
+
"label" | "description" | "error" | "state" | "optionalText" | "onValueChange"
|
|
14
|
+
> & {
|
|
15
|
+
fieldText?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type NumberInputProps = NumberInputOwnProps &
|
|
19
|
+
Omit<InputFieldProps, keyof NumberInputOwnProps> &
|
|
20
|
+
Omit<ViewProps, keyof NumberInputOwnProps>
|
|
21
|
+
|
|
22
|
+
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
23
|
+
|
|
24
|
+
const StyledRoot = styled(View)(({ theme }) => {
|
|
25
|
+
const { spacing } = theme.tokens.components.inputs
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
gap: parseTokenValue(spacing.gap)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Number input component for collecting numeric data.
|
|
34
|
+
* Supports both a simple props API and a flexible compound component API.
|
|
35
|
+
*
|
|
36
|
+
* Features:
|
|
37
|
+
* - Label with optional text support
|
|
38
|
+
* - Helper text (description) and error messages
|
|
39
|
+
* - Centered number input sized from `numberField.size.large.height`
|
|
40
|
+
* - Controlled and uncontrolled modes
|
|
41
|
+
* - Full accessibility
|
|
42
|
+
*
|
|
43
|
+
* **Simple Props API:**
|
|
44
|
+
* @example
|
|
45
|
+
* <NumberInput
|
|
46
|
+
* label="Weight"
|
|
47
|
+
* defaultValue="0"
|
|
48
|
+
* description="Enter weight in kilograms"
|
|
49
|
+
* error="Value must be between 0 and 100"
|
|
50
|
+
* optionalText="(optional)"
|
|
51
|
+
* />
|
|
52
|
+
*
|
|
53
|
+
* **Compound Component API:**
|
|
54
|
+
* @example
|
|
55
|
+
* <NumberInput.Root>
|
|
56
|
+
* <NumberInput.Label optionalText="(optional)">Weight</NumberInput.Label>
|
|
57
|
+
* <NumberInput.Field defaultValue="0" />
|
|
58
|
+
* <NumberInput.Description>Enter weight in kilograms</NumberInput.Description>
|
|
59
|
+
* <NumberInput.Error>Invalid value</NumberInput.Error>
|
|
60
|
+
* </NumberInput.Root>
|
|
61
|
+
*
|
|
62
|
+
* @param {string} [label] - Label text (props API)
|
|
63
|
+
* @param {string} [description] - Description/help text (props API)
|
|
64
|
+
* @param {string} [error] - Error message to display (props API, does not affect visual state - use state prop for that)
|
|
65
|
+
* @param {InputState} [state] - Visual state of the input: 'default', 'error', or 'success' (props API)
|
|
66
|
+
* @param {string} [optionalText] - Optional text to display next to label (props API)
|
|
67
|
+
* @param {string} [value] - Controlled value
|
|
68
|
+
* @param {string} [defaultValue] - Default value for uncontrolled mode
|
|
69
|
+
*/
|
|
70
|
+
const NumberInputRoot = React.forwardRef<View, NumberInputProps>(
|
|
71
|
+
(
|
|
72
|
+
{
|
|
73
|
+
label,
|
|
74
|
+
description,
|
|
75
|
+
error,
|
|
76
|
+
state = "default",
|
|
77
|
+
optionalText,
|
|
78
|
+
onValueChange,
|
|
79
|
+
fieldText,
|
|
80
|
+
children,
|
|
81
|
+
...inputProps
|
|
82
|
+
},
|
|
83
|
+
ref
|
|
84
|
+
) => {
|
|
85
|
+
if (children) {
|
|
86
|
+
return <StyledRoot ref={ref}>{children}</StyledRoot>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<StyledRoot ref={ref}>
|
|
91
|
+
{label && (
|
|
92
|
+
<InputLabel optionalText={optionalText} state={state}>
|
|
93
|
+
{label}
|
|
94
|
+
</InputLabel>
|
|
95
|
+
)}
|
|
96
|
+
<NumberInputField
|
|
97
|
+
state={state}
|
|
98
|
+
onChangeText={onValueChange}
|
|
99
|
+
fieldText={fieldText}
|
|
100
|
+
{...inputProps}
|
|
101
|
+
/>
|
|
102
|
+
{description && (
|
|
103
|
+
<InputDescription state={state}>{description}</InputDescription>
|
|
104
|
+
)}
|
|
105
|
+
{error && state === "error" && <InputError>{error}</InputError>}
|
|
106
|
+
</StyledRoot>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
NumberInputRoot.displayName = "NumberInput"
|
|
112
|
+
|
|
113
|
+
type NumberInputComponent = React.ForwardRefExoticComponent<
|
|
114
|
+
NumberInputProps & React.RefAttributes<View>
|
|
115
|
+
> & {
|
|
116
|
+
Root: typeof StyledRoot
|
|
117
|
+
Label: typeof InputLabel
|
|
118
|
+
Field: typeof NumberInputField
|
|
119
|
+
Description: typeof InputDescription
|
|
120
|
+
Error: typeof InputError
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const NumberInput = Object.assign(NumberInputRoot, {
|
|
124
|
+
Root: StyledRoot,
|
|
125
|
+
Label: InputLabel,
|
|
126
|
+
Field: NumberInputField,
|
|
127
|
+
Description: InputDescription,
|
|
128
|
+
Error: InputError
|
|
129
|
+
}) as NumberInputComponent
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { View, TextInput } from "react-native"
|
|
3
|
+
import styled from "@emotion/native"
|
|
4
|
+
import { useTheme } from "@emotion/react"
|
|
5
|
+
import { Typography } from "../Typography"
|
|
6
|
+
import { InputField, type InputFieldProps } from "../Input/InputField"
|
|
7
|
+
|
|
8
|
+
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
9
|
+
|
|
10
|
+
const StyledFieldGroup = styled(View)(({ theme }) => {
|
|
11
|
+
const { spacing } = theme.tokens.components.inputs
|
|
12
|
+
return {
|
|
13
|
+
flexDirection: "row",
|
|
14
|
+
alignItems: "center",
|
|
15
|
+
gap: parseTokenValue(spacing.field.gap)
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const StyledFieldTextWrapper = styled(View)({
|
|
20
|
+
flexShrink: 0,
|
|
21
|
+
justifyContent: "center"
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
type NumberInputFieldOwnProps = {
|
|
25
|
+
fieldText?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type NumberInputFieldProps = NumberInputFieldOwnProps &
|
|
29
|
+
Omit<InputFieldProps, keyof NumberInputFieldOwnProps>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Field component for NumberInput.
|
|
33
|
+
* Renders a centered number input sized to `numberField.size.large.height`.
|
|
34
|
+
* When fieldText is provided, wraps input + text in a horizontal group.
|
|
35
|
+
*/
|
|
36
|
+
export const NumberInputField = React.forwardRef<
|
|
37
|
+
TextInput,
|
|
38
|
+
NumberInputFieldProps
|
|
39
|
+
>(({ fieldText, ...props }, ref) => {
|
|
40
|
+
const theme = useTheme()
|
|
41
|
+
const { colour, description } = theme.tokens.components.inputs
|
|
42
|
+
const fieldSize = parseTokenValue(
|
|
43
|
+
theme.tokens.components.numberField.size.large.height
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const inputElement = (
|
|
47
|
+
<InputField
|
|
48
|
+
keyboardType="numeric"
|
|
49
|
+
hideStateIcons
|
|
50
|
+
containerWidth={fieldSize}
|
|
51
|
+
containerHeight={fieldSize}
|
|
52
|
+
ref={ref}
|
|
53
|
+
style={{ textAlign: "center" }}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if (!fieldText) {
|
|
59
|
+
return inputElement
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<StyledFieldGroup>
|
|
64
|
+
{inputElement}
|
|
65
|
+
<StyledFieldTextWrapper>
|
|
66
|
+
<Typography
|
|
67
|
+
token={description.text.default}
|
|
68
|
+
color={colour.description.default}
|
|
69
|
+
>
|
|
70
|
+
{fieldText}
|
|
71
|
+
</Typography>
|
|
72
|
+
</StyledFieldTextWrapper>
|
|
73
|
+
</StyledFieldGroup>
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
NumberInputField.displayName = "NumberInput.Field"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { screen } from "@testing-library/react"
|
|
3
|
+
import { describe, it, expect } from "vitest"
|
|
4
|
+
import { renderWithTheme } from "../../../test-utils"
|
|
5
|
+
import { Spinner } from "./Spinner"
|
|
6
|
+
|
|
7
|
+
describe("Spinner", () => {
|
|
8
|
+
describe("when component is rendering", () => {
|
|
9
|
+
it("renders without crashing", () => {
|
|
10
|
+
const { container } = renderWithTheme(<Spinner />)
|
|
11
|
+
expect(container.firstChild).toBeTruthy()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("renders with progressbar role", () => {
|
|
15
|
+
renderWithTheme(<Spinner />)
|
|
16
|
+
expect(screen.getByRole("progressbar")).toBeInTheDocument()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("has accessible loading label", () => {
|
|
20
|
+
renderWithTheme(<Spinner />)
|
|
21
|
+
expect(screen.getByLabelText("Loading")).toBeInTheDocument()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe("when rendering all sizes", () => {
|
|
26
|
+
it.each(["sm", "md", "lg"] as const)("renders %s size", (size) => {
|
|
27
|
+
const { container } = renderWithTheme(<Spinner size={size} />)
|
|
28
|
+
expect(container.firstChild).toBeTruthy()
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe("when rendering all variants", () => {
|
|
33
|
+
it.each(["dark", "light"] as const)("renders %s variant", (variant) => {
|
|
34
|
+
const { container } = renderWithTheme(<Spinner variant={variant} />)
|
|
35
|
+
expect(container.firstChild).toBeTruthy()
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe("when using with ref", () => {
|
|
40
|
+
it("forwards ref", () => {
|
|
41
|
+
const ref = React.createRef<any>()
|
|
42
|
+
renderWithTheme(<Spinner ref={ref} />)
|
|
43
|
+
expect(ref.current).toBeTruthy()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
})
|