@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.
Files changed (182) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/CHANGELOG.md +16 -0
  3. package/COMPONENT_GUIDELINES.md +111 -4
  4. package/dist/index.cjs +12370 -1455
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +1110 -11
  7. package/dist/index.d.ts +1110 -11
  8. package/dist/index.js +12324 -1455
  9. package/dist/index.js.map +1 -1
  10. package/package.json +28 -9
  11. package/src/__mocks__/asset-stub.ts +1 -0
  12. package/src/__mocks__/emotion-native.tsx +18 -0
  13. package/src/__mocks__/react-native-gesture-handler.tsx +41 -0
  14. package/src/__mocks__/react-native-reanimated.tsx +79 -0
  15. package/src/__mocks__/react-native-safe-area-context.tsx +6 -0
  16. package/src/__mocks__/react-native-svg.tsx +27 -0
  17. package/src/__mocks__/react-native-worklets.tsx +11 -0
  18. package/src/__mocks__/react-native.tsx +338 -0
  19. package/src/__mocks__/rn-primitives/avatar.tsx +24 -0
  20. package/src/__mocks__/rn-primitives/checkbox.tsx +19 -0
  21. package/src/__mocks__/rn-primitives/select.tsx +116 -0
  22. package/src/__mocks__/rn-primitives/slider.tsx +40 -0
  23. package/src/__mocks__/rn-primitives/slot.tsx +30 -0
  24. package/src/__mocks__/rn-primitives/switch.tsx +24 -0
  25. package/src/__mocks__/rn-primitives/toggle.tsx +16 -0
  26. package/src/components/atoms/Avatar/Avatar.stories.tsx +57 -49
  27. package/src/components/atoms/Avatar/Avatar.test.tsx +269 -0
  28. package/src/components/atoms/Avatar/Avatar.tsx +68 -22
  29. package/src/components/atoms/Avatar/index.ts +1 -6
  30. package/src/components/atoms/Badge/Badge.stories.tsx +5 -29
  31. package/src/components/atoms/Badge/Badge.test.tsx +90 -0
  32. package/src/components/atoms/Button/Button.test.tsx +123 -0
  33. package/src/components/atoms/Button/Button.tsx +1 -1
  34. package/src/components/atoms/CarouselControls/CarouselControls.stories.tsx +217 -0
  35. package/src/components/atoms/CarouselControls/CarouselControls.tsx +127 -0
  36. package/src/components/atoms/CarouselControls/index.ts +2 -0
  37. package/src/components/atoms/Hint/Hint.test.tsx +36 -0
  38. package/src/components/atoms/Icon/Icon.test.tsx +98 -0
  39. package/src/components/atoms/Icon/Icon.tsx +5 -1
  40. package/src/components/atoms/IconButton/IconButton.test.tsx +101 -0
  41. package/src/components/atoms/Illustration/Illustration.test.tsx +55 -0
  42. package/src/components/atoms/Input/Input.stories.tsx +129 -86
  43. package/src/components/atoms/Input/Input.test.tsx +306 -0
  44. package/src/components/atoms/Input/Input.tsx +9 -1
  45. package/src/components/atoms/Input/InputField.tsx +226 -74
  46. package/src/components/atoms/Link/Link.test.tsx +89 -0
  47. package/src/components/atoms/Logo/Logo.registry.ts +30 -5
  48. package/src/components/atoms/Logo/Logo.stories.tsx +108 -0
  49. package/src/components/atoms/Logo/Logo.test.tsx +56 -0
  50. package/src/components/atoms/Logo/assets/BCorp.tsx +113 -0
  51. package/src/components/atoms/Logo/assets/ButternutFavicon.tsx +33 -0
  52. package/src/components/atoms/Logo/assets/ButternutPrimary.tsx +294 -0
  53. package/src/components/atoms/Logo/assets/ButternutTabbedBottom.tsx +294 -0
  54. package/src/components/atoms/Logo/assets/ButternutTabbedTop.tsx +294 -0
  55. package/src/components/atoms/Logo/assets/ButternutWordmark.tsx +274 -0
  56. package/src/components/atoms/Logo/assets/PsiBufetFavicon.tsx +45 -0
  57. package/src/components/atoms/Logo/assets/PsiBufetPrimary.tsx +218 -0
  58. package/src/components/atoms/Logo/assets/PsiBufetTabbedBottom.tsx +218 -0
  59. package/src/components/atoms/Logo/assets/PsiBufetTabbedTop.tsx +218 -0
  60. package/src/components/atoms/Logo/assets/PsiBufetWordmark.tsx +195 -0
  61. package/src/components/atoms/Logo/assets/index.ts +11 -0
  62. package/src/components/atoms/NumberInput/NumberInput.stories.tsx +183 -0
  63. package/src/components/atoms/NumberInput/NumberInput.test.tsx +261 -0
  64. package/src/components/atoms/NumberInput/NumberInput.tsx +129 -0
  65. package/src/components/atoms/NumberInput/NumberInputField.tsx +77 -0
  66. package/src/components/atoms/NumberInput/index.ts +4 -0
  67. package/src/components/atoms/Spinner/Spinner.test.tsx +46 -0
  68. package/src/components/atoms/Spinner/Spinner.tsx +14 -5
  69. package/src/components/atoms/Switch/Switch.test.tsx +92 -0
  70. package/src/components/atoms/Switch/Switch.tsx +16 -13
  71. package/src/components/atoms/Tag/Tag.test.tsx +70 -0
  72. package/src/components/atoms/TextArea/TextArea.stories.tsx +303 -0
  73. package/src/components/atoms/TextArea/TextArea.test.tsx +416 -0
  74. package/src/components/atoms/TextArea/TextArea.tsx +171 -0
  75. package/src/components/atoms/TextArea/TextAreaField.tsx +304 -0
  76. package/src/components/atoms/TextArea/TextAreaLabel.tsx +103 -0
  77. package/src/components/atoms/TextArea/index.ts +6 -0
  78. package/src/components/atoms/Typography/Typography.test.tsx +94 -0
  79. package/src/components/atoms/index.ts +3 -0
  80. package/src/components/molecules/Accordion/Accordion.stories.tsx +177 -0
  81. package/src/components/molecules/Accordion/Accordion.test.tsx +185 -0
  82. package/src/components/molecules/Accordion/Accordion.tsx +284 -0
  83. package/src/components/molecules/Accordion/index.ts +6 -0
  84. package/src/components/molecules/Animated/Animated.stories.tsx +254 -0
  85. package/src/components/molecules/Animated/Animated.tsx +283 -0
  86. package/src/components/molecules/Animated/index.ts +10 -0
  87. package/src/components/molecules/ButtonDock/ButtonDock.test.tsx +83 -0
  88. package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +8 -14
  89. package/src/components/molecules/ButtonGroup/ButtonGroup.test.tsx +73 -0
  90. package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +25 -3
  91. package/src/components/molecules/Checkbox/Checkbox.stories.tsx +72 -0
  92. package/src/components/molecules/Checkbox/Checkbox.test.tsx +117 -0
  93. package/src/components/molecules/Checkbox/Checkbox.tsx +101 -95
  94. package/src/components/molecules/CopyField/CopyField.stories.tsx +313 -0
  95. package/src/components/molecules/CopyField/CopyField.test.tsx +431 -0
  96. package/src/components/molecules/CopyField/CopyField.tsx +156 -0
  97. package/src/components/molecules/CopyField/CopyFieldInput.tsx +127 -0
  98. package/src/components/molecules/CopyField/hooks/index.ts +1 -0
  99. package/src/components/molecules/CopyField/hooks/useCopyField.ts +25 -0
  100. package/src/components/molecules/CopyField/index.ts +4 -0
  101. package/src/components/molecules/DatePicker/DatePicker.stories.tsx +298 -0
  102. package/src/components/molecules/DatePicker/DatePicker.test.tsx +201 -0
  103. package/src/components/molecules/DatePicker/DatePicker.tsx +590 -0
  104. package/src/components/molecules/DatePicker/index.ts +2 -0
  105. package/src/components/molecules/Drawer/Drawer.stories.tsx +285 -0
  106. package/src/components/molecules/Drawer/Drawer.test.tsx +180 -0
  107. package/src/components/molecules/Drawer/Drawer.tsx +187 -0
  108. package/src/components/molecules/Drawer/DrawerBody.tsx +80 -0
  109. package/src/components/molecules/Drawer/DrawerClose.tsx +76 -0
  110. package/src/components/molecules/Drawer/DrawerContent.tsx +339 -0
  111. package/src/components/molecules/Drawer/DrawerContext.ts +19 -0
  112. package/src/components/molecules/Drawer/DrawerDescription.tsx +31 -0
  113. package/src/components/molecules/Drawer/DrawerDragContext.ts +11 -0
  114. package/src/components/molecules/Drawer/DrawerFooter.tsx +49 -0
  115. package/src/components/molecules/Drawer/DrawerFooterContext.ts +6 -0
  116. package/src/components/molecules/Drawer/DrawerGrabber.tsx +62 -0
  117. package/src/components/molecules/Drawer/DrawerHeader.tsx +244 -0
  118. package/src/components/molecules/Drawer/DrawerHeaderContext.ts +13 -0
  119. package/src/components/molecules/Drawer/DrawerOverlay.tsx +53 -0
  120. package/src/components/molecules/Drawer/DrawerTitle.tsx +32 -0
  121. package/src/components/molecules/Drawer/index.ts +12 -0
  122. package/src/components/molecules/FilterTab/FilterTab.stories.tsx +210 -0
  123. package/src/components/molecules/FilterTab/FilterTab.tsx +310 -0
  124. package/src/components/molecules/FilterTab/index.ts +2 -0
  125. package/src/components/molecules/MessageCard/MessageCard.stories.tsx +169 -0
  126. package/src/components/molecules/MessageCard/MessageCard.tsx +362 -0
  127. package/src/components/molecules/MessageCard/index.ts +10 -0
  128. package/src/components/molecules/Notification/Notification.stories.tsx +219 -0
  129. package/src/components/molecules/Notification/Notification.tsx +426 -0
  130. package/src/components/molecules/Notification/index.ts +2 -0
  131. package/src/components/molecules/NumberField/NumberField.stories.tsx +231 -0
  132. package/src/components/molecules/NumberField/NumberField.tsx +186 -0
  133. package/src/components/molecules/NumberField/NumberFieldInput.tsx +287 -0
  134. package/src/components/molecules/NumberField/index.ts +2 -0
  135. package/src/components/molecules/PasswordField/PasswordField.stories.tsx +362 -0
  136. package/src/components/molecules/PasswordField/PasswordField.test.tsx +369 -0
  137. package/src/components/molecules/PasswordField/PasswordField.tsx +194 -0
  138. package/src/components/molecules/PasswordField/PasswordFieldError.tsx +52 -0
  139. package/src/components/molecules/PasswordField/PasswordFieldInput.tsx +73 -0
  140. package/src/components/molecules/PasswordField/PasswordFieldRequirements.tsx +92 -0
  141. package/src/components/molecules/PasswordField/hooks/index.ts +2 -0
  142. package/src/components/molecules/PasswordField/hooks/usePasswordField.ts +113 -0
  143. package/src/components/molecules/PasswordField/index.ts +10 -0
  144. package/src/components/molecules/PictureSelector/PictureSelector.stories.tsx +243 -0
  145. package/src/components/molecules/PictureSelector/PictureSelector.tsx +313 -0
  146. package/src/components/molecules/PictureSelector/index.ts +5 -0
  147. package/src/components/molecules/Progress/Progress.stories.tsx +145 -0
  148. package/src/components/molecules/Progress/Progress.tsx +184 -0
  149. package/src/components/molecules/Progress/index.ts +2 -0
  150. package/src/components/molecules/Radio/Radio.test.tsx +104 -0
  151. package/src/components/molecules/Radio/Radio.tsx +1 -2
  152. package/src/components/molecules/SearchField/SearchField.stories.tsx +242 -0
  153. package/src/components/molecules/SearchField/SearchField.test.tsx +318 -0
  154. package/src/components/molecules/SearchField/SearchField.tsx +143 -0
  155. package/src/components/molecules/SearchField/SearchFieldInput.tsx +63 -0
  156. package/src/components/molecules/SearchField/hooks/index.ts +1 -0
  157. package/src/components/molecules/SearchField/hooks/useSearchField.ts +56 -0
  158. package/src/components/molecules/SearchField/index.ts +4 -0
  159. package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +31 -8
  160. package/src/components/molecules/SegmentedControl/SegmentedControl.test.tsx +141 -0
  161. package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +237 -23
  162. package/src/components/molecules/SelectField/SelectField.stories.tsx +320 -0
  163. package/src/components/molecules/SelectField/SelectField.test.tsx +254 -0
  164. package/src/components/molecules/SelectField/SelectField.tsx +236 -0
  165. package/src/components/molecules/SelectField/SelectFieldContent.tsx +85 -0
  166. package/src/components/molecules/SelectField/SelectFieldItem.tsx +133 -0
  167. package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +170 -0
  168. package/src/components/molecules/SelectField/SelectFieldValue.tsx +31 -0
  169. package/src/components/molecules/SelectField/hooks/index.ts +2 -0
  170. package/src/components/molecules/SelectField/hooks/useSelectField.ts +84 -0
  171. package/src/components/molecules/SelectField/index.ts +10 -0
  172. package/src/components/molecules/Slider/Slider.test.tsx +102 -0
  173. package/src/components/molecules/Slider/Slider.tsx +293 -180
  174. package/src/components/molecules/Tooltip/Tooltip.stories.tsx +168 -0
  175. package/src/components/molecules/Tooltip/Tooltip.tsx +326 -0
  176. package/src/components/molecules/Tooltip/index.ts +2 -0
  177. package/src/components/molecules/index.ts +15 -0
  178. package/src/test-utils.tsx +20 -0
  179. package/tsconfig.json +1 -1
  180. package/tsup.config.ts +16 -2
  181. package/vitest.config.ts +114 -0
  182. 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,4 @@
1
+ export { NumberInput } from "./NumberInput"
2
+ export type { NumberInputProps } from "./NumberInput"
3
+ export { NumberInputField } from "./NumberInputField"
4
+ export type { NumberInputFieldProps } from "./NumberInputField"
@@ -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
+ })