@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.
- package/.turbo/turbo-build.log +15 -15
- package/CHANGELOG.md +30 -0
- package/COMPONENT_GUIDELINES.md +111 -4
- package/dist/index.cjs +12413 -1459
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1111 -13
- package/dist/index.d.ts +1111 -13
- package/dist/index.js +12365 -1457
- package/dist/index.js.map +1 -1
- package/package.json +29 -11
- package/src/__mocks__/asset-stub.ts +1 -0
- package/src/__mocks__/emotion-native.tsx +18 -0
- package/src/__mocks__/react-native-gesture-handler.tsx +41 -0
- package/src/__mocks__/react-native-reanimated.tsx +79 -0
- package/src/__mocks__/react-native-safe-area-context.tsx +6 -0
- package/src/__mocks__/react-native-svg.tsx +27 -0
- package/src/__mocks__/react-native-worklets.tsx +11 -0
- package/src/__mocks__/react-native.tsx +338 -0
- package/src/__mocks__/rn-primitives/avatar.tsx +24 -0
- package/src/__mocks__/rn-primitives/checkbox.tsx +19 -0
- package/src/__mocks__/rn-primitives/select.tsx +116 -0
- package/src/__mocks__/rn-primitives/slider.tsx +40 -0
- package/src/__mocks__/rn-primitives/slot.tsx +30 -0
- package/src/__mocks__/rn-primitives/switch.tsx +24 -0
- package/src/__mocks__/rn-primitives/toggle.tsx +16 -0
- package/src/components/atoms/Avatar/Avatar.stories.tsx +57 -49
- package/src/components/atoms/Avatar/Avatar.test.tsx +269 -0
- package/src/components/atoms/Avatar/Avatar.tsx +68 -22
- package/src/components/atoms/Avatar/index.ts +1 -6
- package/src/components/atoms/Badge/Badge.stories.tsx +5 -29
- package/src/components/atoms/Badge/Badge.test.tsx +90 -0
- package/src/components/atoms/Button/Button.test.tsx +123 -0
- package/src/components/atoms/Button/Button.tsx +1 -1
- package/src/components/atoms/CarouselControls/CarouselControls.stories.tsx +217 -0
- package/src/components/atoms/CarouselControls/CarouselControls.tsx +127 -0
- package/src/components/atoms/CarouselControls/index.ts +2 -0
- package/src/components/atoms/Hint/Hint.test.tsx +36 -0
- package/src/components/atoms/Icon/Icon.test.tsx +98 -0
- package/src/components/atoms/Icon/Icon.tsx +5 -1
- package/src/components/atoms/IconButton/IconButton.test.tsx +101 -0
- package/src/components/atoms/Illustration/Illustration.stories.tsx +2 -2
- package/src/components/atoms/Illustration/Illustration.test.tsx +55 -0
- package/src/components/atoms/Illustration/Illustration.tsx +3 -3
- package/src/components/atoms/Input/Input.stories.tsx +129 -86
- package/src/components/atoms/Input/Input.test.tsx +306 -0
- package/src/components/atoms/Input/Input.tsx +9 -1
- package/src/components/atoms/Input/InputField.tsx +226 -74
- package/src/components/atoms/Link/Link.test.tsx +89 -0
- package/src/components/atoms/Link/Link.tsx +7 -6
- package/src/components/atoms/Logo/Logo.registry.ts +30 -5
- package/src/components/atoms/Logo/Logo.stories.tsx +108 -0
- package/src/components/atoms/Logo/Logo.test.tsx +56 -0
- package/src/components/atoms/Logo/assets/BCorp.tsx +113 -0
- package/src/components/atoms/Logo/assets/ButternutFavicon.tsx +33 -0
- package/src/components/atoms/Logo/assets/ButternutPrimary.tsx +294 -0
- package/src/components/atoms/Logo/assets/ButternutTabbedBottom.tsx +294 -0
- package/src/components/atoms/Logo/assets/ButternutTabbedTop.tsx +294 -0
- package/src/components/atoms/Logo/assets/ButternutWordmark.tsx +274 -0
- package/src/components/atoms/Logo/assets/PsiBufetFavicon.tsx +45 -0
- package/src/components/atoms/Logo/assets/PsiBufetPrimary.tsx +218 -0
- package/src/components/atoms/Logo/assets/PsiBufetTabbedBottom.tsx +218 -0
- package/src/components/atoms/Logo/assets/PsiBufetTabbedTop.tsx +218 -0
- package/src/components/atoms/Logo/assets/PsiBufetWordmark.tsx +195 -0
- package/src/components/atoms/Logo/assets/index.ts +11 -0
- package/src/components/atoms/NumberInput/NumberInput.stories.tsx +183 -0
- package/src/components/atoms/NumberInput/NumberInput.test.tsx +261 -0
- package/src/components/atoms/NumberInput/NumberInput.tsx +129 -0
- package/src/components/atoms/NumberInput/NumberInputField.tsx +77 -0
- package/src/components/atoms/NumberInput/index.ts +4 -0
- package/src/components/atoms/Spinner/Spinner.test.tsx +46 -0
- package/src/components/atoms/Spinner/Spinner.tsx +14 -5
- package/src/components/atoms/Switch/Switch.test.tsx +92 -0
- package/src/components/atoms/Switch/Switch.tsx +28 -17
- package/src/components/atoms/Tag/Tag.test.tsx +70 -0
- package/src/components/atoms/TextArea/TextArea.stories.tsx +303 -0
- package/src/components/atoms/TextArea/TextArea.test.tsx +416 -0
- package/src/components/atoms/TextArea/TextArea.tsx +171 -0
- package/src/components/atoms/TextArea/TextAreaField.tsx +304 -0
- package/src/components/atoms/TextArea/TextAreaLabel.tsx +103 -0
- package/src/components/atoms/TextArea/index.ts +6 -0
- package/src/components/atoms/Typography/Typography.test.tsx +94 -0
- package/src/components/atoms/index.ts +3 -0
- package/src/components/molecules/Accordion/Accordion.stories.tsx +177 -0
- package/src/components/molecules/Accordion/Accordion.test.tsx +185 -0
- package/src/components/molecules/Accordion/Accordion.tsx +284 -0
- package/src/components/molecules/Accordion/index.ts +6 -0
- package/src/components/molecules/Animated/Animated.stories.tsx +254 -0
- package/src/components/molecules/Animated/Animated.tsx +283 -0
- package/src/components/molecules/Animated/index.ts +10 -0
- package/src/components/molecules/ButtonDock/ButtonDock.stories.tsx +44 -25
- package/src/components/molecules/ButtonDock/ButtonDock.test.tsx +83 -0
- package/src/components/molecules/ButtonDock/ButtonDock.tsx +16 -13
- package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +48 -29
- package/src/components/molecules/ButtonGroup/ButtonGroup.test.tsx +73 -0
- package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +25 -3
- package/src/components/molecules/Checkbox/Checkbox.stories.tsx +72 -0
- package/src/components/molecules/Checkbox/Checkbox.test.tsx +117 -0
- package/src/components/molecules/Checkbox/Checkbox.tsx +101 -95
- package/src/components/molecules/CopyField/CopyField.stories.tsx +313 -0
- package/src/components/molecules/CopyField/CopyField.test.tsx +431 -0
- package/src/components/molecules/CopyField/CopyField.tsx +156 -0
- package/src/components/molecules/CopyField/CopyFieldInput.tsx +127 -0
- package/src/components/molecules/CopyField/hooks/index.ts +1 -0
- package/src/components/molecules/CopyField/hooks/useCopyField.ts +25 -0
- package/src/components/molecules/CopyField/index.ts +4 -0
- package/src/components/molecules/DatePicker/DatePicker.stories.tsx +298 -0
- package/src/components/molecules/DatePicker/DatePicker.test.tsx +201 -0
- package/src/components/molecules/DatePicker/DatePicker.tsx +590 -0
- package/src/components/molecules/DatePicker/index.ts +2 -0
- package/src/components/molecules/Drawer/Drawer.stories.tsx +285 -0
- package/src/components/molecules/Drawer/Drawer.test.tsx +180 -0
- package/src/components/molecules/Drawer/Drawer.tsx +187 -0
- package/src/components/molecules/Drawer/DrawerBody.tsx +80 -0
- package/src/components/molecules/Drawer/DrawerClose.tsx +76 -0
- package/src/components/molecules/Drawer/DrawerContent.tsx +339 -0
- package/src/components/molecules/Drawer/DrawerContext.ts +19 -0
- package/src/components/molecules/Drawer/DrawerDescription.tsx +31 -0
- package/src/components/molecules/Drawer/DrawerDragContext.ts +11 -0
- package/src/components/molecules/Drawer/DrawerFooter.tsx +49 -0
- package/src/components/molecules/Drawer/DrawerFooterContext.ts +6 -0
- package/src/components/molecules/Drawer/DrawerGrabber.tsx +62 -0
- package/src/components/molecules/Drawer/DrawerHeader.tsx +244 -0
- package/src/components/molecules/Drawer/DrawerHeaderContext.ts +13 -0
- package/src/components/molecules/Drawer/DrawerOverlay.tsx +53 -0
- package/src/components/molecules/Drawer/DrawerTitle.tsx +32 -0
- package/src/components/molecules/Drawer/index.ts +12 -0
- package/src/components/molecules/FilterTab/FilterTab.stories.tsx +210 -0
- package/src/components/molecules/FilterTab/FilterTab.tsx +310 -0
- package/src/components/molecules/FilterTab/index.ts +2 -0
- package/src/components/molecules/MessageCard/MessageCard.stories.tsx +169 -0
- package/src/components/molecules/MessageCard/MessageCard.tsx +362 -0
- package/src/components/molecules/MessageCard/index.ts +10 -0
- package/src/components/molecules/Notification/Notification.stories.tsx +219 -0
- package/src/components/molecules/Notification/Notification.tsx +426 -0
- package/src/components/molecules/Notification/index.ts +2 -0
- package/src/components/molecules/NumberField/NumberField.stories.tsx +231 -0
- package/src/components/molecules/NumberField/NumberField.tsx +186 -0
- package/src/components/molecules/NumberField/NumberFieldInput.tsx +287 -0
- package/src/components/molecules/NumberField/index.ts +2 -0
- package/src/components/molecules/PasswordField/PasswordField.stories.tsx +362 -0
- package/src/components/molecules/PasswordField/PasswordField.test.tsx +369 -0
- package/src/components/molecules/PasswordField/PasswordField.tsx +194 -0
- package/src/components/molecules/PasswordField/PasswordFieldError.tsx +53 -0
- package/src/components/molecules/PasswordField/PasswordFieldInput.tsx +73 -0
- package/src/components/molecules/PasswordField/PasswordFieldRequirements.tsx +95 -0
- package/src/components/molecules/PasswordField/hooks/index.ts +2 -0
- package/src/components/molecules/PasswordField/hooks/usePasswordField.ts +113 -0
- package/src/components/molecules/PasswordField/index.ts +10 -0
- package/src/components/molecules/PictureSelector/PictureSelector.stories.tsx +204 -0
- package/src/components/molecules/PictureSelector/PictureSelector.tsx +335 -0
- package/src/components/molecules/PictureSelector/index.ts +5 -0
- package/src/components/molecules/Progress/Progress.stories.tsx +145 -0
- package/src/components/molecules/Progress/Progress.tsx +184 -0
- package/src/components/molecules/Progress/index.ts +2 -0
- package/src/components/molecules/Radio/Radio.test.tsx +104 -0
- package/src/components/molecules/Radio/Radio.tsx +1 -2
- package/src/components/molecules/SearchField/SearchField.stories.tsx +242 -0
- package/src/components/molecules/SearchField/SearchField.test.tsx +318 -0
- package/src/components/molecules/SearchField/SearchField.tsx +143 -0
- package/src/components/molecules/SearchField/SearchFieldInput.tsx +63 -0
- package/src/components/molecules/SearchField/hooks/index.ts +1 -0
- package/src/components/molecules/SearchField/hooks/useSearchField.ts +56 -0
- package/src/components/molecules/SearchField/index.ts +4 -0
- package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +31 -8
- package/src/components/molecules/SegmentedControl/SegmentedControl.test.tsx +141 -0
- package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +237 -23
- package/src/components/molecules/SelectField/SelectField.stories.tsx +320 -0
- package/src/components/molecules/SelectField/SelectField.test.tsx +254 -0
- package/src/components/molecules/SelectField/SelectField.tsx +236 -0
- package/src/components/molecules/SelectField/SelectFieldContent.tsx +85 -0
- package/src/components/molecules/SelectField/SelectFieldItem.tsx +133 -0
- package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +170 -0
- package/src/components/molecules/SelectField/SelectFieldValue.tsx +31 -0
- package/src/components/molecules/SelectField/hooks/index.ts +2 -0
- package/src/components/molecules/SelectField/hooks/useSelectField.ts +84 -0
- package/src/components/molecules/SelectField/index.ts +10 -0
- package/src/components/molecules/Slider/Slider.test.tsx +102 -0
- package/src/components/molecules/Slider/Slider.tsx +293 -180
- package/src/components/molecules/Tooltip/Tooltip.stories.tsx +168 -0
- package/src/components/molecules/Tooltip/Tooltip.tsx +326 -0
- package/src/components/molecules/Tooltip/index.ts +2 -0
- package/src/components/molecules/index.ts +15 -0
- package/src/test-utils.tsx +20 -0
- package/tsconfig.json +1 -1
- package/tsup.config.ts +16 -2
- package/vitest.config.ts +114 -0
- package/vitest.setup.ts +16 -0
|
@@ -0,0 +1,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"
|