@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,369 @@
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 { PasswordField } from "./PasswordField"
8
+
9
+ describe("PasswordField", () => {
10
+ describe("when using simple props API", () => {
11
+ it("renders label when provided", () => {
12
+ renderWithTheme(
13
+ <PasswordField label="Password" placeholder="Enter password" />
14
+ )
15
+
16
+ expect(screen.getByText("Password")).toBeInTheDocument()
17
+ })
18
+
19
+ it("renders description when provided", () => {
20
+ renderWithTheme(
21
+ <PasswordField
22
+ label="Password"
23
+ placeholder="Enter password"
24
+ description="Choose a strong password"
25
+ />
26
+ )
27
+
28
+ expect(screen.getByText("Choose a strong password")).toBeInTheDocument()
29
+ })
30
+
31
+ it("renders error message when error prop and state='error' are provided", () => {
32
+ renderWithTheme(
33
+ <PasswordField
34
+ label="Password"
35
+ placeholder="Enter password"
36
+ state="error"
37
+ error="Password is too weak"
38
+ />
39
+ )
40
+
41
+ expect(screen.getByText("Password is too weak")).toBeInTheDocument()
42
+ })
43
+
44
+ it("does not render error message when error prop is provided without state='error'", () => {
45
+ renderWithTheme(
46
+ <PasswordField
47
+ label="Password"
48
+ placeholder="Enter password"
49
+ error="Password is too weak"
50
+ />
51
+ )
52
+
53
+ expect(screen.queryByText("Password is too weak")).not.toBeInTheDocument()
54
+ })
55
+
56
+ it("shows (optional) when optionalText is provided", () => {
57
+ renderWithTheme(
58
+ <PasswordField
59
+ label="Password"
60
+ placeholder="Enter password"
61
+ optionalText="(optional)"
62
+ />
63
+ )
64
+
65
+ expect(screen.getByText("(optional)")).toBeInTheDocument()
66
+ })
67
+
68
+ it("renders input with placeholder", () => {
69
+ renderWithTheme(
70
+ <PasswordField label="Password" placeholder="Enter password" />
71
+ )
72
+
73
+ expect(screen.getByPlaceholderText("Enter password")).toBeInTheDocument()
74
+ })
75
+
76
+ it("accepts user input", async () => {
77
+ const user = userEvent.setup()
78
+
79
+ renderWithTheme(
80
+ <PasswordField label="Password" placeholder="Enter password" />
81
+ )
82
+
83
+ const input = screen.getByPlaceholderText("Enter password")
84
+ await user.type(input, "mypassword")
85
+
86
+ expect(input).toHaveValue("mypassword")
87
+ })
88
+
89
+ it("renders requirements list when showRequirements is true", () => {
90
+ const requirements = ["At least 8 characters", "One uppercase letter"]
91
+
92
+ renderWithTheme(
93
+ <PasswordField
94
+ label="Password"
95
+ placeholder="Enter password"
96
+ requirements={requirements}
97
+ showRequirements={true}
98
+ />
99
+ )
100
+
101
+ expect(screen.getByText("At least 8 characters")).toBeInTheDocument()
102
+ expect(screen.getByText("One uppercase letter")).toBeInTheDocument()
103
+ })
104
+
105
+ it("does not render requirements list when showRequirements is false", () => {
106
+ const requirements = ["At least 8 characters", "One uppercase letter"]
107
+
108
+ renderWithTheme(
109
+ <PasswordField
110
+ label="Password"
111
+ placeholder="Enter password"
112
+ requirements={requirements}
113
+ showRequirements={false}
114
+ />
115
+ )
116
+
117
+ expect(
118
+ screen.queryByText("At least 8 characters")
119
+ ).not.toBeInTheDocument()
120
+ })
121
+ })
122
+
123
+ describe("when using controlled mode", () => {
124
+ it("updates value when parent updates", async () => {
125
+ const user = userEvent.setup()
126
+
127
+ function ControlledPasswordField() {
128
+ const [value, setValue] = React.useState("")
129
+
130
+ return (
131
+ <PasswordField
132
+ label="Password"
133
+ value={value}
134
+ onValueChange={(newValue) => setValue(newValue)}
135
+ placeholder="Enter password"
136
+ />
137
+ )
138
+ }
139
+
140
+ renderWithTheme(<ControlledPasswordField />)
141
+
142
+ const input = screen.getByPlaceholderText("Enter password")
143
+ await user.type(input, "mypassword")
144
+
145
+ expect(input).toHaveValue("mypassword")
146
+ })
147
+
148
+ it("calls onValueChange when value changes", async () => {
149
+ const user = userEvent.setup()
150
+ const onValueChange = vi.fn()
151
+
152
+ renderWithTheme(
153
+ <PasswordField
154
+ label="Password"
155
+ value=""
156
+ onValueChange={onValueChange}
157
+ placeholder="Enter password"
158
+ />
159
+ )
160
+
161
+ const input = screen.getByPlaceholderText("Enter password")
162
+ await user.type(input, "a")
163
+
164
+ expect(onValueChange).toHaveBeenCalledWith("a")
165
+ })
166
+ })
167
+
168
+ describe("when disabled", () => {
169
+ it("disables the input", () => {
170
+ renderWithTheme(
171
+ <PasswordField
172
+ label="Password"
173
+ placeholder="Enter password"
174
+ editable={false}
175
+ />
176
+ )
177
+
178
+ expect(screen.getByPlaceholderText("Enter password")).toBeDisabled()
179
+ })
180
+
181
+ it("does not accept user input when disabled", async () => {
182
+ const user = userEvent.setup()
183
+
184
+ renderWithTheme(
185
+ <PasswordField
186
+ label="Password"
187
+ placeholder="Enter password"
188
+ editable={false}
189
+ />
190
+ )
191
+
192
+ const input = screen.getByPlaceholderText("Enter password")
193
+ await user.type(input, "test")
194
+
195
+ expect(input).toHaveValue("")
196
+ })
197
+ })
198
+
199
+ describe("password visibility toggle", () => {
200
+ it("shows toggle button", () => {
201
+ renderWithTheme(<PasswordField placeholder="Enter password" />)
202
+
203
+ expect(screen.getByLabelText("Show password")).toBeInTheDocument()
204
+ })
205
+
206
+ it("toggles password visibility when button is clicked", async () => {
207
+ const user = userEvent.setup()
208
+
209
+ renderWithTheme(<PasswordField placeholder="Enter password" />)
210
+
211
+ expect(screen.getByLabelText("Show password")).toBeInTheDocument()
212
+
213
+ const toggleButton = screen.getByLabelText("Show password")
214
+ await user.click(toggleButton)
215
+
216
+ expect(screen.getByLabelText("Hide password")).toBeInTheDocument()
217
+
218
+ await user.click(screen.getByLabelText("Hide password"))
219
+ expect(screen.getByLabelText("Show password")).toBeInTheDocument()
220
+ })
221
+
222
+ it("does not toggle when disabled", async () => {
223
+ const user = userEvent.setup()
224
+
225
+ renderWithTheme(
226
+ <PasswordField placeholder="Enter password" editable={false} />
227
+ )
228
+
229
+ expect(screen.getByLabelText("Show password")).toBeInTheDocument()
230
+
231
+ const toggleButton = screen.getByLabelText("Show password")
232
+ await user.click(toggleButton)
233
+
234
+ // Button label should not change when disabled
235
+ expect(screen.getByLabelText("Show password")).toBeInTheDocument()
236
+ expect(screen.queryByLabelText("Hide password")).not.toBeInTheDocument()
237
+ })
238
+ })
239
+
240
+ describe("when using compound component API", () => {
241
+ it("renders compound components", () => {
242
+ renderWithTheme(
243
+ <PasswordField.Root>
244
+ <PasswordField.Label>Password</PasswordField.Label>
245
+ <PasswordField.Field placeholder="Enter password" />
246
+ <PasswordField.Description>
247
+ Choose a strong password
248
+ </PasswordField.Description>
249
+ </PasswordField.Root>
250
+ )
251
+
252
+ expect(screen.getByText("Password")).toBeInTheDocument()
253
+ expect(screen.getByPlaceholderText("Enter password")).toBeInTheDocument()
254
+ expect(screen.getByText("Choose a strong password")).toBeInTheDocument()
255
+ })
256
+
257
+ it("renders error in compound mode", () => {
258
+ renderWithTheme(
259
+ <PasswordField.Root>
260
+ <PasswordField.Label state="error">Password</PasswordField.Label>
261
+ <PasswordField.Field placeholder="Enter password" state="error" />
262
+ <PasswordField.Error>Invalid password</PasswordField.Error>
263
+ </PasswordField.Root>
264
+ )
265
+
266
+ expect(screen.getByText("Invalid password")).toBeInTheDocument()
267
+ })
268
+
269
+ it("renders requirements in compound mode", () => {
270
+ const requirements = ["At least 8 characters", "One number"]
271
+
272
+ renderWithTheme(
273
+ <PasswordField.Root>
274
+ <PasswordField.Label>Password</PasswordField.Label>
275
+ <PasswordField.Field placeholder="Enter password" />
276
+ <PasswordField.Requirements requirements={requirements} />
277
+ </PasswordField.Root>
278
+ )
279
+
280
+ expect(screen.getByText("At least 8 characters")).toBeInTheDocument()
281
+ expect(screen.getByText("One number")).toBeInTheDocument()
282
+ })
283
+ })
284
+
285
+ describe("validation states", () => {
286
+ it.each(["default", "error", "success"] as const)(
287
+ "renders %s state without errors",
288
+ (state) => {
289
+ renderWithTheme(
290
+ <PasswordField
291
+ label="Password"
292
+ state={state}
293
+ placeholder="Enter password"
294
+ />
295
+ )
296
+ expect(
297
+ screen.getByPlaceholderText("Enter password")
298
+ ).toBeInTheDocument()
299
+ }
300
+ )
301
+
302
+ it("shows error state with error message", () => {
303
+ renderWithTheme(
304
+ <PasswordField
305
+ label="Password"
306
+ placeholder="Enter password"
307
+ state="error"
308
+ error="Password is too weak"
309
+ />
310
+ )
311
+
312
+ expect(screen.getByText("Password is too weak")).toBeInTheDocument()
313
+ })
314
+
315
+ it("hides requirements when state is success", () => {
316
+ const requirements = ["At least 8 characters", "One uppercase letter"]
317
+
318
+ renderWithTheme(
319
+ <PasswordField
320
+ label="Password"
321
+ placeholder="Enter password"
322
+ requirements={requirements}
323
+ showRequirements={true}
324
+ state="success"
325
+ />
326
+ )
327
+
328
+ expect(
329
+ screen.queryByText("At least 8 characters")
330
+ ).not.toBeInTheDocument()
331
+ })
332
+ })
333
+
334
+ describe("compound component API with states", () => {
335
+ it("renders error state in compound mode", () => {
336
+ renderWithTheme(
337
+ <PasswordField.Root>
338
+ <PasswordField.Label state="error">Password</PasswordField.Label>
339
+ <PasswordField.Field placeholder="Enter password" state="error" />
340
+ <PasswordField.Error>Invalid password</PasswordField.Error>
341
+ </PasswordField.Root>
342
+ )
343
+
344
+ expect(screen.getByText("Invalid password")).toBeInTheDocument()
345
+ })
346
+
347
+ it("renders success state in compound mode", () => {
348
+ renderWithTheme(
349
+ <PasswordField.Root>
350
+ <PasswordField.Label state="success">Password</PasswordField.Label>
351
+ <PasswordField.Field placeholder="Enter password" state="success" />
352
+ <PasswordField.Description state="success">
353
+ Password verified
354
+ </PasswordField.Description>
355
+ </PasswordField.Root>
356
+ )
357
+
358
+ expect(screen.getByText("Password verified")).toBeInTheDocument()
359
+ })
360
+ })
361
+
362
+ describe("when using with ref", () => {
363
+ it("forwards ref to input element", () => {
364
+ const ref = React.createRef<TextInput>()
365
+ renderWithTheme(<PasswordField ref={ref} label="Test" />)
366
+ expect(ref.current).toBeTruthy()
367
+ })
368
+ })
369
+ })
@@ -0,0 +1,194 @@
1
+ import React from "react"
2
+ import { View, ViewProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import type { InputState, InputFieldProps } from "../../atoms/Input/InputField"
5
+ import { InputLabel } from "../../atoms/Input/InputLabel"
6
+ import { InputDescription } from "../../atoms/Input/InputDescription"
7
+ import { PasswordFieldInput } from "./PasswordFieldInput"
8
+ import { PasswordFieldError } from "./PasswordFieldError"
9
+ import {
10
+ PasswordFieldRequirements,
11
+ type PasswordRequirement
12
+ } from "./PasswordFieldRequirements"
13
+
14
+ const parseTokenValue = (value: string): number => parseFloat(value)
15
+
16
+ const StyledRoot = styled(View)(({ theme }) => {
17
+ const { spacing } = theme.tokens.components.inputs
18
+
19
+ return {
20
+ gap: parseTokenValue(spacing.gap)
21
+ }
22
+ })
23
+
24
+ type PasswordFieldOwnProps = {
25
+ label?: string
26
+ description?: string
27
+ error?: string | string[]
28
+ state?: InputState
29
+ optionalText?: string
30
+ requirements?: string[] | PasswordRequirement[]
31
+ showRequirements?: boolean
32
+ onValueChange?: (value: string) => void
33
+ }
34
+
35
+ export type PasswordFieldProps = PasswordFieldOwnProps &
36
+ Omit<InputFieldProps, keyof PasswordFieldOwnProps> &
37
+ Omit<ViewProps, keyof PasswordFieldOwnProps>
38
+
39
+ /**
40
+ * Password field component with visibility toggle and optional requirements list.
41
+ *
42
+ * **Recommended: Use with usePasswordField hook:**
43
+ * @example
44
+ * ```tsx
45
+ * import { PasswordField, usePasswordField } from '@butternutbox/pawprint-native'
46
+ *
47
+ * const passwordProps = usePasswordField({
48
+ * validationRules: [
49
+ * { test: (v) => v.length >= 8, message: "At least 8 characters" },
50
+ * { test: (v) => /[A-Z]/.test(v), message: "One uppercase letter" },
51
+ * { test: (v) => /[a-z]/.test(v), message: "One lowercase letter" },
52
+ * { test: (v) => /\d/.test(v), message: "One number" }
53
+ * ]
54
+ * })
55
+ *
56
+ * <PasswordField
57
+ * {...passwordProps}
58
+ * label="Create Password"
59
+ * placeholder="Enter password"
60
+ * />
61
+ * ```
62
+ *
63
+ * **Simple Props API (without hook):**
64
+ * @example
65
+ * ```tsx
66
+ * const [password, setPassword] = useState("")
67
+ * const [state, setState] = useState<InputState>("default")
68
+ *
69
+ * const requirements: PasswordRequirement[] = [
70
+ * { text: "At least 8 characters", satisfied: password.length >= 8 },
71
+ * { text: "One uppercase letter", satisfied: /[A-Z]/.test(password) }
72
+ * ]
73
+ *
74
+ * <PasswordField
75
+ * label="Password"
76
+ * placeholder="Enter password"
77
+ * value={password}
78
+ * onValueChange={setPassword}
79
+ * state={state}
80
+ * requirements={requirements}
81
+ * showRequirements={password.length > 0}
82
+ * />
83
+ * ```
84
+ *
85
+ * **Compound Component API:**
86
+ * @example
87
+ * ```tsx
88
+ * const [password, setPassword] = useState("")
89
+ * const [state, setState] = useState<InputState>("default")
90
+ *
91
+ * <PasswordField.Root>
92
+ * <PasswordField.Label state={state}>Password</PasswordField.Label>
93
+ * <PasswordField.Field
94
+ * placeholder="Enter password"
95
+ * value={password}
96
+ * onValueChange={setPassword}
97
+ * state={state}
98
+ * />
99
+ * <PasswordField.Description state={state}>
100
+ * Choose a strong password
101
+ * </PasswordField.Description>
102
+ * <PasswordField.Requirements requirements={requirements} />
103
+ * <PasswordField.Error>Password is required</PasswordField.Error>
104
+ * </PasswordField.Root>
105
+ * ```
106
+ *
107
+ * @param {string} [label] - Label text (props API)
108
+ * @param {string} [description] - Description/help text (props API)
109
+ * @param {string | string[]} [error] - Error message(s) to display. Pass a string for a single error or an array for multiple. Does not affect visual state - use state prop for that.
110
+ * @param {InputState} [state] - Visual state of the input: 'default', 'error', or 'success' (props API)
111
+ * @param {string} [optionalText] - Optional text to display next to label (props API)
112
+ * @param {string[] | PasswordRequirement[]} [requirements] - List of password requirements to display. Can be simple strings or objects with text and satisfied status (props API)
113
+ * @param {boolean} [showRequirements] - Whether to show requirements list. Requirements auto-hide when state is 'success' (props API)
114
+ * @param {function} [onValueChange] - Value change handler (receives string value)
115
+ * @param {string} [value] - Controlled value
116
+ * @param {string} [defaultValue] - Default value for uncontrolled mode
117
+ * @param {function} [onChangeText] - Change event handler
118
+ * @param {boolean} [editable] - Controls whether the input is editable
119
+ */
120
+ const PasswordFieldRoot = React.forwardRef<View, PasswordFieldProps>(
121
+ (
122
+ {
123
+ label,
124
+ description,
125
+ error,
126
+ state = "default",
127
+ optionalText,
128
+ requirements,
129
+ showRequirements = false,
130
+ onValueChange,
131
+ value,
132
+ children,
133
+ ...inputFieldProps
134
+ },
135
+ ref
136
+ ) => {
137
+ if (children) {
138
+ return <StyledRoot ref={ref}>{children}</StyledRoot>
139
+ }
140
+
141
+ return (
142
+ <StyledRoot ref={ref}>
143
+ {label && (
144
+ <InputLabel optionalText={optionalText} state={state}>
145
+ {label}
146
+ </InputLabel>
147
+ )}
148
+ <PasswordFieldInput
149
+ state={state}
150
+ value={value}
151
+ onValueChange={onValueChange}
152
+ {...inputFieldProps}
153
+ />
154
+ {description && (
155
+ <InputDescription state={state}>{description}</InputDescription>
156
+ )}
157
+ {state === "error" &&
158
+ (Array.isArray(error) ? error : error ? [error] : []).map(
159
+ (message, index) => (
160
+ <PasswordFieldError key={index}>{message}</PasswordFieldError>
161
+ )
162
+ )}
163
+ {showRequirements &&
164
+ requirements &&
165
+ requirements.length > 0 &&
166
+ state !== "success" && (
167
+ <PasswordFieldRequirements requirements={requirements} />
168
+ )}
169
+ </StyledRoot>
170
+ )
171
+ }
172
+ )
173
+
174
+ PasswordFieldRoot.displayName = "PasswordField"
175
+
176
+ type PasswordFieldComponent = React.ForwardRefExoticComponent<
177
+ PasswordFieldProps & React.RefAttributes<View>
178
+ > & {
179
+ Root: typeof StyledRoot
180
+ Label: typeof InputLabel
181
+ Field: typeof PasswordFieldInput
182
+ Description: typeof InputDescription
183
+ Requirements: typeof PasswordFieldRequirements
184
+ Error: typeof PasswordFieldError
185
+ }
186
+
187
+ export const PasswordField = Object.assign(PasswordFieldRoot, {
188
+ Root: StyledRoot,
189
+ Label: InputLabel,
190
+ Field: PasswordFieldInput,
191
+ Description: InputDescription,
192
+ Requirements: PasswordFieldRequirements,
193
+ Error: PasswordFieldError
194
+ }) as PasswordFieldComponent
@@ -0,0 +1,52 @@
1
+ import React from "react"
2
+ import { View, ViewProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import { Typography } from "../../atoms/Typography"
6
+ import { Icon } from "../../atoms/Icon"
7
+ import { Cancel } from "@butternutbox/pawprint-icons/core"
8
+
9
+ export type PasswordFieldErrorProps = {
10
+ children?: React.ReactNode
11
+ } & ViewProps
12
+
13
+ const parseTokenValue = (value: string): number => parseFloat(value)
14
+
15
+ const StyledErrorRow = styled(View)<{ gap: number }>(({ gap }) => ({
16
+ flexDirection: "row",
17
+ alignItems: "center",
18
+ gap
19
+ }))
20
+
21
+ /**
22
+ * Error message component for PasswordField.
23
+ * Displays the error text with a RemoveCircle icon to match the design system
24
+ * password validation styling.
25
+ *
26
+ * @param children - Error message text
27
+ */
28
+ export const PasswordFieldError = React.forwardRef<
29
+ View,
30
+ PasswordFieldErrorProps
31
+ >(({ children, ...rest }, ref) => {
32
+ const theme = useTheme()
33
+ const { colour, description, spacing } = theme.tokens.components.inputs
34
+
35
+ return (
36
+ <StyledErrorRow
37
+ ref={ref}
38
+ gap={parseTokenValue(spacing.description.leftPadding)}
39
+ {...rest}
40
+ >
41
+ <Icon icon={Cancel} size="sm" colour="error" />
42
+ <Typography
43
+ token={description.text.default}
44
+ color={colour.description.error}
45
+ >
46
+ {children}
47
+ </Typography>
48
+ </StyledErrorRow>
49
+ )
50
+ })
51
+
52
+ PasswordFieldError.displayName = "PasswordField.Error"
@@ -0,0 +1,73 @@
1
+ import React from "react"
2
+ import { Pressable, TextInput } from "react-native"
3
+ import { InputField } from "../../atoms/Input/InputField"
4
+ import { Icon } from "../../atoms/Icon"
5
+ import { Visibility, VisibilityOff } from "@butternutbox/pawprint-icons/core"
6
+ import type { PasswordFieldProps } from "./PasswordField"
7
+
8
+ const HIT_SLOP = 12
9
+
10
+ /**
11
+ * Input field component for PasswordField.
12
+ * Wraps InputField with password visibility toggle.
13
+ *
14
+ * @param onValueChange - Value change handler
15
+ * @param value - Controlled value
16
+ * @param state - Visual state of the input
17
+ * @param editable - Controls whether the input is editable
18
+ */
19
+ export const PasswordFieldInput = React.forwardRef<
20
+ TextInput,
21
+ Omit<
22
+ PasswordFieldProps,
23
+ | "label"
24
+ | "description"
25
+ | "error"
26
+ | "optionalText"
27
+ | "requirements"
28
+ | "showRequirements"
29
+ >
30
+ >(({ onValueChange, value, state, editable, ...inputFieldProps }, ref) => {
31
+ const [showPassword, setShowPassword] = React.useState(false)
32
+
33
+ const handleValueChange = (newValue: string) => {
34
+ onValueChange?.(newValue)
35
+ }
36
+
37
+ const handleToggleVisibility = () => {
38
+ if (editable === false) return
39
+ setShowPassword((prev) => !prev)
40
+ }
41
+
42
+ const toggleButton = (
43
+ <Pressable
44
+ onPress={handleToggleVisibility}
45
+ accessibilityLabel={showPassword ? "Hide password" : "Show password"}
46
+ accessibilityRole="button"
47
+ disabled={editable === false}
48
+ hitSlop={{
49
+ top: HIT_SLOP,
50
+ bottom: HIT_SLOP,
51
+ left: HIT_SLOP,
52
+ right: HIT_SLOP
53
+ }}
54
+ >
55
+ <Icon icon={showPassword ? VisibilityOff : Visibility} size="md" />
56
+ </Pressable>
57
+ )
58
+
59
+ return (
60
+ <InputField
61
+ ref={ref}
62
+ {...inputFieldProps}
63
+ state={state}
64
+ value={value}
65
+ onChangeText={handleValueChange}
66
+ actionIcon={toggleButton}
67
+ secureTextEntry={!showPassword}
68
+ editable={editable}
69
+ />
70
+ )
71
+ })
72
+
73
+ PasswordFieldInput.displayName = "PasswordField.Field"