@butternutbox/pawprint-native 0.0.1 → 0.2.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 (187) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/CHANGELOG.md +30 -0
  3. package/COMPONENT_GUIDELINES.md +111 -4
  4. package/dist/index.cjs +12413 -1459
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +1111 -13
  7. package/dist/index.d.ts +1111 -13
  8. package/dist/index.js +12365 -1457
  9. package/dist/index.js.map +1 -1
  10. package/package.json +29 -11
  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.stories.tsx +2 -2
  42. package/src/components/atoms/Illustration/Illustration.test.tsx +55 -0
  43. package/src/components/atoms/Illustration/Illustration.tsx +3 -3
  44. package/src/components/atoms/Input/Input.stories.tsx +129 -86
  45. package/src/components/atoms/Input/Input.test.tsx +306 -0
  46. package/src/components/atoms/Input/Input.tsx +9 -1
  47. package/src/components/atoms/Input/InputField.tsx +226 -74
  48. package/src/components/atoms/Link/Link.test.tsx +89 -0
  49. package/src/components/atoms/Link/Link.tsx +7 -6
  50. package/src/components/atoms/Logo/Logo.registry.ts +30 -5
  51. package/src/components/atoms/Logo/Logo.stories.tsx +108 -0
  52. package/src/components/atoms/Logo/Logo.test.tsx +56 -0
  53. package/src/components/atoms/Logo/assets/BCorp.tsx +113 -0
  54. package/src/components/atoms/Logo/assets/ButternutFavicon.tsx +33 -0
  55. package/src/components/atoms/Logo/assets/ButternutPrimary.tsx +294 -0
  56. package/src/components/atoms/Logo/assets/ButternutTabbedBottom.tsx +294 -0
  57. package/src/components/atoms/Logo/assets/ButternutTabbedTop.tsx +294 -0
  58. package/src/components/atoms/Logo/assets/ButternutWordmark.tsx +274 -0
  59. package/src/components/atoms/Logo/assets/PsiBufetFavicon.tsx +45 -0
  60. package/src/components/atoms/Logo/assets/PsiBufetPrimary.tsx +218 -0
  61. package/src/components/atoms/Logo/assets/PsiBufetTabbedBottom.tsx +218 -0
  62. package/src/components/atoms/Logo/assets/PsiBufetTabbedTop.tsx +218 -0
  63. package/src/components/atoms/Logo/assets/PsiBufetWordmark.tsx +195 -0
  64. package/src/components/atoms/Logo/assets/index.ts +11 -0
  65. package/src/components/atoms/NumberInput/NumberInput.stories.tsx +183 -0
  66. package/src/components/atoms/NumberInput/NumberInput.test.tsx +261 -0
  67. package/src/components/atoms/NumberInput/NumberInput.tsx +129 -0
  68. package/src/components/atoms/NumberInput/NumberInputField.tsx +77 -0
  69. package/src/components/atoms/NumberInput/index.ts +4 -0
  70. package/src/components/atoms/Spinner/Spinner.test.tsx +46 -0
  71. package/src/components/atoms/Spinner/Spinner.tsx +14 -5
  72. package/src/components/atoms/Switch/Switch.test.tsx +92 -0
  73. package/src/components/atoms/Switch/Switch.tsx +28 -17
  74. package/src/components/atoms/Tag/Tag.test.tsx +70 -0
  75. package/src/components/atoms/TextArea/TextArea.stories.tsx +303 -0
  76. package/src/components/atoms/TextArea/TextArea.test.tsx +416 -0
  77. package/src/components/atoms/TextArea/TextArea.tsx +171 -0
  78. package/src/components/atoms/TextArea/TextAreaField.tsx +304 -0
  79. package/src/components/atoms/TextArea/TextAreaLabel.tsx +103 -0
  80. package/src/components/atoms/TextArea/index.ts +6 -0
  81. package/src/components/atoms/Typography/Typography.test.tsx +94 -0
  82. package/src/components/atoms/index.ts +3 -0
  83. package/src/components/molecules/Accordion/Accordion.stories.tsx +177 -0
  84. package/src/components/molecules/Accordion/Accordion.test.tsx +185 -0
  85. package/src/components/molecules/Accordion/Accordion.tsx +284 -0
  86. package/src/components/molecules/Accordion/index.ts +6 -0
  87. package/src/components/molecules/Animated/Animated.stories.tsx +254 -0
  88. package/src/components/molecules/Animated/Animated.tsx +283 -0
  89. package/src/components/molecules/Animated/index.ts +10 -0
  90. package/src/components/molecules/ButtonDock/ButtonDock.stories.tsx +44 -25
  91. package/src/components/molecules/ButtonDock/ButtonDock.test.tsx +83 -0
  92. package/src/components/molecules/ButtonDock/ButtonDock.tsx +16 -13
  93. package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +48 -29
  94. package/src/components/molecules/ButtonGroup/ButtonGroup.test.tsx +73 -0
  95. package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +25 -3
  96. package/src/components/molecules/Checkbox/Checkbox.stories.tsx +72 -0
  97. package/src/components/molecules/Checkbox/Checkbox.test.tsx +117 -0
  98. package/src/components/molecules/Checkbox/Checkbox.tsx +101 -95
  99. package/src/components/molecules/CopyField/CopyField.stories.tsx +313 -0
  100. package/src/components/molecules/CopyField/CopyField.test.tsx +431 -0
  101. package/src/components/molecules/CopyField/CopyField.tsx +156 -0
  102. package/src/components/molecules/CopyField/CopyFieldInput.tsx +127 -0
  103. package/src/components/molecules/CopyField/hooks/index.ts +1 -0
  104. package/src/components/molecules/CopyField/hooks/useCopyField.ts +25 -0
  105. package/src/components/molecules/CopyField/index.ts +4 -0
  106. package/src/components/molecules/DatePicker/DatePicker.stories.tsx +298 -0
  107. package/src/components/molecules/DatePicker/DatePicker.test.tsx +201 -0
  108. package/src/components/molecules/DatePicker/DatePicker.tsx +590 -0
  109. package/src/components/molecules/DatePicker/index.ts +2 -0
  110. package/src/components/molecules/Drawer/Drawer.stories.tsx +285 -0
  111. package/src/components/molecules/Drawer/Drawer.test.tsx +180 -0
  112. package/src/components/molecules/Drawer/Drawer.tsx +187 -0
  113. package/src/components/molecules/Drawer/DrawerBody.tsx +80 -0
  114. package/src/components/molecules/Drawer/DrawerClose.tsx +76 -0
  115. package/src/components/molecules/Drawer/DrawerContent.tsx +339 -0
  116. package/src/components/molecules/Drawer/DrawerContext.ts +19 -0
  117. package/src/components/molecules/Drawer/DrawerDescription.tsx +31 -0
  118. package/src/components/molecules/Drawer/DrawerDragContext.ts +11 -0
  119. package/src/components/molecules/Drawer/DrawerFooter.tsx +49 -0
  120. package/src/components/molecules/Drawer/DrawerFooterContext.ts +6 -0
  121. package/src/components/molecules/Drawer/DrawerGrabber.tsx +62 -0
  122. package/src/components/molecules/Drawer/DrawerHeader.tsx +244 -0
  123. package/src/components/molecules/Drawer/DrawerHeaderContext.ts +13 -0
  124. package/src/components/molecules/Drawer/DrawerOverlay.tsx +53 -0
  125. package/src/components/molecules/Drawer/DrawerTitle.tsx +32 -0
  126. package/src/components/molecules/Drawer/index.ts +12 -0
  127. package/src/components/molecules/FilterTab/FilterTab.stories.tsx +210 -0
  128. package/src/components/molecules/FilterTab/FilterTab.tsx +310 -0
  129. package/src/components/molecules/FilterTab/index.ts +2 -0
  130. package/src/components/molecules/MessageCard/MessageCard.stories.tsx +169 -0
  131. package/src/components/molecules/MessageCard/MessageCard.tsx +362 -0
  132. package/src/components/molecules/MessageCard/index.ts +10 -0
  133. package/src/components/molecules/Notification/Notification.stories.tsx +219 -0
  134. package/src/components/molecules/Notification/Notification.tsx +426 -0
  135. package/src/components/molecules/Notification/index.ts +2 -0
  136. package/src/components/molecules/NumberField/NumberField.stories.tsx +231 -0
  137. package/src/components/molecules/NumberField/NumberField.tsx +186 -0
  138. package/src/components/molecules/NumberField/NumberFieldInput.tsx +287 -0
  139. package/src/components/molecules/NumberField/index.ts +2 -0
  140. package/src/components/molecules/PasswordField/PasswordField.stories.tsx +362 -0
  141. package/src/components/molecules/PasswordField/PasswordField.test.tsx +369 -0
  142. package/src/components/molecules/PasswordField/PasswordField.tsx +194 -0
  143. package/src/components/molecules/PasswordField/PasswordFieldError.tsx +53 -0
  144. package/src/components/molecules/PasswordField/PasswordFieldInput.tsx +73 -0
  145. package/src/components/molecules/PasswordField/PasswordFieldRequirements.tsx +95 -0
  146. package/src/components/molecules/PasswordField/hooks/index.ts +2 -0
  147. package/src/components/molecules/PasswordField/hooks/usePasswordField.ts +113 -0
  148. package/src/components/molecules/PasswordField/index.ts +10 -0
  149. package/src/components/molecules/PictureSelector/PictureSelector.stories.tsx +204 -0
  150. package/src/components/molecules/PictureSelector/PictureSelector.tsx +335 -0
  151. package/src/components/molecules/PictureSelector/index.ts +5 -0
  152. package/src/components/molecules/Progress/Progress.stories.tsx +145 -0
  153. package/src/components/molecules/Progress/Progress.tsx +184 -0
  154. package/src/components/molecules/Progress/index.ts +2 -0
  155. package/src/components/molecules/Radio/Radio.test.tsx +104 -0
  156. package/src/components/molecules/Radio/Radio.tsx +1 -2
  157. package/src/components/molecules/SearchField/SearchField.stories.tsx +242 -0
  158. package/src/components/molecules/SearchField/SearchField.test.tsx +318 -0
  159. package/src/components/molecules/SearchField/SearchField.tsx +143 -0
  160. package/src/components/molecules/SearchField/SearchFieldInput.tsx +63 -0
  161. package/src/components/molecules/SearchField/hooks/index.ts +1 -0
  162. package/src/components/molecules/SearchField/hooks/useSearchField.ts +56 -0
  163. package/src/components/molecules/SearchField/index.ts +4 -0
  164. package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +31 -8
  165. package/src/components/molecules/SegmentedControl/SegmentedControl.test.tsx +141 -0
  166. package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +237 -23
  167. package/src/components/molecules/SelectField/SelectField.stories.tsx +320 -0
  168. package/src/components/molecules/SelectField/SelectField.test.tsx +254 -0
  169. package/src/components/molecules/SelectField/SelectField.tsx +236 -0
  170. package/src/components/molecules/SelectField/SelectFieldContent.tsx +85 -0
  171. package/src/components/molecules/SelectField/SelectFieldItem.tsx +133 -0
  172. package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +170 -0
  173. package/src/components/molecules/SelectField/SelectFieldValue.tsx +31 -0
  174. package/src/components/molecules/SelectField/hooks/index.ts +2 -0
  175. package/src/components/molecules/SelectField/hooks/useSelectField.ts +84 -0
  176. package/src/components/molecules/SelectField/index.ts +10 -0
  177. package/src/components/molecules/Slider/Slider.test.tsx +102 -0
  178. package/src/components/molecules/Slider/Slider.tsx +293 -180
  179. package/src/components/molecules/Tooltip/Tooltip.stories.tsx +168 -0
  180. package/src/components/molecules/Tooltip/Tooltip.tsx +326 -0
  181. package/src/components/molecules/Tooltip/index.ts +2 -0
  182. package/src/components/molecules/index.ts +15 -0
  183. package/src/test-utils.tsx +20 -0
  184. package/tsconfig.json +1 -1
  185. package/tsup.config.ts +16 -2
  186. package/vitest.config.ts +114 -0
  187. package/vitest.setup.ts +16 -0
