@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,254 @@
1
+ import React from "react"
2
+ import { View } from "react-native"
3
+ import { screen } from "@testing-library/react"
4
+ import { describe, it, expect, vi } from "vitest"
5
+ import { renderWithTheme } from "../../../test-utils"
6
+ import { SelectField } from "./SelectField"
7
+
8
+ describe("SelectField", () => {
9
+ describe("when using simple props API", () => {
10
+ it("renders label when provided", () => {
11
+ renderWithTheme(
12
+ <SelectField label="Country" placeholder="Select a country">
13
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
14
+ </SelectField>
15
+ )
16
+
17
+ expect(screen.getByText("Country")).toBeInTheDocument()
18
+ })
19
+
20
+ it("renders description when provided", () => {
21
+ renderWithTheme(
22
+ <SelectField
23
+ label="Country"
24
+ placeholder="Select a country"
25
+ description="Choose your country"
26
+ >
27
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
28
+ </SelectField>
29
+ )
30
+
31
+ expect(screen.getByText("Choose your country")).toBeInTheDocument()
32
+ })
33
+
34
+ it("renders error message when error prop and state='error' are provided", () => {
35
+ renderWithTheme(
36
+ <SelectField
37
+ label="Country"
38
+ placeholder="Select a country"
39
+ state="error"
40
+ error="Country is required"
41
+ >
42
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
43
+ </SelectField>
44
+ )
45
+
46
+ expect(screen.getByText("Country is required")).toBeInTheDocument()
47
+ })
48
+
49
+ it("does not render error message when error prop is provided without state='error'", () => {
50
+ renderWithTheme(
51
+ <SelectField
52
+ label="Country"
53
+ placeholder="Select a country"
54
+ error="Country is required"
55
+ >
56
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
57
+ </SelectField>
58
+ )
59
+
60
+ expect(screen.queryByText("Country is required")).not.toBeInTheDocument()
61
+ })
62
+
63
+ it("shows (optional) when optionalText is provided", () => {
64
+ renderWithTheme(
65
+ <SelectField
66
+ label="Country"
67
+ placeholder="Select a country"
68
+ optionalText="(optional)"
69
+ >
70
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
71
+ </SelectField>
72
+ )
73
+
74
+ expect(screen.getByText("(optional)")).toBeInTheDocument()
75
+ })
76
+
77
+ it("renders the component structure", () => {
78
+ renderWithTheme(
79
+ <SelectField label="Country" placeholder="Select a country">
80
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
81
+ </SelectField>
82
+ )
83
+
84
+ expect(screen.getByText("Country")).toBeInTheDocument()
85
+ })
86
+ })
87
+
88
+ describe("when using controlled mode", () => {
89
+ it("renders with controlled value", () => {
90
+ const onValueChange = vi.fn()
91
+
92
+ renderWithTheme(
93
+ <SelectField
94
+ label="Country"
95
+ value="uk"
96
+ onValueChange={onValueChange}
97
+ placeholder="Select a country"
98
+ >
99
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
100
+ <SelectField.Item value="us">United States</SelectField.Item>
101
+ </SelectField>
102
+ )
103
+
104
+ expect(screen.getByText("Country")).toBeInTheDocument()
105
+ })
106
+ })
107
+
108
+ describe("when disabled", () => {
109
+ it("renders disabled state", () => {
110
+ renderWithTheme(
111
+ <SelectField label="Country" placeholder="Select a country" disabled>
112
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
113
+ </SelectField>
114
+ )
115
+
116
+ expect(screen.getByText("Country")).toBeInTheDocument()
117
+ })
118
+ })
119
+
120
+ describe("validation states", () => {
121
+ it("shows error state with error message", () => {
122
+ renderWithTheme(
123
+ <SelectField
124
+ label="Country"
125
+ placeholder="Select a country"
126
+ state="error"
127
+ error="Country is required"
128
+ >
129
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
130
+ </SelectField>
131
+ )
132
+
133
+ expect(screen.getByText("Country is required")).toBeInTheDocument()
134
+ })
135
+
136
+ it("renders success state", () => {
137
+ renderWithTheme(
138
+ <SelectField
139
+ label="Country"
140
+ placeholder="Select a country"
141
+ state="success"
142
+ >
143
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
144
+ </SelectField>
145
+ )
146
+
147
+ expect(screen.getByText("Country")).toBeInTheDocument()
148
+ })
149
+ })
150
+
151
+ describe("when using compound component API", () => {
152
+ it("renders compound components", () => {
153
+ renderWithTheme(
154
+ <SelectField.Root>
155
+ <SelectField.Label>Country</SelectField.Label>
156
+ <SelectField.Trigger>
157
+ <SelectField.Value placeholder="Select a country" />
158
+ </SelectField.Trigger>
159
+ <SelectField.Content>
160
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
161
+ </SelectField.Content>
162
+ <SelectField.Description>Choose your country</SelectField.Description>
163
+ </SelectField.Root>
164
+ )
165
+
166
+ expect(screen.getByText("Country")).toBeInTheDocument()
167
+ expect(screen.getByText("Choose your country")).toBeInTheDocument()
168
+ })
169
+
170
+ it("renders error in compound mode", () => {
171
+ renderWithTheme(
172
+ <SelectField.Root>
173
+ <SelectField.Label state="error">Country</SelectField.Label>
174
+ <SelectField.Trigger state="error">
175
+ <SelectField.Value placeholder="Select a country" />
176
+ </SelectField.Trigger>
177
+ <SelectField.Content>
178
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
179
+ </SelectField.Content>
180
+ <SelectField.Error>Country is required</SelectField.Error>
181
+ </SelectField.Root>
182
+ )
183
+
184
+ expect(screen.getByText("Country is required")).toBeInTheDocument()
185
+ })
186
+ })
187
+
188
+ describe("when using with ref", () => {
189
+ it("forwards ref to root element", () => {
190
+ const ref = React.createRef<View>()
191
+ renderWithTheme(<SelectField ref={ref} label="Test" />)
192
+ expect(ref.current).toBeTruthy()
193
+ })
194
+ })
195
+
196
+ describe("uncontrolled mode with defaultValue", () => {
197
+ it("uses defaultValue as initial value", () => {
198
+ renderWithTheme(
199
+ <SelectField
200
+ label="Country"
201
+ placeholder="Select a country"
202
+ defaultValue="uk"
203
+ >
204
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
205
+ <SelectField.Item value="us">United States</SelectField.Item>
206
+ </SelectField>
207
+ )
208
+
209
+ expect(screen.getByText("United Kingdom")).toBeInTheDocument()
210
+ })
211
+ })
212
+
213
+ describe("with items", () => {
214
+ it("renders items with leadingIcon", () => {
215
+ renderWithTheme(
216
+ <SelectField label="Country" placeholder="Select a country">
217
+ <SelectField.Item
218
+ value="uk"
219
+ leadingIcon={<div data-testid="icon">🇬🇧</div>}
220
+ >
221
+ United Kingdom
222
+ </SelectField.Item>
223
+ </SelectField>
224
+ )
225
+
226
+ expect(screen.getByTestId("icon")).toBeInTheDocument()
227
+ })
228
+
229
+ it("renders items with hintText", () => {
230
+ renderWithTheme(
231
+ <SelectField label="Country" placeholder="Select a country">
232
+ <SelectField.Item value="uk" hintText="Europe">
233
+ United Kingdom
234
+ </SelectField.Item>
235
+ </SelectField>
236
+ )
237
+
238
+ expect(screen.getByText("Europe")).toBeInTheDocument()
239
+ })
240
+
241
+ it("renders disabled items", () => {
242
+ renderWithTheme(
243
+ <SelectField label="Country" placeholder="Select a country">
244
+ <SelectField.Item value="uk" disabled>
245
+ United Kingdom
246
+ </SelectField.Item>
247
+ </SelectField>
248
+ )
249
+
250
+ const item = screen.getByText("United Kingdom")
251
+ expect(item).toBeInTheDocument()
252
+ })
253
+ })
254
+ })
@@ -0,0 +1,236 @@
1
+ import React from "react"
2
+ import { View, ViewProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import * as SelectPrimitive from "@rn-primitives/select"
5
+ import type { Option } from "@rn-primitives/select"
6
+ import type { InputState } from "../../atoms/Input/InputField"
7
+ import { InputError } from "../../atoms/Input/InputError"
8
+ import { InputLabel } from "../../atoms/Input/InputLabel"
9
+ import { InputDescription } from "../../atoms/Input/InputDescription"
10
+ import { SelectFieldTrigger } from "./SelectFieldTrigger"
11
+ import { SelectFieldValue } from "./SelectFieldValue"
12
+ import { SelectFieldContent } from "./SelectFieldContent"
13
+ import { SelectFieldItem } from "./SelectFieldItem"
14
+
15
+ const parseTokenValue = (value: string): number => parseFloat(value)
16
+
17
+ const StyledRoot = styled(View)(({ theme }) => {
18
+ const { spacing } = theme.tokens.components.inputs
19
+
20
+ return {
21
+ gap: parseTokenValue(spacing.gap)
22
+ }
23
+ })
24
+
25
+ type SelectFieldOwnProps<Value = Option | string> = {
26
+ label?: string
27
+ description?: string
28
+ error?: string
29
+ state?: InputState
30
+ optionalText?: string
31
+ placeholder?: string
32
+ leadingIcon?: React.ReactNode
33
+ value?: Value | null
34
+ defaultValue?: Value | null
35
+ onValueChange?: (value: Value | null) => void
36
+ children?: React.ReactNode
37
+ disabled?: boolean
38
+ }
39
+
40
+ export type SelectFieldProps<Value = Option> = SelectFieldOwnProps<Value> &
41
+ Omit<ViewProps, keyof SelectFieldOwnProps<Value>>
42
+
43
+ /**
44
+ * Select field component for choosing a value from a dropdown list.
45
+ * Built on @rn-primitives/select with design system styling.
46
+ * Supports a simple props API, compound component API, and useSelectField hook for state management.
47
+ *
48
+ * **Simple Props API:**
49
+ * @example
50
+ * ```tsx
51
+ * <SelectField
52
+ * label="Country"
53
+ * placeholder="Select a country"
54
+ * description="Choose your country"
55
+ * >
56
+ * <SelectField.Item value="uk">United Kingdom</SelectField.Item>
57
+ * <SelectField.Item value="us">United States</SelectField.Item>
58
+ * <SelectField.Item value="ca">Canada</SelectField.Item>
59
+ * </SelectField>
60
+ * ```
61
+ *
62
+ * **With useSelectField Hook (Recommended):**
63
+ * @example
64
+ * ```tsx
65
+ * const selectProps = useSelectField({
66
+ * validationRule: {
67
+ * test: (value) => value !== null,
68
+ * message: "Please select an option"
69
+ * }
70
+ * })
71
+ *
72
+ * <SelectField
73
+ * {...selectProps}
74
+ * label="Category"
75
+ * placeholder="Select a category"
76
+ * >
77
+ * <SelectField.Item value="food">Food</SelectField.Item>
78
+ * <SelectField.Item value="toys">Toys</SelectField.Item>
79
+ * </SelectField>
80
+ * ```
81
+ *
82
+ * **Compound Component API:**
83
+ * @example
84
+ * ```tsx
85
+ * <SelectField.Root>
86
+ * <SelectField.Label optionalText="(optional)">Country</SelectField.Label>
87
+ * <SelectField.Trigger>
88
+ * <SelectField.Value placeholder="Select a country" />
89
+ * </SelectField.Trigger>
90
+ * <SelectField.Content>
91
+ * <SelectField.Item value="uk">United Kingdom</SelectField.Item>
92
+ * <SelectField.Item value="us">United States</SelectField.Item>
93
+ * </SelectField.Content>
94
+ * <SelectField.Description>Choose your country</SelectField.Description>
95
+ * <SelectField.Error>Country is required</SelectField.Error>
96
+ * </SelectField.Root>
97
+ * ```
98
+ *
99
+ * @param {string} [label] - Label text (props API)
100
+ * @param {string} [description] - Help text below select (props API)
101
+ * @param {string} [error] - Error message (props API)
102
+ * @param {InputState} [state] - Visual state (default, error, success)
103
+ * @param {string} [optionalText] - Optional text to display next to label (props API)
104
+ * @param {string} [placeholder] - Placeholder text when no value is selected
105
+ * @param {React.ReactNode} [leadingIcon] - Icon to display before the trigger text
106
+ * @param {Value | null} [value] - Controlled value
107
+ * @param {Value | null} [defaultValue] - Default value for uncontrolled mode
108
+ * @param {function} [onValueChange] - Value change handler
109
+ * @param {boolean} [disabled] - Disables the select
110
+ */
111
+ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
112
+ (
113
+ {
114
+ label,
115
+ placeholder,
116
+ description,
117
+ error,
118
+ state = "default",
119
+ optionalText,
120
+ leadingIcon,
121
+ children,
122
+ value,
123
+ defaultValue,
124
+ onValueChange,
125
+ disabled,
126
+ ...rest
127
+ },
128
+ ref
129
+ ) => {
130
+ const [isOpen, setIsOpen] = React.useState(false)
131
+ const [internalValue, setInternalValue] = React.useState(
132
+ defaultValue ?? null
133
+ )
134
+
135
+ const isCompound = React.Children.toArray(children).some(
136
+ (child) =>
137
+ React.isValidElement(child) && child.type === SelectFieldContent
138
+ )
139
+
140
+ if (isCompound) {
141
+ return (
142
+ <StyledRoot ref={ref} {...rest}>
143
+ {children}
144
+ </StyledRoot>
145
+ )
146
+ }
147
+
148
+ const handleValueChange = (newValue: Option) => {
149
+ const valueToSet = newValue ?? null
150
+ setInternalValue(valueToSet)
151
+ onValueChange?.(valueToSet)
152
+ }
153
+
154
+ const currentValue = value !== undefined ? value : internalValue
155
+
156
+ return (
157
+ <SelectPrimitive.Root
158
+ value={value ?? undefined}
159
+ defaultValue={defaultValue ?? undefined}
160
+ onValueChange={handleValueChange}
161
+ disabled={disabled}
162
+ onOpenChange={setIsOpen}
163
+ >
164
+ <StyledRoot ref={ref} {...rest}>
165
+ {label && (
166
+ <InputLabel optionalText={optionalText} state={state}>
167
+ {label}
168
+ </InputLabel>
169
+ )}
170
+ <SelectFieldTrigger
171
+ state={state}
172
+ leadingIcon={leadingIcon}
173
+ isOpen={isOpen}
174
+ >
175
+ <SelectFieldValue placeholder={placeholder} />
176
+ </SelectFieldTrigger>
177
+ {description && (
178
+ <InputDescription state={state}>{description}</InputDescription>
179
+ )}
180
+ {error && state === "error" && <InputError>{error}</InputError>}
181
+ </StyledRoot>
182
+ <SelectFieldContent>
183
+ {React.Children.map(children, (child) => {
184
+ if (React.isValidElement(child) && child.type === SelectFieldItem) {
185
+ // Extract value from object if needed
186
+ const selectedValue =
187
+ currentValue &&
188
+ typeof currentValue === "object" &&
189
+ "value" in currentValue
190
+ ? (currentValue as { value: string }).value
191
+ : currentValue
192
+
193
+ const childProps = child.props as { value: string }
194
+ return React.cloneElement(
195
+ child as React.ReactElement<{
196
+ value: string
197
+ isSelected?: boolean
198
+ }>,
199
+ {
200
+ isSelected: childProps.value === selectedValue
201
+ }
202
+ )
203
+ }
204
+ return child
205
+ })}
206
+ </SelectFieldContent>
207
+ </SelectPrimitive.Root>
208
+ )
209
+ }
210
+ )
211
+
212
+ SelectFieldRoot.displayName = "SelectField"
213
+
214
+ type SelectFieldComponent = React.ForwardRefExoticComponent<
215
+ SelectFieldProps<Option | string> & React.RefAttributes<View>
216
+ > & {
217
+ Root: typeof StyledRoot
218
+ Label: typeof InputLabel
219
+ Trigger: typeof SelectFieldTrigger
220
+ Value: typeof SelectFieldValue
221
+ Content: typeof SelectFieldContent
222
+ Item: typeof SelectFieldItem
223
+ Description: typeof InputDescription
224
+ Error: typeof InputError
225
+ }
226
+
227
+ export const SelectField = Object.assign(SelectFieldRoot, {
228
+ Root: StyledRoot,
229
+ Label: InputLabel,
230
+ Trigger: SelectFieldTrigger,
231
+ Value: SelectFieldValue,
232
+ Content: SelectFieldContent,
233
+ Item: SelectFieldItem,
234
+ Description: InputDescription,
235
+ Error: InputError
236
+ }) as SelectFieldComponent
@@ -0,0 +1,85 @@
1
+ import React from "react"
2
+ import { View, ViewProps, ScrollView } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import * as SelectPrimitive from "@rn-primitives/select"
6
+ import { Slot } from "@rn-primitives/slot"
7
+
8
+ type SelectFieldContentProps = {
9
+ children?: React.ReactNode
10
+ } & Omit<ViewProps, "children">
11
+
12
+ const parseTokenValue = (value: string): number => parseFloat(value)
13
+
14
+ // TODO: Replace with dynamic positioning when @rn-primitives/select supports it on React Native
15
+ // These values position the dropdown below and aligned with the trigger
16
+ const DROPDOWN_OFFSET_X = -32 // Align with trigger left edge
17
+ const DROPDOWN_OFFSET_Y = -94 // Position below trigger with gap
18
+
19
+ const StyledContentShadow = styled(View)(({ theme }) => {
20
+ const { dropdown } = theme.tokens.components.dropdownList
21
+ const shadow = dropdown.list.dropshadow.default
22
+
23
+ return {
24
+ borderRadius: parseTokenValue(dropdown.list.borderRadius.default),
25
+ shadowColor: shadow.color,
26
+ shadowOffset: {
27
+ width: parseTokenValue(shadow.offsetX),
28
+ height: parseTokenValue(shadow.offsetY)
29
+ },
30
+ shadowOpacity: 1,
31
+ shadowRadius: parseTokenValue(shadow.blur),
32
+ elevation: 8,
33
+ transform: [
34
+ { translateX: DROPDOWN_OFFSET_X },
35
+ { translateY: DROPDOWN_OFFSET_Y }
36
+ ]
37
+ }
38
+ })
39
+
40
+ const StyledContentInner = styled(View)(({ theme }) => {
41
+ const { dropdown } = theme.tokens.components.dropdownList
42
+ const { colour } = theme.tokens.components.inputs
43
+
44
+ return {
45
+ backgroundColor: colour.field.background.default,
46
+ borderRadius: parseTokenValue(dropdown.list.borderRadius.default),
47
+ minWidth: parseTokenValue(dropdown.list.size.minWidth),
48
+ maxHeight: parseTokenValue(dropdown.list.size.maxHeight),
49
+ overflow: "hidden"
50
+ }
51
+ })
52
+
53
+ export const SelectFieldContent = React.forwardRef<
54
+ View,
55
+ SelectFieldContentProps
56
+ >(({ children, ...rest }, ref) => {
57
+ const theme = useTheme()
58
+ const { spacing } = theme.tokens.components.inputs
59
+
60
+ return (
61
+ <SelectPrimitive.Portal>
62
+ <SelectPrimitive.Content
63
+ side="bottom"
64
+ align="start"
65
+ sideOffset={parseTokenValue(spacing.gap)}
66
+ alignOffset={0}
67
+ asChild
68
+ >
69
+ <Slot ref={ref}>
70
+ <StyledContentShadow {...rest}>
71
+ <StyledContentInner>
72
+ <SelectPrimitive.Viewport>
73
+ <ScrollView showsVerticalScrollIndicator nestedScrollEnabled>
74
+ {children}
75
+ </ScrollView>
76
+ </SelectPrimitive.Viewport>
77
+ </StyledContentInner>
78
+ </StyledContentShadow>
79
+ </Slot>
80
+ </SelectPrimitive.Content>
81
+ </SelectPrimitive.Portal>
82
+ )
83
+ })
84
+
85
+ SelectFieldContent.displayName = "SelectField.Content"
@@ -0,0 +1,133 @@
1
+ import React from "react"
2
+ import { Pressable, View, Text, PressableProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import * as SelectPrimitive from "@rn-primitives/select"
6
+ import { Slot } from "@rn-primitives/slot"
7
+
8
+ type SelectFieldItemOwnProps = {
9
+ value: string
10
+ leadingIcon?: React.ReactNode
11
+ hintText?: string
12
+ disabled?: boolean
13
+ isSelected?: boolean
14
+ }
15
+
16
+ export type SelectFieldItemProps = SelectFieldItemOwnProps &
17
+ Omit<PressableProps, keyof SelectFieldItemOwnProps>
18
+
19
+ const parseTokenValue = (value: string): number => parseFloat(value)
20
+
21
+ const StyledItem = styled(Pressable)<{ disabled?: boolean }>(({ theme }) => {
22
+ const { dropdown } = theme.tokens.components.dropdownList
23
+
24
+ return {
25
+ flexDirection: "row",
26
+ alignItems: "center",
27
+ gap: parseTokenValue(dropdown.listItem.content.gap),
28
+ height: parseTokenValue(dropdown.listItem.size.height),
29
+ paddingVertical: parseTokenValue(dropdown.listItem.spacing.verticalPadding),
30
+ paddingHorizontal: parseTokenValue(
31
+ dropdown.listItem.spacing.horiztonalPadding
32
+ ),
33
+ backgroundColor: dropdown.listItem.colour.background.default
34
+ }
35
+ })
36
+
37
+ const StyledIconWrapper = styled(View)({
38
+ flexShrink: 0,
39
+ alignItems: "center",
40
+ justifyContent: "center"
41
+ })
42
+
43
+ const StyledContentWrapper = styled(View)(({ theme }) => {
44
+ const { dropdown } = theme.tokens.components.dropdownList
45
+
46
+ return {
47
+ flexDirection: "row",
48
+ alignItems: "center",
49
+ gap: parseTokenValue(dropdown.listItem.content.gap),
50
+ flex: 1,
51
+ minWidth: 0
52
+ }
53
+ })
54
+
55
+ const StyledTextWrapper = styled(View)({
56
+ flex: 1,
57
+ minWidth: 0
58
+ })
59
+
60
+ const StyledItemText = styled(Text)(({ theme }) => {
61
+ const { dropdown } = theme.tokens.components.dropdownList
62
+
63
+ return {
64
+ color: dropdown.listItem.colour.text.placeholder.default
65
+ }
66
+ })
67
+
68
+ const StyledHintText = styled(Text)(({ theme }) => {
69
+ const { dropdown } = theme.tokens.components.dropdownList
70
+
71
+ return {
72
+ color: dropdown.listItem.colour.text.hint.default,
73
+ flexShrink: 0
74
+ }
75
+ })
76
+
77
+ export const SelectFieldItem = React.forwardRef<View, SelectFieldItemProps>(
78
+ (
79
+ { value, leadingIcon, hintText, children, disabled, isSelected, ...rest },
80
+ ref
81
+ ) => {
82
+ const theme = useTheme()
83
+ const { dropdown } = theme.tokens.components.dropdownList
84
+ const itemText = children as React.ReactNode
85
+
86
+ const backgroundColor = disabled
87
+ ? dropdown.listItem.colour.background.disabled
88
+ : isSelected
89
+ ? dropdown.listItem.colour.background.hover
90
+ : dropdown.listItem.colour.background.default
91
+
92
+ const textColor = disabled
93
+ ? dropdown.listItem.colour.text.placeholder.disabled
94
+ : dropdown.listItem.colour.text.placeholder.default
95
+
96
+ return (
97
+ <SelectPrimitive.Item
98
+ label={String(children)}
99
+ value={value}
100
+ disabled={disabled}
101
+ asChild
102
+ >
103
+ <Slot ref={ref}>
104
+ <StyledItem disabled={disabled} style={{ backgroundColor }} {...rest}>
105
+ {leadingIcon && (
106
+ <StyledIconWrapper>{leadingIcon}</StyledIconWrapper>
107
+ )}
108
+ <StyledContentWrapper>
109
+ <StyledTextWrapper>
110
+ <StyledItemText style={{ color: textColor }}>
111
+ {itemText}
112
+ </StyledItemText>
113
+ </StyledTextWrapper>
114
+ {hintText && (
115
+ <StyledHintText
116
+ style={{
117
+ color: disabled
118
+ ? dropdown.listItem.colour.text.hint.disabled
119
+ : dropdown.listItem.colour.text.hint.default
120
+ }}
121
+ >
122
+ {hintText}
123
+ </StyledHintText>
124
+ )}
125
+ </StyledContentWrapper>
126
+ </StyledItem>
127
+ </Slot>
128
+ </SelectPrimitive.Item>
129
+ )
130
+ }
131
+ )
132
+
133
+ SelectFieldItem.displayName = "SelectField.Item"