@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,304 @@
1
+ import React, { useState, useRef, useEffect, useCallback } from "react"
2
+ import {
3
+ View,
4
+ TextInput,
5
+ TextInputProps,
6
+ Animated,
7
+ Easing,
8
+ Pressable
9
+ } from "react-native"
10
+ import styled from "@emotion/native"
11
+ import { useTheme } from "@emotion/react"
12
+ import {
13
+ Error as ErrorIcon,
14
+ CheckCircle as SuccessIcon
15
+ } from "@butternutbox/pawprint-icons/core"
16
+ import { Icon } from "../Icon"
17
+ import type { InputState } from "../Input/InputField"
18
+
19
+ type TextAreaFieldOwnProps = {
20
+ leadingIcon?: React.ReactNode
21
+ trailingIcon?: React.ReactNode
22
+ state?: InputState
23
+ hideStateIcons?: boolean
24
+ rows?: number
25
+ }
26
+
27
+ export type TextAreaFieldProps = TextAreaFieldOwnProps &
28
+ Omit<TextInputProps, keyof TextAreaFieldOwnProps>
29
+
30
+ const parseTokenValue = (value: string): number => parseFloat(value)
31
+
32
+ const StyledTextAreaWrapper = styled(Animated.View)<{
33
+ isDisabled: boolean
34
+ }>(({ theme, isDisabled }) => {
35
+ const { spacing, colour, borderRadius, size } = theme.tokens.components.inputs
36
+ const { dimensions } = theme.tokens.semantics
37
+
38
+ return {
39
+ flexDirection: "column",
40
+ gap: parseTokenValue(spacing.field.gap),
41
+ width: "100%",
42
+ minWidth: parseTokenValue(size.field.minWidth),
43
+ minHeight: parseTokenValue(dimensions.sizing["3xl"]),
44
+ backgroundColor: isDisabled
45
+ ? colour.field.background.inactive
46
+ : colour.field.background.default,
47
+ borderRadius: parseTokenValue(borderRadius.field.default),
48
+ position: "relative"
49
+ }
50
+ })
51
+
52
+ const StyledTextArea = styled(TextInput)(({ theme }) => {
53
+ const { colour } = theme.tokens.components.inputs
54
+ return {
55
+ flex: 1,
56
+ minWidth: 0,
57
+ minHeight: 0,
58
+ padding: 0,
59
+ color: colour.field.text.default,
60
+ textAlignVertical: "top",
61
+ // Suppress the browser default focus outline on React Native Web —
62
+ // the focus indicator is rendered on the wrapper instead.
63
+ ...({
64
+ outlineStyle: "none",
65
+ outlineWidth: 0,
66
+ outlineColor: "transparent"
67
+ } as object)
68
+ }
69
+ })
70
+
71
+ const StyledFocusRing = styled(View)({
72
+ position: "absolute",
73
+ pointerEvents: "none"
74
+ })
75
+
76
+ const TextAreaContainer = styled(View)({
77
+ position: "relative",
78
+ width: "100%",
79
+ flex: 1,
80
+ flexDirection: "column"
81
+ })
82
+
83
+ const IconWrapper = styled(View)<{ side: "left" | "right" }>(({ side }) => {
84
+ return {
85
+ position: "absolute",
86
+ top: 0,
87
+ [side]: 0,
88
+ alignItems: "center",
89
+ justifyContent: "flex-start",
90
+ flexShrink: 0,
91
+ zIndex: 1
92
+ }
93
+ })
94
+
95
+ /**
96
+ * TextAreaField component - Multi-line text input field with icons and visual states.
97
+ *
98
+ * @param {React.ReactNode} [leadingIcon] - Icon to display at the start of the textarea (top-left, should be 24px)
99
+ * @param {React.ReactNode} [trailingIcon] - Icon to display at the end of the textarea (top-right, overrides automatic state icons)
100
+ * @param {InputState} [state] - Visual state of the textarea (default, error, success)
101
+ * @param {boolean} [hideStateIcons] - Hide automatic error/success icons
102
+ * @param {number} [rows=2] - Number of visible text rows (used to calculate minHeight)
103
+ */
104
+ export const TextAreaField = React.forwardRef<TextInput, TextAreaFieldProps>(
105
+ (
106
+ {
107
+ leadingIcon,
108
+ trailingIcon,
109
+ state = "default",
110
+ hideStateIcons,
111
+ rows = 2,
112
+ style,
113
+ editable,
114
+ onFocus,
115
+ onBlur,
116
+ ...rest
117
+ },
118
+ ref
119
+ ) => {
120
+ const theme = useTheme()
121
+ const { spacing, colour, borderWidth, borderRadius, size } =
122
+ theme.tokens.components.inputs
123
+ const { dimensions } = theme.tokens.semantics
124
+ const focusBorderColor = theme.tokens.semantics.colour.border.focus
125
+
126
+ const [isFocused, setIsFocused] = useState(false)
127
+
128
+ const internalInputRef = useRef<TextInput>(null)
129
+ const combinedRef = useCallback(
130
+ (node: TextInput | null) => {
131
+ internalInputRef.current = node
132
+ if (typeof ref === "function") {
133
+ ref(node)
134
+ } else if (ref) {
135
+ ;(ref as React.MutableRefObject<TextInput | null>).current = node
136
+ }
137
+ },
138
+ [ref]
139
+ )
140
+
141
+ const isDisabled = editable === false
142
+ const hasState = state === "error" || state === "success"
143
+
144
+ const baseBorderWidth = parseTokenValue(borderWidth.field.default)
145
+ const selectedBorderWidth = parseTokenValue(borderWidth.field.selected)
146
+ const focusBorderWidth = parseTokenValue(borderWidth.field.focus)
147
+ const fieldBorderRadius = parseTokenValue(borderRadius.field.default)
148
+ const baseVerticalPadding = parseTokenValue(spacing.field.verticalPadding)
149
+ const baseHorizontalPadding = parseTokenValue(
150
+ spacing.field.horizontalPadding
151
+ )
152
+ const borderDelta = selectedBorderWidth - baseBorderWidth
153
+
154
+ const borderColor =
155
+ state === "error"
156
+ ? colour.field.border.error
157
+ : state === "success"
158
+ ? colour.field.border.success
159
+ : isFocused
160
+ ? colour.field.border.selected
161
+ : colour.field.border.default
162
+
163
+ const isActiveBorder = isFocused || hasState
164
+ const borderAnim = useRef(
165
+ new Animated.Value(isActiveBorder ? 1 : 0)
166
+ ).current
167
+
168
+ useEffect(() => {
169
+ const animation = Animated.timing(borderAnim, {
170
+ toValue: isActiveBorder ? 1 : 0,
171
+ duration: 50,
172
+ easing: Easing.ease,
173
+ useNativeDriver: false
174
+ })
175
+ animation.start()
176
+ return () => animation.stop()
177
+ }, [isActiveBorder, borderAnim])
178
+
179
+ const animatedBorderWidth = borderAnim.interpolate({
180
+ inputRange: [0, 1],
181
+ outputRange: [baseBorderWidth, selectedBorderWidth]
182
+ })
183
+
184
+ const animatedPaddingVertical = borderAnim.interpolate({
185
+ inputRange: [0, 1],
186
+ outputRange: [baseVerticalPadding, baseVerticalPadding - borderDelta]
187
+ })
188
+
189
+ const animatedPaddingHorizontal = borderAnim.interpolate({
190
+ inputRange: [0, 1],
191
+ outputRange: [baseHorizontalPadding, baseHorizontalPadding - borderDelta]
192
+ })
193
+
194
+ const stateIconMap = {
195
+ error: { icon: ErrorIcon, colour: "error" as const },
196
+ success: { icon: SuccessIcon, colour: "success" as const }
197
+ }
198
+
199
+ const stateIconConfig = state !== "default" ? stateIconMap[state] : null
200
+
201
+ // Trailing icons should be 16px (sm), leading icons 24px (md)
202
+ const effectiveTrailingIcon =
203
+ trailingIcon ||
204
+ (!hideStateIcons && stateIconConfig ? (
205
+ <Icon
206
+ icon={stateIconConfig.icon}
207
+ size="sm"
208
+ colour={stateIconConfig.colour}
209
+ />
210
+ ) : null)
211
+
212
+ const handleFocus = (
213
+ e: Parameters<NonNullable<TextInputProps["onFocus"]>>[0]
214
+ ) => {
215
+ setIsFocused(true)
216
+ onFocus?.(e)
217
+ }
218
+
219
+ const handleBlur = (
220
+ e: Parameters<NonNullable<TextInputProps["onBlur"]>>[0]
221
+ ) => {
222
+ setIsFocused(false)
223
+ onBlur?.(e)
224
+ }
225
+
226
+ const handleWrapperPress = () => {
227
+ internalInputRef.current?.focus()
228
+ }
229
+
230
+ const fieldHeight = parseTokenValue(size.field.height)
231
+ const textAreaMinHeight = fieldHeight
232
+
233
+ // Calculate icon padding using tokens
234
+ const iconGap = parseTokenValue(spacing.field.gap)
235
+ const leadingIconSize = parseTokenValue(dimensions.sizing.xl) // 24px
236
+ const trailingIconSize = parseTokenValue(dimensions.sizing.md) // 16px
237
+ const leadingIconPadding = leadingIconSize + iconGap
238
+ const trailingIconPadding = trailingIconSize + iconGap
239
+
240
+ return (
241
+ <Pressable onPress={handleWrapperPress}>
242
+ <StyledTextAreaWrapper
243
+ isDisabled={isDisabled}
244
+ style={[
245
+ {
246
+ borderColor,
247
+ borderWidth: animatedBorderWidth,
248
+ paddingVertical: animatedPaddingVertical,
249
+ paddingHorizontal: animatedPaddingHorizontal
250
+ }
251
+ ]}
252
+ >
253
+ {isFocused && !hasState && (
254
+ <StyledFocusRing
255
+ style={{
256
+ top: -focusBorderWidth,
257
+ left: -focusBorderWidth,
258
+ right: -focusBorderWidth,
259
+ bottom: -focusBorderWidth,
260
+ borderWidth: focusBorderWidth,
261
+ borderColor: focusBorderColor,
262
+ borderRadius: fieldBorderRadius + focusBorderWidth
263
+ }}
264
+ />
265
+ )}
266
+ <TextAreaContainer>
267
+ {leadingIcon && (
268
+ <IconWrapper side="left">{leadingIcon}</IconWrapper>
269
+ )}
270
+ {effectiveTrailingIcon && (
271
+ <IconWrapper side="right">{effectiveTrailingIcon}</IconWrapper>
272
+ )}
273
+ <View
274
+ style={{
275
+ flex: 1,
276
+ paddingLeft: leadingIcon ? leadingIconPadding : 0,
277
+ paddingRight: effectiveTrailingIcon ? trailingIconPadding : 0
278
+ }}
279
+ >
280
+ <StyledTextArea
281
+ ref={combinedRef}
282
+ style={[
283
+ style,
284
+ {
285
+ minHeight: textAreaMinHeight
286
+ }
287
+ ]}
288
+ placeholderTextColor={colour.field.text.placeholder}
289
+ editable={editable}
290
+ multiline
291
+ numberOfLines={rows}
292
+ onFocus={handleFocus}
293
+ onBlur={handleBlur}
294
+ {...rest}
295
+ />
296
+ </View>
297
+ </TextAreaContainer>
298
+ </StyledTextAreaWrapper>
299
+ </Pressable>
300
+ )
301
+ }
302
+ )
303
+
304
+ TextAreaField.displayName = "TextArea.Field"
@@ -0,0 +1,103 @@
1
+ import React from "react"
2
+ import { View } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import { Typography } from "../Typography"
6
+ import { Icon } from "../Icon"
7
+ import { Error as ErrorIcon } from "@butternutbox/pawprint-icons/core"
8
+ import type { InputState } from "../Input/InputField"
9
+
10
+ type TextAreaLabelOwnProps = {
11
+ optionalText?: string
12
+ state?: InputState
13
+ children: React.ReactNode
14
+ maxLength?: number
15
+ currentLength?: number
16
+ }
17
+
18
+ export type TextAreaLabelProps = TextAreaLabelOwnProps
19
+
20
+ const parseTokenValue = (value: string): number => parseFloat(value)
21
+
22
+ const StyledLabelWrapper = styled(View)({
23
+ flexDirection: "row",
24
+ alignItems: "center",
25
+ justifyContent: "space-between",
26
+ width: "100%"
27
+ })
28
+
29
+ const StyledLabel = styled(View)<{ labelGap: number }>(({ labelGap }) => ({
30
+ flexDirection: "row",
31
+ alignItems: "center",
32
+ gap: labelGap
33
+ }))
34
+
35
+ const StyledCharCountWrapper = styled(View)<{
36
+ charCountGap: number
37
+ charCountPadding: number
38
+ }>(({ charCountGap, charCountPadding }) => ({
39
+ flexDirection: "row",
40
+ alignItems: "center",
41
+ gap: charCountGap,
42
+ paddingLeft: charCountPadding
43
+ }))
44
+
45
+ export const TextAreaLabel = React.forwardRef<View, TextAreaLabelProps>(
46
+ (
47
+ {
48
+ optionalText,
49
+ state = "default",
50
+ children,
51
+ maxLength,
52
+ currentLength,
53
+ ...rest
54
+ },
55
+ ref
56
+ ) => {
57
+ const theme = useTheme()
58
+ const { colour, text, spacing } = theme.tokens.components.inputs
59
+
60
+ const showCharacterCount =
61
+ maxLength !== undefined && currentLength !== undefined
62
+ const isOverLimit =
63
+ currentLength !== undefined &&
64
+ maxLength !== undefined &&
65
+ currentLength > maxLength
66
+
67
+ return (
68
+ <StyledLabelWrapper ref={ref} {...rest}>
69
+ <StyledLabel labelGap={parseTokenValue(spacing.label.gap)}>
70
+ <Typography
71
+ token={text.label}
72
+ color={
73
+ state === "error" ? colour.label.error : colour.label.default
74
+ }
75
+ >
76
+ {children}
77
+ </Typography>
78
+ {optionalText && (
79
+ <Typography token={text.optional} color={colour.label.optional}>
80
+ {optionalText}
81
+ </Typography>
82
+ )}
83
+ </StyledLabel>
84
+ {showCharacterCount && (
85
+ <StyledCharCountWrapper
86
+ charCountGap={parseTokenValue(spacing.field.gap)}
87
+ charCountPadding={parseTokenValue(spacing.description.leftPadding)}
88
+ >
89
+ {isOverLimit && <Icon icon={ErrorIcon} size="sm" colour="error" />}
90
+ <Typography
91
+ token={text.characterCount}
92
+ color={colour.characterCount.default}
93
+ >
94
+ {currentLength}/{maxLength}
95
+ </Typography>
96
+ </StyledCharCountWrapper>
97
+ )}
98
+ </StyledLabelWrapper>
99
+ )
100
+ }
101
+ )
102
+
103
+ TextAreaLabel.displayName = "TextArea.Label"
@@ -0,0 +1,6 @@
1
+ export { TextArea } from "./TextArea"
2
+ export type { TextAreaProps } from "./TextArea"
3
+ export { TextAreaField } from "./TextAreaField"
4
+ export type { TextAreaFieldProps } from "./TextAreaField"
5
+ export { TextAreaLabel } from "./TextAreaLabel"
6
+ export type { TextAreaLabelProps } from "./TextAreaLabel"
@@ -0,0 +1,94 @@
1
+ import React from "react"
2
+ import { screen } from "@testing-library/react"
3
+ import { describe, it, expect } from "vitest"
4
+ import { renderWithTheme } from "../../../test-utils"
5
+ import { Typography } from "./Typography"
6
+
7
+ describe("Typography", () => {
8
+ describe("when component is rendering", () => {
9
+ it("renders children text", () => {
10
+ renderWithTheme(<Typography>Hello world</Typography>)
11
+ expect(screen.getByText("Hello world")).toBeInTheDocument()
12
+ })
13
+
14
+ it("renders with default body variant", () => {
15
+ renderWithTheme(<Typography>Body text</Typography>)
16
+ expect(screen.getByText("Body text")).toBeInTheDocument()
17
+ })
18
+ })
19
+
20
+ describe("when using semantic variants", () => {
21
+ it("renders body variant", () => {
22
+ renderWithTheme(<Typography variant="body">Body</Typography>)
23
+ expect(screen.getByText("Body")).toBeInTheDocument()
24
+ })
25
+
26
+ it("renders heading variant", () => {
27
+ renderWithTheme(<Typography variant="heading">Heading</Typography>)
28
+ expect(screen.getByText("Heading")).toBeInTheDocument()
29
+ })
30
+
31
+ it("renders display variant", () => {
32
+ renderWithTheme(<Typography variant="display">Display</Typography>)
33
+ expect(screen.getByText("Display")).toBeInTheDocument()
34
+ })
35
+ })
36
+
37
+ describe("when using body sizes", () => {
38
+ it.each(["xs", "sm", "md", "lg"] as const)(
39
+ "renders body size %s",
40
+ (size) => {
41
+ renderWithTheme(<Typography size={size}>Size {size}</Typography>)
42
+ expect(screen.getByText(`Size ${size}`)).toBeInTheDocument()
43
+ }
44
+ )
45
+ })
46
+
47
+ describe("when using body weights", () => {
48
+ it.each(["medium", "semiBold", "bold"] as const)(
49
+ "renders weight %s",
50
+ (weight) => {
51
+ renderWithTheme(<Typography weight={weight}>Weight</Typography>)
52
+ expect(screen.getByText("Weight")).toBeInTheDocument()
53
+ }
54
+ )
55
+ })
56
+
57
+ describe("when using token mode", () => {
58
+ it("renders with a token object", () => {
59
+ renderWithTheme(
60
+ <Typography
61
+ token={{
62
+ fontFamily: "IBM Plex Sans Condensed",
63
+ fontWeight: "500",
64
+ fontSize: "16",
65
+ lineHeight: "24"
66
+ }}
67
+ color="#000000"
68
+ >
69
+ Token text
70
+ </Typography>
71
+ )
72
+ expect(screen.getByText("Token text")).toBeInTheDocument()
73
+ })
74
+ })
75
+
76
+ describe("when using common props", () => {
77
+ it("renders with noWrap", () => {
78
+ renderWithTheme(<Typography noWrap>Truncated</Typography>)
79
+ expect(screen.getByText("Truncated")).toBeInTheDocument()
80
+ })
81
+
82
+ it("renders with textTransform", () => {
83
+ renderWithTheme(<Typography textTransform="uppercase">upper</Typography>)
84
+ expect(screen.getByText("upper")).toBeInTheDocument()
85
+ })
86
+
87
+ it("renders with textDecoration", () => {
88
+ renderWithTheme(
89
+ <Typography textDecoration="underline">underlined</Typography>
90
+ )
91
+ expect(screen.getByText("underlined")).toBeInTheDocument()
92
+ })
93
+ })
94
+ })
@@ -1,4 +1,5 @@
1
1
  export * from "./Avatar"