@@ -0,0 +1,431 @@
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, beforeEach } from "vitest"
6
+ import { renderWithTheme } from "../../../test-utils"
7
+ import { CopyField } from "./CopyField"
8
+ import { Clipboard } from "react-native"
9
+
10
+ vi.mock("react-native", async () => {
11
+ const actual = await vi.importActual("react-native")
12
+ return {
13
+ ...actual,
14
+ Clipboard: {
15
+ setString: vi.fn()
16
+ },
17
+ Alert: {
18
+ alert: vi.fn()
19
+ }
20
+ }
21
+ })
22
+
23
+ describe("CopyField", () => {
24
+ beforeEach(() => {
25
+ vi.clearAllMocks()
26
+ })
27
+
28
+ describe("when using simple props API", () => {
29
+ it("renders label when provided", () => {
30
+ renderWithTheme(<CopyField label="API Key" placeholder="Enter key..." />)
31
+
32
+ expect(screen.getByText("API Key")).toBeInTheDocument()
33
+ })
34
+
35
+ it("renders description when provided", () => {
36
+ renderWithTheme(
37
+ <CopyField
38
+ label="API Key"
39
+ placeholder="Enter key..."
40
+ description="Your unique API key"
41
+ />
42
+ )
43
+
44
+ expect(screen.getByText("Your unique API key")).toBeInTheDocument()
45
+ })
46
+
47
+ it("renders error message when error prop and state='error' are provided", () => {
48
+ renderWithTheme(
49
+ <CopyField
50
+ label="API Key"
51
+ placeholder="Enter key..."
52
+ state="error"
53
+ error="Invalid API key"
54
+ />
55
+ )
56
+
57
+ expect(screen.getByText("Invalid API key")).toBeInTheDocument()
58
+ })
59
+
60
+ it("does not render error message when error prop is provided without state='error'", () => {
61
+ renderWithTheme(
62
+ <CopyField
63
+ label="API Key"
64
+ placeholder="Enter key..."
65
+ error="Invalid API key"
66
+ />
67
+ )
68
+
69
+ expect(screen.queryByText("Invalid API key")).not.toBeInTheDocument()
70
+ })
71
+
72
+ it("shows (optional) when optionalText is provided", () => {
73
+ renderWithTheme(
74
+ <CopyField
75
+ label="API Key"
76
+ placeholder="Enter key..."
77
+ optionalText="(optional)"
78
+ />
79
+ )
80
+
81
+ expect(screen.getByText("(optional)")).toBeInTheDocument()
82
+ })
83
+
84
+ it("renders input with placeholder", () => {
85
+ renderWithTheme(<CopyField label="API Key" placeholder="Enter key..." />)
86
+
87
+ expect(screen.getByPlaceholderText("Enter key...")).toBeInTheDocument()
88
+ })
89
+
90
+ it("renders copy button", () => {
91
+ renderWithTheme(<CopyField placeholder="Enter key..." />)
92
+
93
+ expect(screen.getByText("Copy")).toBeInTheDocument()
94
+ })
95
+
96
+ it("accepts user input", async () => {
97
+ const user = userEvent.setup()
98
+
99
+ renderWithTheme(<CopyField label="API Key" placeholder="Enter key..." />)
100
+
101
+ const input = screen.getByPlaceholderText("Enter key...")
102
+ await user.type(input, "test-api-key")
103
+
104
+ expect(input).toHaveValue("test-api-key")
105
+ })
106
+
107
+ it("renders custom copy button label", () => {
108
+ renderWithTheme(
109
+ <CopyField placeholder="Enter key..." copyButtonLabel="Copy Key" />
110
+ )
111
+
112
+ expect(screen.getByText("Copy Key")).toBeInTheDocument()
113
+ })
114
+ })
115
+
116
+ describe("when using controlled mode", () => {
117
+ it("updates value when parent updates", async () => {
118
+ const user = userEvent.setup()
119
+
120
+ function ControlledCopyField() {
121
+ const [value, setValue] = React.useState("")
122
+
123
+ return (
124
+ <CopyField
125
+ label="API Key"
126
+ value={value}
127
+ onValueChange={(newValue) => setValue(newValue)}
128
+ placeholder="Enter key..."
129
+ />
130
+ )
131
+ }
132
+
133
+ renderWithTheme(<ControlledCopyField />)
134
+
135
+ const input = screen.getByPlaceholderText("Enter key...")
136
+ await user.type(input, "test-key")
137
+
138
+ expect(input).toHaveValue("test-key")
139
+ })
140
+
141
+ it("calls onValueChange when value changes", async () => {
142
+ const user = userEvent.setup()
143
+ const onValueChange = vi.fn()
144
+
145
+ renderWithTheme(
146
+ <CopyField
147
+ label="API Key"
148
+ value=""
149
+ onValueChange={onValueChange}
150
+ placeholder="Enter key..."
151
+ />
152
+ )
153
+
154
+ const input = screen.getByPlaceholderText("Enter key...")
155
+ await user.type(input, "a")
156
+
157
+ expect(onValueChange).toHaveBeenCalledWith("a")
158
+ })
159
+ })
160
+
161
+ describe("when disabled", () => {
162
+ it("disables the input", () => {
163
+ renderWithTheme(
164
+ <CopyField
165
+ label="API Key"
166
+ placeholder="Enter key..."
167
+ editable={false}
168
+ />
169
+ )
170
+
171
+ expect(screen.getByPlaceholderText("Enter key...")).toBeDisabled()
172
+ })
173
+
174
+ it("disables the copy button", () => {
175
+ renderWithTheme(
176
+ <CopyField
177
+ label="API Key"
178
+ placeholder="Enter key..."
179
+ value="test-key"
180
+ editable={false}
181
+ />
182
+ )
183
+
184
+ const buttons = screen.getAllByRole("button", { name: /copy/i })
185
+ expect(buttons[buttons.length - 1]).toBeDisabled()
186
+ })
187
+
188
+ it("does not accept user input when disabled", async () => {
189
+ const user = userEvent.setup()
190
+
191
+ renderWithTheme(
192
+ <CopyField
193
+ label="API Key"
194
+ placeholder="Enter key..."
195
+ editable={false}
196
+ />
197
+ )
198
+
199
+ const input = screen.getByPlaceholderText("Enter key...")
200
+ await user.type(input, "test")
201
+
202
+ expect(input).toHaveValue("")
203
+ })
204
+ })
205
+
206
+ describe("copy button behavior", () => {
207
+ it("disables copy button when field is empty", () => {
208
+ renderWithTheme(<CopyField placeholder="Enter key..." />)
209
+
210
+ const buttons = screen.getAllByRole("button", { name: /copy/i })
211
+ expect(buttons[buttons.length - 1]).toBeDisabled()
212
+ })
213
+
214
+ it("enables copy button when field has text", async () => {
215
+ const user = userEvent.setup()
216
+ renderWithTheme(<CopyField placeholder="Enter key..." />)
217
+
218
+ const input = screen.getByPlaceholderText("Enter key...")
219
+ await user.type(input, "test")
220
+
221
+ const buttons = screen.getAllByRole("button", { name: /copy/i })
222
+ expect(buttons[buttons.length - 1]).not.toBeDisabled()
223
+ })
224
+
225
+ it("copies text to clipboard when copy button is clicked", async () => {
226
+ const user = userEvent.setup()
227
+ const onCopy = vi.fn()
228
+
229
+ renderWithTheme(
230
+ <CopyField
231
+ placeholder="Enter key..."
232
+ value="test-api-key"
233
+ onCopy={onCopy}
234
+ onValueChange={() => {}}
235
+ />
236
+ )
237
+
238
+ const buttons = screen.getAllByRole("button", { name: /copy/i })
239
+ const copyButton = buttons[buttons.length - 1]
240
+ await user.click(copyButton)
241
+
242
+ expect(Clipboard.setString).toHaveBeenCalledWith("test-api-key")
243
+ expect(onCopy).toHaveBeenCalledWith("test-api-key")
244
+ })
245
+
246
+ it("does not copy when button is disabled", async () => {
247
+ const user = userEvent.setup()
248
+ const onCopy = vi.fn()
249
+
250
+ renderWithTheme(
251
+ <CopyField
252
+ placeholder="Enter key..."
253
+ value=""
254
+ onCopy={onCopy}
255
+ onValueChange={() => {}}
256
+ />
257
+ )
258
+
259
+ const buttons = screen.getAllByRole("button", { name: /copy/i })
260
+ const copyButton = buttons[buttons.length - 1]
261
+ await user.click(copyButton)
262
+
263
+ expect(Clipboard.setString).not.toHaveBeenCalled()
264
+ expect(onCopy).not.toHaveBeenCalled()
265
+ })
266
+
267
+ it("shows success icon temporarily after copying", async () => {
268
+ const user = userEvent.setup()
269
+
270
+ renderWithTheme(
271
+ <CopyField
272
+ placeholder="Enter key..."
273
+ value="test-key"
274
+ onValueChange={() => {}}
275
+ />
276
+ )
277
+
278
+ const buttons = screen.getAllByRole("button", { name: /copy/i })
279
+ const copyButton = buttons[buttons.length - 1]
280
+ await user.click(copyButton)
281
+
282
+ expect(Clipboard.setString).toHaveBeenCalledWith("test-key")
283
+ })
284
+ })
285
+
286
+ describe("when using compound component API", () => {
287
+ it("renders compound components", () => {
288
+ renderWithTheme(
289
+ <CopyField.Root>
290
+ <CopyField.Label>API Key</CopyField.Label>
291
+ <CopyField.Field placeholder="Enter key..." />
292
+ <CopyField.Description>Your unique API key</CopyField.Description>
293
+ </CopyField.Root>
294
+ )
295
+
296
+ expect(screen.getByText("API Key")).toBeInTheDocument()
297
+ expect(screen.getByPlaceholderText("Enter key...")).toBeInTheDocument()
298
+ expect(screen.getByText("Your unique API key")).toBeInTheDocument()
299
+ })
300
+
301
+ it("renders error in compound mode", () => {
302
+ renderWithTheme(
303
+ <CopyField.Root>
304
+ <CopyField.Label state="error">API Key</CopyField.Label>
305
+ <CopyField.Field placeholder="Enter key..." state="error" />
306
+ <CopyField.Error>Invalid API key</CopyField.Error>
307
+ </CopyField.Root>
308
+ )
309
+
310
+ expect(screen.getByText("Invalid API key")).toBeInTheDocument()
311
+ })
312
+ })
313
+
314
+ describe("validation states", () => {
315
+ it.each(["default", "error", "success"] as const)(
316
+ "renders %s state without errors",
317
+ (state) => {
318
+ renderWithTheme(
319
+ <CopyField label="API Key" placeholder="Enter key..." state={state} />
320
+ )
321
+
322
+ expect(screen.getByPlaceholderText("Enter key...")).toBeInTheDocument()
323
+ }
324
+ )
325
+
326
+ it("shows error state with error message", () => {
327
+ renderWithTheme(
328
+ <CopyField
329
+ label="API Key"
330
+ placeholder="Enter key..."
331
+ state="error"
332
+ error="Invalid API key"
333
+ />
334
+ )
335
+
336
+ expect(screen.getByText("Invalid API key")).toBeInTheDocument()
337
+ })
338
+ })
339
+
340
+ describe("compound component API with states", () => {
341
+ it("renders error state in compound mode", () => {
342
+ renderWithTheme(
343
+ <CopyField.Root>
344
+ <CopyField.Label state="error">API Key</CopyField.Label>
345
+ <CopyField.Field placeholder="Enter key..." state="error" />
346
+ <CopyField.Error>Invalid API key</CopyField.Error>
347
+ </CopyField.Root>
348
+ )
349
+
350
+ expect(screen.getByText("Invalid API key")).toBeInTheDocument()
351
+ })
352
+
353
+ it("renders success state in compound mode", () => {
354
+ renderWithTheme(
355
+ <CopyField.Root>
356
+ <CopyField.Label state="success">API Key</CopyField.Label>
357
+ <CopyField.Field placeholder="Enter key..." state="success" />
358
+ <CopyField.Description state="success">
359
+ API key verified
360
+ </CopyField.Description>
361
+ </CopyField.Root>
362
+ )
363
+
364
+ expect(screen.getByText("API key verified")).toBeInTheDocument()
365
+ })
366
+ })
367
+
368
+ describe("when using with ref", () => {
369
+ it("forwards ref to input element", () => {
370
+ const ref = React.createRef<TextInput>()
371
+ renderWithTheme(<CopyField ref={ref} label="Test" />)
372
+ expect(ref.current).toBeTruthy()
373
+ })
374
+ })
375
+
376
+ describe("uncontrolled mode with defaultValue", () => {
377
+ it("uses defaultValue as initial value", () => {
378
+ renderWithTheme(
379
+ <CopyField placeholder="Enter key..." defaultValue="initial-value" />
380
+ )
381
+
382
+ const input = screen.getByPlaceholderText("Enter key...")
383
+ expect(input).toHaveValue("initial-value")
384
+ })
385
+
386
+ it("allows changing value from defaultValue", async () => {
387
+ const user = userEvent.setup()
388
+
389
+ renderWithTheme(
390
+ <CopyField placeholder="Enter key..." defaultValue="initial" />
391
+ )
392
+
393
+ const input = screen.getByPlaceholderText("Enter key...")
394
+ await user.clear(input)
395
+ await user.type(input, "new-value")
396
+
397
+ expect(input).toHaveValue("new-value")
398
+ })
399
+ })
400
+
401
+ describe("error handling", () => {
402
+ it("handles clipboard error gracefully", async () => {
403
+ const user = userEvent.setup()
404
+ const consoleError = vi
405
+ .spyOn(console, "error")
406
+ .mockImplementation(() => {})
407
+
408
+ vi.mocked(Clipboard.setString).mockImplementationOnce(() => {
409
+ throw new Error("Clipboard error")
410
+ })
411
+
412
+ renderWithTheme(
413
+ <CopyField
414
+ placeholder="Enter key..."
415
+ value="test-key"
416
+ onValueChange={() => {}}
417
+ />
418
+ )
419
+
420
+ const copyButton = screen.getByText("Copy")
421
+ await user.click(copyButton)
422
+
423
+ expect(consoleError).toHaveBeenCalledWith(
424
+ "Failed to copy text:",
425
+ expect.any(Error)
426
+ )
427
+
428
+ consoleError.mockRestore()
429
+ })
430
+ })
431
+ })
@@ -0,0 +1,156 @@
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 { InputError } from "../../atoms/Input/InputError"
6
+ import { InputLabel } from "../../atoms/Input/InputLabel"
7
+ import { InputDescription } from "../../atoms/Input/InputDescription"
8
+ import { CopyFieldInput } from "./CopyFieldInput"
9
+
10
+ const parseTokenValue = (value: string): number => parseFloat(value)
11
+
12
+ const StyledRoot = styled(View)(({ theme }) => {
13
+ const { spacing } = theme.tokens.components.inputs
14
+
15
+ return {
16
+ gap: parseTokenValue(spacing.gap)
17
+ }
18
+ })
19
+
20
+ type CopyFieldOwnProps = {
21
+ label?: string
22
+ description?: string
23
+ error?: string
24
+ state?: InputState
25
+ optionalText?: string
26
+ onCopy?: (value: string) => void
27
+ onValueChange?: (value: string) => void
28
+ copyButtonLabel?: string
29
+ }
30
+
31
+ export type CopyFieldProps = CopyFieldOwnProps &
32
+ Omit<InputFieldProps, keyof CopyFieldOwnProps> &
33
+ Omit<ViewProps, keyof CopyFieldOwnProps>
34
+
35
+ /**
36
+ * Copy field component with a copy button that copies the input value to clipboard.
37
+ *
38
+ * **Recommended: Use with useCopyField hook:**
39
+ * @example
40
+ * ```tsx
41
+ * import { CopyField, useCopyField } from '@butternutbox/pawprint-native'
42
+ *
43
+ * const copyFieldProps = useCopyField({
44
+ * initialValue: "",
45
+ * onCopy: (value) => console.log('Copied:', value)
46
+ * })
47
+ *
48
+ * <CopyField
49
+ * {...copyFieldProps}
50
+ * label="API Key"
51
+ * placeholder="Enter API key..."
52
+ * />
53
+ * ```
54
+ *
55
+ * **Simple Props API (without hook):**
56
+ * @example
57
+ * ```tsx
58
+ * const [apiKey, setApiKey] = useState("")
59
+ * <CopyField
60
+ * label="API Key"
61
+ * placeholder="Enter API key..."
62
+ * value={apiKey}
63
+ * onValueChange={(value) => setApiKey(value)}
64
+ * onCopy={(value) => console.log('Copied:', value)}
65
+ * />
66
+ * ```
67
+ *
68
+ * **Compound Component API:**
69
+ * @example
70
+ * <CopyField.Root>
71
+ * <CopyField.Label optionalText="(optional)">API Key</CopyField.Label>
72
+ * <CopyField.Field
73
+ * placeholder="Enter API key..."
74
+ * onCopy={(value) => console.log('Copied:', value)}
75
+ * />
76
+ * <CopyField.Description>Your unique API key</CopyField.Description>
77
+ * <CopyField.Error>API key is required</CopyField.Error>
78
+ * </CopyField.Root>
79
+ *
80
+ * @param {string} [label] - Label text (props API)
81
+ * @param {string} [description] - Description/help text (props API)
82
+ * @param {string} [error] - Error message to display (props API, does not affect visual state - use state prop for that)
83
+ * @param {InputState} [state] - Visual state of the input: 'default', 'error', or 'success' (props API)
84
+ * @param {string} [optionalText] - Optional text to display next to label (props API)
85
+ * @param {function} [onCopy] - Copy button press handler (receives the copied value)
86
+ * @param {function} [onValueChange] - Value change handler (receives string value)
87
+ * @param {string} [copyButtonLabel="Copy"] - Label for the copy button
88
+ * @param {string} [value] - Controlled value
89
+ * @param {string} [defaultValue] - Default value for uncontrolled mode
90
+ * @param {function} [onChangeText] - Change event handler
91
+ * @param {boolean} [editable] - Controls whether the input is editable
92
+ */
93
+ const CopyFieldRoot = React.forwardRef<View, CopyFieldProps>(
94
+ (
95
+ {
96
+ label,
97
+ description,
98
+ error,
99
+ state = "default",
100
+ optionalText,
101
+ onCopy,
102
+ onValueChange,
103
+ copyButtonLabel,
104
+ value,
105
+ children,
106
+ ...inputFieldProps
107
+ },
108
+ ref
109
+ ) => {
110
+ if (children) {
111
+ return <StyledRoot ref={ref}>{children}</StyledRoot>
112
+ }
113
+
114
+ return (
115
+ <StyledRoot ref={ref}>
116
+ {label && (
117
+ <InputLabel optionalText={optionalText} state={state}>
118
+ {label}
119
+ </InputLabel>
120
+ )}
121
+ <CopyFieldInput
122
+ state={state}
123
+ value={value}
124
+ onCopy={onCopy}
125
+ onValueChange={onValueChange}
126
+ copyButtonLabel={copyButtonLabel}
127
+ {...inputFieldProps}
128
+ />
129
+ {description && (
130
+ <InputDescription state={state}>{description}</InputDescription>
131
+ )}
132
+ {error && state === "error" && <InputError>{error}</InputError>}
133
+ </StyledRoot>
134
+ )
135
+ }
136
+ )
137
+
138
+ CopyFieldRoot.displayName = "CopyField"
139
+
140
+ type CopyFieldComponent = React.ForwardRefExoticComponent<
141
+ CopyFieldProps & React.RefAttributes<View>
142
+ > & {
143
+ Root: typeof StyledRoot
144
+ Label: typeof InputLabel
145
+ Field: typeof CopyFieldInput
146
+ Description: typeof InputDescription
147
+ Error: typeof InputError
148
+ }
149
+
150
+ export const CopyField = Object.assign(CopyFieldRoot, {
151
+ Root: StyledRoot,
152
+ Label: InputLabel,
153
+ Field: CopyFieldInput,
154
+ Description: InputDescription,
155
+ Error: InputError
156
+ }) as CopyFieldComponent
@@ -0,0 +1,127 @@
1
+ import React from "react"
2
+ import { Alert, Clipboard, View, TextInput } from "react-native"
3
+ import { InputField, type InputFieldProps } from "../../atoms/Input/InputField"
4
+ import { Button } from "../../atoms/Button"
5
+ import { Icon } from "../../atoms/Icon"
6
+ import { CheckCircle } from "@butternutbox/pawprint-icons/core"
7
+
8
+ type CopyFieldInputOwnProps = {
9
+ onCopy?: (value: string) => void
10
+ onValueChange?: (value: string) => void
11
+ copyButtonLabel?: string
12
+ }
13
+
14
+ type CopyFieldInputProps = CopyFieldInputOwnProps &
15
+ Omit<InputFieldProps, keyof CopyFieldInputOwnProps>
16
+
17
+ export const CopyFieldInput = React.forwardRef<TextInput, CopyFieldInputProps>(
18
+ (
19
+ {
20
+ onCopy,
21
+ onValueChange,
22
+ value,
23
+ state,
24
+ copyButtonLabel = "Copy",
25
+ editable,
26
+ defaultValue,
27
+ ...inputFieldProps
28
+ },
29
+ ref
30
+ ) => {
31
+ const [internalValue, setInternalValue] = React.useState(
32
+ typeof defaultValue === "string" ? defaultValue : ""
33
+ )
34
+ const [copied, setCopied] = React.useState(false)
35
+ const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(
36
+ undefined
37
+ )
38
+
39
+ React.useEffect(() => {
40
+ if (defaultValue !== undefined && value === undefined) {
41
+ setInternalValue(typeof defaultValue === "string" ? defaultValue : "")
42
+ }
43
+ }, [defaultValue, value])
44
+
45
+ React.useEffect(() => {
46
+ return () => {
47
+ if (timeoutRef.current) {
48
+ clearTimeout(timeoutRef.current)
49
+ }
50
+ }
51
+ }, [])
52
+
53
+ const handleValueChange = (newValue: string) => {
54
+ setInternalValue(newValue)
55
+ onValueChange?.(newValue)
56
+ }
57
+
58
+ const currentValue = value !== undefined ? value : internalValue
59
+
60
+ const hasText =
61
+ typeof currentValue === "string"
62
+ ? currentValue.length > 0
63
+ : Boolean(currentValue)
64
+
65
+ const isDisabled = editable === false
66
+
67
+ const handleCopy = async () => {
68
+ if (!hasText || isDisabled) return
69
+
70
+ const textToCopy =
71
+ typeof currentValue === "string" ? currentValue : String(currentValue)
72
+
73
+ try {
74
+ Clipboard.setString(textToCopy)
75
+ onCopy?.(textToCopy)
76
+
77
+ setCopied(true)
78
+
79
+ if (timeoutRef.current) {
80
+ clearTimeout(timeoutRef.current)
81
+ }
82
+
83
+ timeoutRef.current = setTimeout(() => {
84
+ setCopied(false)
85
+ }, 2000)
86
+ } catch (err) {
87
+ console.error("Failed to copy text:", err)
88
+ Alert.alert("Error", "Failed to copy text to clipboard")
89
+ }
90
+ }
91
+
92
+ const copyButton = (
93
+ <View style={{ justifyContent: "center" }}>
94
+ <Button
95
+ variant="filled"
96
+ colour="primary"
97
+ size="sm"
98
+ onPress={handleCopy}
99
+ disabled={!hasText || isDisabled}
100
+ startIcon={
101
+ copied ? (
102
+ <Icon icon={CheckCircle} size="sm" colour="alt" />
103
+ ) : undefined
104
+ }
105
+ >
106
+ {copyButtonLabel}
107
+ </Button>
108
+ </View>
109
+ )
110
+
111
+ return (
112
+ <InputField
113
+ ref={ref}
114
+ {...inputFieldProps}
115
+ {...(state !== undefined && { state })}
116
+ value={value}
117
+ defaultValue={defaultValue}
118
+ onChangeText={handleValueChange}
119
+ editable={editable}
120
+ hideStateIcons
121
+ actionIcon={copyButton}
122
+ />
123
+ )
124
+ }
125
+ )
126
+
127
+ CopyFieldInput.displayName = "CopyField.Field"