2
+ export * from "./CarouselControls"
2
3
  export * from "./Typography"
3
4
  export * from "./Badge"
4
5
  export * from "./Button"
@@ -9,6 +10,8 @@ export * from "./Illustration"
9
10
  export * from "./Input"
10
11
  export * from "./Link"
11
12
  export * from "./Logo"
13
+ export * from "./NumberInput"
12
14
  export * from "./Spinner"
13
15
  export * from "./Switch"
14
16
  export * from "./Tag"
17
+ export * from "./TextArea"
@@ -0,0 +1,177 @@
1
+ import React from "react"
2
+ import { View, StyleSheet, Text } from "react-native"
3
+ import { Accordion } from "./Accordion"
4
+ import type { AccordionProps } from "./Accordion"
5
+
6
+ export default {
7
+ title: "Molecules/Accordion",
8
+ component: Accordion,
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "A vertically stacked list of items that reveal or hide content when clicked."
14
+ }
15
+ }
16
+ },
17
+ argTypes: {
18
+ size: {
19
+ control: { type: "select" },
20
+ options: ["small", "large"],
21
+ description: "Title size variant"
22
+ },
23
+ multiple: {
24
+ control: { type: "boolean" },
25
+ description: "Allow multiple items open simultaneously"
26
+ },
27
+ disabled: {
28
+ control: { type: "boolean" },
29
+ description: "Disables all items"
30
+ }
31
+ }
32
+ }
33
+
34
+ const faqItems = [
35
+ {
36
+ title: "What ingredients do you use?",
37
+ content:
38
+ "We use 60% human-quality protein paired with fresh vegetables and wholesome grains."
39
+ },
40
+ {
41
+ title: "How is the food delivered?",
42
+ content:
43
+ "Your order is fresh-frozen and delivered straight to your door in an insulated box."
44
+ },
45
+ {
46
+ title: "Is it suitable for puppies?",
47
+ content:
48
+ "Yes! Our recipes are nutritionally complete for all life stages, including puppies over 4 months."
49
+ }
50
+ ]
51
+
52
+ export const Small = () => (
53
+ <Accordion size="small">
54
+ {faqItems.map((item) => (
55
+ <Accordion.Item key={item.title} title={item.title}>
56
+ {item.content}
57
+ </Accordion.Item>
58
+ ))}
59
+ </Accordion>
60
+ )
61
+
62
+ export const Large = () => (
63
+ <Accordion size="large">
64
+ {faqItems.map((item) => (
65
+ <Accordion.Item key={item.title} title={item.title}>
66
+ {item.content}
67
+ </Accordion.Item>
68
+ ))}
69
+ </Accordion>
70
+ )
71
+
72
+ export const Multiple = () => (
73
+ <Accordion multiple>
74
+ {faqItems.map((item) => (
75
+ <Accordion.Item key={item.title} title={item.title}>
76
+ {item.content}
77
+ </Accordion.Item>
78
+ ))}
79
+ </Accordion>
80
+ )
81
+
82
+ export const DefaultOpen = () => (
83
+ <Accordion defaultValue={["ingredients"]}>
84
+ <Accordion.Item value="ingredients" title="What ingredients do you use?">
85
+ We use 60% human-quality protein paired with fresh vegetables and
86
+ wholesome grains.
87
+ </Accordion.Item>
88
+ <Accordion.Item value="delivery" title="How is the food delivered?">
89
+ Your order is fresh-frozen and delivered straight to your door in an
90
+ insulated box.
91
+ </Accordion.Item>
92
+ <Accordion.Item value="puppies" title="Is it suitable for puppies?">
93
+ Yes! Our recipes are nutritionally complete for all life stages, including
94
+ puppies over 4 months.
95
+ </Accordion.Item>
96
+ </Accordion>
97
+ )
98
+
99
+ export const Disabled = () => (
100
+ <Accordion disabled>
101
+ {faqItems.slice(0, 2).map((item) => (
102
+ <Accordion.Item key={item.title} title={item.title}>
103
+ {item.content}
104
+ </Accordion.Item>
105
+ ))}
106
+ </Accordion>
107
+ )
108
+
109
+ export const AllStates = () => (
110
+ <View style={styles.column}>
111
+ <View style={styles.section}>
112
+ <Text style={styles.label}>Default (closed)</Text>
113
+ <Accordion>
114
+ <Accordion.Item title="Accordion title">
115
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
116
+ </Accordion.Item>
117
+ </Accordion>
118
+ </View>
119
+
120
+ <View style={styles.section}>
121
+ <Text style={styles.label}>Open</Text>
122
+ <Accordion defaultValue={["open-item"]}>
123
+ <Accordion.Item value="open-item" title="Accordion title">
124
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
125
+ </Accordion.Item>
126
+ </Accordion>
127
+ </View>
128
+
129
+ <View style={styles.section}>
130
+ <Text style={styles.label}>Multiple items</Text>
131
+ <Accordion>
132
+ <Accordion.Item title="Accordion title one">
133
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
134
+ </Accordion.Item>
135
+ <Accordion.Item title="Accordion title two">
136
+ Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
137
+ </Accordion.Item>
138
+ <Accordion.Item title="Accordion title three">
139
+ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
140
+ </Accordion.Item>
141
+ </Accordion>
142
+ </View>
143
+ </View>
144
+ )
145
+
146
+ export const Playground = (args: AccordionProps) => (
147
+ <Accordion {...args}>
148
+ {faqItems.map((item) => (
149
+ <Accordion.Item key={item.title} title={item.title}>
150
+ {item.content}
151
+ </Accordion.Item>
152
+ ))}
153
+ </Accordion>
154
+ )
155
+ Playground.args = {
156
+ size: "small",
157
+ multiple: false,
158
+ disabled: false
159
+ }
160
+
161
+ const styles = StyleSheet.create({
162
+ column: {
163
+ flexDirection: "column",
164
+ gap: 32
165
+ },
166
+ section: {
167
+ flexDirection: "column",
168
+ gap: 12
169
+ },
170
+ label: {
171
+ fontSize: 12,
172
+ fontWeight: "600",
173
+ textTransform: "uppercase",
174
+ letterSpacing: 1,
175
+ color: "#999"
176
+ }
177
+ })