@butternutbox/pawprint-native 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/CHANGELOG.md +30 -0
  3. package/COMPONENT_GUIDELINES.md +111 -4
  4. package/dist/index.cjs +12413 -1459
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +1111 -13
  7. package/dist/index.d.ts +1111 -13
  8. package/dist/index.js +12365 -1457
  9. package/dist/index.js.map +1 -1
  10. package/package.json +29 -11
  11. package/src/__mocks__/asset-stub.ts +1 -0
  12. package/src/__mocks__/emotion-native.tsx +18 -0
  13. package/src/__mocks__/react-native-gesture-handler.tsx +41 -0
  14. package/src/__mocks__/react-native-reanimated.tsx +79 -0
  15. package/src/__mocks__/react-native-safe-area-context.tsx +6 -0
  16. package/src/__mocks__/react-native-svg.tsx +27 -0
  17. package/src/__mocks__/react-native-worklets.tsx +11 -0
  18. package/src/__mocks__/react-native.tsx +338 -0
  19. package/src/__mocks__/rn-primitives/avatar.tsx +24 -0
  20. package/src/__mocks__/rn-primitives/checkbox.tsx +19 -0
  21. package/src/__mocks__/rn-primitives/select.tsx +116 -0
  22. package/src/__mocks__/rn-primitives/slider.tsx +40 -0
  23. package/src/__mocks__/rn-primitives/slot.tsx +30 -0
  24. package/src/__mocks__/rn-primitives/switch.tsx +24 -0
  25. package/src/__mocks__/rn-primitives/toggle.tsx +16 -0
  26. package/src/components/atoms/Avatar/Avatar.stories.tsx +57 -49
  27. package/src/components/atoms/Avatar/Avatar.test.tsx +269 -0
  28. package/src/components/atoms/Avatar/Avatar.tsx +68 -22
  29. package/src/components/atoms/Avatar/index.ts +1 -6
  30. package/src/components/atoms/Badge/Badge.stories.tsx +5 -29
  31. package/src/components/atoms/Badge/Badge.test.tsx +90 -0
  32. package/src/components/atoms/Button/Button.test.tsx +123 -0
  33. package/src/components/atoms/Button/Button.tsx +1 -1
  34. package/src/components/atoms/CarouselControls/CarouselControls.stories.tsx +217 -0
  35. package/src/components/atoms/CarouselControls/CarouselControls.tsx +127 -0
  36. package/src/components/atoms/CarouselControls/index.ts +2 -0
  37. package/src/components/atoms/Hint/Hint.test.tsx +36 -0
  38. package/src/components/atoms/Icon/Icon.test.tsx +98 -0
  39. package/src/components/atoms/Icon/Icon.tsx +5 -1
  40. package/src/components/atoms/IconButton/IconButton.test.tsx +101 -0
  41. package/src/components/atoms/Illustration/Illustration.stories.tsx +2 -2
  42. package/src/components/atoms/Illustration/Illustration.test.tsx +55 -0
  43. package/src/components/atoms/Illustration/Illustration.tsx +3 -3
  44. package/src/components/atoms/Input/Input.stories.tsx +129 -86
  45. package/src/components/atoms/Input/Input.test.tsx +306 -0
  46. package/src/components/atoms/Input/Input.tsx +9 -1
  47. package/src/components/atoms/Input/InputField.tsx +226 -74
  48. package/src/components/atoms/Link/Link.test.tsx +89 -0
  49. package/src/components/atoms/Link/Link.tsx +7 -6
  50. package/src/components/atoms/Logo/Logo.registry.ts +30 -5
  51. package/src/components/atoms/Logo/Logo.stories.tsx +108 -0
  52. package/src/components/atoms/Logo/Logo.test.tsx +56 -0
  53. package/src/components/atoms/Logo/assets/BCorp.tsx +113 -0
  54. package/src/components/atoms/Logo/assets/ButternutFavicon.tsx +33 -0
  55. package/src/components/atoms/Logo/assets/ButternutPrimary.tsx +294 -0
  56. package/src/components/atoms/Logo/assets/ButternutTabbedBottom.tsx +294 -0
  57. package/src/components/atoms/Logo/assets/ButternutTabbedTop.tsx +294 -0
  58. package/src/components/atoms/Logo/assets/ButternutWordmark.tsx +274 -0
  59. package/src/components/atoms/Logo/assets/PsiBufetFavicon.tsx +45 -0
  60. package/src/components/atoms/Logo/assets/PsiBufetPrimary.tsx +218 -0
  61. package/src/components/atoms/Logo/assets/PsiBufetTabbedBottom.tsx +218 -0
  62. package/src/components/atoms/Logo/assets/PsiBufetTabbedTop.tsx +218 -0
  63. package/src/components/atoms/Logo/assets/PsiBufetWordmark.tsx +195 -0
  64. package/src/components/atoms/Logo/assets/index.ts +11 -0
  65. package/src/components/atoms/NumberInput/NumberInput.stories.tsx +183 -0
  66. package/src/components/atoms/NumberInput/NumberInput.test.tsx +261 -0
  67. package/src/components/atoms/NumberInput/NumberInput.tsx +129 -0
  68. package/src/components/atoms/NumberInput/NumberInputField.tsx +77 -0
  69. package/src/components/atoms/NumberInput/index.ts +4 -0
  70. package/src/components/atoms/Spinner/Spinner.test.tsx +46 -0
  71. package/src/components/atoms/Spinner/Spinner.tsx +14 -5
  72. package/src/components/atoms/Switch/Switch.test.tsx +92 -0
  73. package/src/components/atoms/Switch/Switch.tsx +28 -17
  74. package/src/components/atoms/Tag/Tag.test.tsx +70 -0
  75. package/src/components/atoms/TextArea/TextArea.stories.tsx +303 -0
  76. package/src/components/atoms/TextArea/TextArea.test.tsx +416 -0
  77. package/src/components/atoms/TextArea/TextArea.tsx +171 -0
  78. package/src/components/atoms/TextArea/TextAreaField.tsx +304 -0
  79. package/src/components/atoms/TextArea/TextAreaLabel.tsx +103 -0
  80. package/src/components/atoms/TextArea/index.ts +6 -0
  81. package/src/components/atoms/Typography/Typography.test.tsx +94 -0
  82. package/src/components/atoms/index.ts +3 -0
  83. package/src/components/molecules/Accordion/Accordion.stories.tsx +177 -0
  84. package/src/components/molecules/Accordion/Accordion.test.tsx +185 -0
  85. package/src/components/molecules/Accordion/Accordion.tsx +284 -0
  86. package/src/components/molecules/Accordion/index.ts +6 -0
  87. package/src/components/molecules/Animated/Animated.stories.tsx +254 -0
  88. package/src/components/molecules/Animated/Animated.tsx +283 -0
  89. package/src/components/molecules/Animated/index.ts +10 -0
  90. package/src/components/molecules/ButtonDock/ButtonDock.stories.tsx +44 -25
  91. package/src/components/molecules/ButtonDock/ButtonDock.test.tsx +83 -0
  92. package/src/components/molecules/ButtonDock/ButtonDock.tsx +16 -13
  93. package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +48 -29
  94. package/src/components/molecules/ButtonGroup/ButtonGroup.test.tsx +73 -0
  95. package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +25 -3
  96. package/src/components/molecules/Checkbox/Checkbox.stories.tsx +72 -0
  97. package/src/components/molecules/Checkbox/Checkbox.test.tsx +117 -0
  98. package/src/components/molecules/Checkbox/Checkbox.tsx +101 -95
  99. package/src/components/molecules/CopyField/CopyField.stories.tsx +313 -0
  100. package/src/components/molecules/CopyField/CopyField.test.tsx +431 -0
  101. package/src/components/molecules/CopyField/CopyField.tsx +156 -0
  102. package/src/components/molecules/CopyField/CopyFieldInput.tsx +127 -0
  103. package/src/components/molecules/CopyField/hooks/index.ts +1 -0
  104. package/src/components/molecules/CopyField/hooks/useCopyField.ts +25 -0
  105. package/src/components/molecules/CopyField/index.ts +4 -0
  106. package/src/components/molecules/DatePicker/DatePicker.stories.tsx +298 -0
  107. package/src/components/molecules/DatePicker/DatePicker.test.tsx +201 -0
  108. package/src/components/molecules/DatePicker/DatePicker.tsx +590 -0
  109. package/src/components/molecules/DatePicker/index.ts +2 -0
  110. package/src/components/molecules/Drawer/Drawer.stories.tsx +285 -0
  111. package/src/components/molecules/Drawer/Drawer.test.tsx +180 -0
  112. package/src/components/molecules/Drawer/Drawer.tsx +187 -0
  113. package/src/components/molecules/Drawer/DrawerBody.tsx +80 -0
  114. package/src/components/molecules/Drawer/DrawerClose.tsx +76 -0
  115. package/src/components/molecules/Drawer/DrawerContent.tsx +339 -0
  116. package/src/components/molecules/Drawer/DrawerContext.ts +19 -0
  117. package/src/components/molecules/Drawer/DrawerDescription.tsx +31 -0
  118. package/src/components/molecules/Drawer/DrawerDragContext.ts +11 -0
  119. package/src/components/molecules/Drawer/DrawerFooter.tsx +49 -0
  120. package/src/components/molecules/Drawer/DrawerFooterContext.ts +6 -0
  121. package/src/components/molecules/Drawer/DrawerGrabber.tsx +62 -0
  122. package/src/components/molecules/Drawer/DrawerHeader.tsx +244 -0
  123. package/src/components/molecules/Drawer/DrawerHeaderContext.ts +13 -0
  124. package/src/components/molecules/Drawer/DrawerOverlay.tsx +53 -0
  125. package/src/components/molecules/Drawer/DrawerTitle.tsx +32 -0
  126. package/src/components/molecules/Drawer/index.ts +12 -0
  127. package/src/components/molecules/FilterTab/FilterTab.stories.tsx +210 -0
  128. package/src/components/molecules/FilterTab/FilterTab.tsx +310 -0
  129. package/src/components/molecules/FilterTab/index.ts +2 -0
  130. package/src/components/molecules/MessageCard/MessageCard.stories.tsx +169 -0
  131. package/src/components/molecules/MessageCard/MessageCard.tsx +362 -0
  132. package/src/components/molecules/MessageCard/index.ts +10 -0
  133. package/src/components/molecules/Notification/Notification.stories.tsx +219 -0
  134. package/src/components/molecules/Notification/Notification.tsx +426 -0
  135. package/src/components/molecules/Notification/index.ts +2 -0
  136. package/src/components/molecules/NumberField/NumberField.stories.tsx +231 -0
  137. package/src/components/molecules/NumberField/NumberField.tsx +186 -0
  138. package/src/components/molecules/NumberField/NumberFieldInput.tsx +287 -0
  139. package/src/components/molecules/NumberField/index.ts +2 -0
  140. package/src/components/molecules/PasswordField/PasswordField.stories.tsx +362 -0
  141. package/src/components/molecules/PasswordField/PasswordField.test.tsx +369 -0
  142. package/src/components/molecules/PasswordField/PasswordField.tsx +194 -0
  143. package/src/components/molecules/PasswordField/PasswordFieldError.tsx +53 -0
  144. package/src/components/molecules/PasswordField/PasswordFieldInput.tsx +73 -0
  145. package/src/components/molecules/PasswordField/PasswordFieldRequirements.tsx +95 -0
  146. package/src/components/molecules/PasswordField/hooks/index.ts +2 -0
  147. package/src/components/molecules/PasswordField/hooks/usePasswordField.ts +113 -0
  148. package/src/components/molecules/PasswordField/index.ts +10 -0
  149. package/src/components/molecules/PictureSelector/PictureSelector.stories.tsx +204 -0
  150. package/src/components/molecules/PictureSelector/PictureSelector.tsx +335 -0
  151. package/src/components/molecules/PictureSelector/index.ts +5 -0
  152. package/src/components/molecules/Progress/Progress.stories.tsx +145 -0
  153. package/src/components/molecules/Progress/Progress.tsx +184 -0
  154. package/src/components/molecules/Progress/index.ts +2 -0
  155. package/src/components/molecules/Radio/Radio.test.tsx +104 -0
  156. package/src/components/molecules/Radio/Radio.tsx +1 -2
  157. package/src/components/molecules/SearchField/SearchField.stories.tsx +242 -0
  158. package/src/components/molecules/SearchField/SearchField.test.tsx +318 -0
  159. package/src/components/molecules/SearchField/SearchField.tsx +143 -0
  160. package/src/components/molecules/SearchField/SearchFieldInput.tsx +63 -0
  161. package/src/components/molecules/SearchField/hooks/index.ts +1 -0
  162. package/src/components/molecules/SearchField/hooks/useSearchField.ts +56 -0
  163. package/src/components/molecules/SearchField/index.ts +4 -0
  164. package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +31 -8
  165. package/src/components/molecules/SegmentedControl/SegmentedControl.test.tsx +141 -0
  166. package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +237 -23
  167. package/src/components/molecules/SelectField/SelectField.stories.tsx +320 -0
  168. package/src/components/molecules/SelectField/SelectField.test.tsx +254 -0
  169. package/src/components/molecules/SelectField/SelectField.tsx +236 -0
  170. package/src/components/molecules/SelectField/SelectFieldContent.tsx +85 -0
  171. package/src/components/molecules/SelectField/SelectFieldItem.tsx +133 -0
  172. package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +170 -0
  173. package/src/components/molecules/SelectField/SelectFieldValue.tsx +31 -0
  174. package/src/components/molecules/SelectField/hooks/index.ts +2 -0
  175. package/src/components/molecules/SelectField/hooks/useSelectField.ts +84 -0
  176. package/src/components/molecules/SelectField/index.ts +10 -0
  177. package/src/components/molecules/Slider/Slider.test.tsx +102 -0
  178. package/src/components/molecules/Slider/Slider.tsx +293 -180
  179. package/src/components/molecules/Tooltip/Tooltip.stories.tsx +168 -0
  180. package/src/components/molecules/Tooltip/Tooltip.tsx +326 -0
  181. package/src/components/molecules/Tooltip/index.ts +2 -0
  182. package/src/components/molecules/index.ts +15 -0
  183. package/src/test-utils.tsx +20 -0
  184. package/tsconfig.json +1 -1
  185. package/tsup.config.ts +16 -2
  186. package/vitest.config.ts +114 -0
  187. package/vitest.setup.ts +16 -0
@@ -1,14 +1,30 @@
1
- import React from "react"
2
- import { View, TextInput, TextInputProps } from "react-native"
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"
3
10
  import styled from "@emotion/native"
4
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"
5
17
 
6
18
  export type InputState = "default" | "error" | "success"
7
19
 
8
20
  type InputFieldOwnProps = {
9
21
  leadingIcon?: React.ReactNode
10
22
  trailingIcon?: React.ReactNode
23
+ actionIcon?: React.ReactNode
11
24
  state?: InputState
25
+ hideStateIcons?: boolean
26
+ containerWidth?: number
27
+ containerHeight?: number
12
28
  }
13
29
 
14
30
  export type InputFieldProps = InputFieldOwnProps &
@@ -16,52 +32,55 @@ export type InputFieldProps = InputFieldOwnProps &
16
32
 
17
33
  const parseTokenValue = (value: string): number => parseFloat(value)
18
34
 
19
- const StyledInputWrapper = styled(View)<{
20
- wrapperGap: number
21
- wrapperMinWidth: number
22
- wrapperHeight: number
23
- wrapperPaddingVertical: number
24
- wrapperPaddingHorizontal: number
25
- wrapperBgColor: string
26
- wrapperBorderWidth: number
27
- wrapperBorderColor: string
28
- wrapperBorderRadius: number
29
- }>(
30
- ({
31
- wrapperGap,
32
- wrapperMinWidth,
33
- wrapperHeight,
34
- wrapperPaddingVertical,
35
- wrapperPaddingHorizontal,
36
- wrapperBgColor,
37
- wrapperBorderWidth,
38
- wrapperBorderColor,
39
- wrapperBorderRadius
40
- }) => ({
35
+ const StyledInputWrapper = styled(Animated.View)<{
36
+ isDisabled: boolean
37
+ containerWidth?: number
38
+ containerHeight?: number
39
+ }>(({ theme, isDisabled, containerWidth, containerHeight }) => {
40
+ const { spacing, colour, borderRadius, size } = theme.tokens.components.inputs
41
+ return {
41
42
  flexDirection: "row",
42
43
  alignItems: "center",
43
- gap: wrapperGap,
44
- minWidth: wrapperMinWidth,
45
- width: "100%",
46
- height: wrapperHeight,
47
- paddingVertical: wrapperPaddingVertical,
48
- paddingHorizontal: wrapperPaddingHorizontal,
49
- backgroundColor: wrapperBgColor,
50
- borderWidth: wrapperBorderWidth,
51
- borderColor: wrapperBorderColor,
52
- borderRadius: wrapperBorderRadius
53
- })
54
- )
44
+ gap: parseTokenValue(spacing.field.gap),
45
+ minWidth:
46
+ containerWidth !== undefined
47
+ ? undefined
48
+ : parseTokenValue(size.field.minWidth),
49
+ width: containerWidth !== undefined ? containerWidth : "100%",
50
+ height:
51
+ containerHeight !== undefined
52
+ ? containerHeight
53
+ : parseTokenValue(size.field.height),
54
+ backgroundColor: isDisabled
55
+ ? colour.field.background.inactive
56
+ : colour.field.background.default,
57
+ borderRadius: parseTokenValue(borderRadius.field.default),
58
+ flexShrink: containerWidth !== undefined ? 0 : undefined
59
+ }
60
+ })
55
61
 
56
- const StyledInput = styled(TextInput)<{
57
- inputColor: string
58
- }>(({ inputColor }) => ({
59
- flex: 1,
60
- minWidth: 0,
61
- minHeight: 0,
62
- padding: 0,
63
- color: inputColor
64
- }))
62
+ const StyledInput = styled(TextInput)(({ theme }) => {
63
+ const { colour } = theme.tokens.components.inputs
64
+ return {
65
+ flex: 1,
66
+ minWidth: 0,
67
+ minHeight: 0,
68
+ padding: 0,
69
+ color: colour.field.text.default,
70
+ // Suppress the browser default focus outline on React Native Web —
71
+ // the focus indicator is rendered on the wrapper instead.
72
+ ...({
73
+ outlineStyle: "none",
74
+ outlineWidth: 0,
75
+ outlineColor: "transparent"
76
+ } as object)
77
+ }
78
+ })
79
+
80
+ const StyledFocusRing = styled(View)({
81
+ position: "absolute",
82
+ pointerEvents: "none"
83
+ })
65
84
 
66
85
  const StyledIconSlot = styled(View)({
67
86
  alignItems: "center",
@@ -74,44 +93,177 @@ const StyledIconSlot = styled(View)({
74
93
  *
75
94
  * @param {React.ReactNode} [leadingIcon] - Icon to display before the input
76
95
  * @param {React.ReactNode} [trailingIcon] - Icon to display after the input
96
+ * @param {React.ReactNode} [actionIcon] - Interactive icon at the rightmost position (e.g. clear button, password toggle)
77
97
  * @param {InputState} [state] - Visual state of the input (default, error, success)
98
+ * @param {boolean} [hideStateIcons] - Hide automatic error/success icons
78
99
  */
79
100
  export const InputField = React.forwardRef<TextInput, InputFieldProps>(
80
- ({ leadingIcon, trailingIcon, state = "default", style, ...rest }, ref) => {
101
+ (
102
+ {
103
+ leadingIcon,
104
+ trailingIcon,
105
+ actionIcon,
106
+ state = "default",
107
+ hideStateIcons,
108
+ containerWidth,
109
+ containerHeight,
110
+ style,
111
+ editable,
112
+ onFocus,
113
+ onBlur,
114
+ ...rest
115
+ },
116
+ ref
117
+ ) => {
81
118
  const theme = useTheme()
82
- const { spacing, colour, borderWidth, borderRadius, size } =
119
+ const { spacing, colour, borderWidth, borderRadius } =
83
120
  theme.tokens.components.inputs
121
+ const focusBorderColor = theme.tokens.semantics.colour.border.focus
122
+
123
+ const [isFocused, setIsFocused] = useState(false)
124
+
125
+ const internalInputRef = useRef<TextInput>(null)
126
+ const combinedRef = useCallback(
127
+ (node: TextInput | null) => {
128
+ internalInputRef.current = node
129
+ if (typeof ref === "function") {
130
+ ref(node)
131
+ } else if (ref) {
132
+ ;(ref as React.MutableRefObject<TextInput | null>).current = node
133
+ }
134
+ },
135
+ [ref]
136
+ )
137
+ const isDisabled = editable === false
138
+ const hasState = state === "error" || state === "success"
139
+
140
+ const baseBorderWidth = parseTokenValue(borderWidth.field.default)
141
+ const selectedBorderWidth = parseTokenValue(borderWidth.field.selected)
142
+ const focusBorderWidth = parseTokenValue(borderWidth.field.focus)
143
+ const fieldBorderRadius = parseTokenValue(borderRadius.field.default)
144
+ const baseVerticalPadding = parseTokenValue(spacing.field.verticalPadding)
145
+ const baseHorizontalPadding = parseTokenValue(
146
+ spacing.field.horizontalPadding
147
+ )
148
+ const borderDelta = selectedBorderWidth - baseBorderWidth
149
+
150
+ const borderColor =
151
+ state === "error"
152
+ ? colour.field.border.error
153
+ : state === "success"
154
+ ? colour.field.border.success
155
+ : isFocused
156
+ ? colour.field.border.selected
157
+ : colour.field.border.default
158
+
159
+ const isActiveBorder = isFocused || hasState
160
+ const borderAnim = useRef(
161
+ new Animated.Value(isActiveBorder ? 1 : 0)
162
+ ).current
84
163
 
85
- const stateBorderColorMap = {
86
- error: colour.field.border.error,
87
- success: colour.field.border.success,
88
- default: colour.field.border.default
164
+ useEffect(() => {
165
+ const animation = Animated.timing(borderAnim, {
166
+ toValue: isActiveBorder ? 1 : 0,
167
+ duration: 50,
168
+ easing: Easing.ease,
169
+ useNativeDriver: false
170
+ })
171
+ animation.start()
172
+ return () => animation.stop()
173
+ }, [isActiveBorder, borderAnim])
174
+
175
+ const animatedBorderWidth = borderAnim.interpolate({
176
+ inputRange: [0, 1],
177
+ outputRange: [baseBorderWidth, selectedBorderWidth]
178
+ })
179
+
180
+ const animatedPaddingVertical = borderAnim.interpolate({
181
+ inputRange: [0, 1],
182
+ outputRange: [baseVerticalPadding, baseVerticalPadding - borderDelta]
183
+ })
184
+
185
+ const animatedPaddingHorizontal = borderAnim.interpolate({
186
+ inputRange: [0, 1],
187
+ outputRange: [baseHorizontalPadding, baseHorizontalPadding - borderDelta]
188
+ })
189
+
190
+ const stateIconMap = {
191
+ error: { icon: ErrorIcon, colour: "error" as const },
192
+ success: { icon: SuccessIcon, colour: "success" as const }
89
193
  }
90
194
 
91
- return (
92
- <StyledInputWrapper
93
- wrapperGap={parseTokenValue(spacing.field.gap)}
94
- wrapperMinWidth={parseTokenValue(size.field.minWidth)}
95
- wrapperHeight={parseTokenValue(size.field.height)}
96
- wrapperPaddingVertical={parseTokenValue(spacing.field.verticalPadding)}
97
- wrapperPaddingHorizontal={parseTokenValue(
98
- spacing.field.horizontalPadding
99
- )}
100
- wrapperBgColor={colour.field.background.default}
101
- wrapperBorderWidth={parseTokenValue(borderWidth.field.default)}
102
- wrapperBorderColor={stateBorderColorMap[state]}
103
- wrapperBorderRadius={parseTokenValue(borderRadius.field.default)}
104
- >
105
- {leadingIcon && <StyledIconSlot>{leadingIcon}</StyledIconSlot>}
106
- <StyledInput
107
- ref={ref}
108
- inputColor={colour.field.text.default}
109
- style={style}
110
- placeholderTextColor={colour.field.text.placeholder}
111
- {...rest}
195
+ const stateIconConfig = state !== "default" ? stateIconMap[state] : null
196
+
197
+ const stateIcon =
198
+ !hideStateIcons && stateIconConfig ? (
199
+ <Icon
200
+ icon={stateIconConfig.icon}
201
+ size="md"
202
+ colour={stateIconConfig.colour}
112
203
  />
113
- {trailingIcon && <StyledIconSlot>{trailingIcon}</StyledIconSlot>}
114
- </StyledInputWrapper>
204
+ ) : null
205
+
206
+ const handleFocus = (
207
+ e: Parameters<NonNullable<TextInputProps["onFocus"]>>[0]
208
+ ) => {
209
+ setIsFocused(true)
210
+ onFocus?.(e)
211
+ }
212
+
213
+ const handleBlur = (
214
+ e: Parameters<NonNullable<TextInputProps["onBlur"]>>[0]
215
+ ) => {
216
+ setIsFocused(false)
217
+ onBlur?.(e)
218
+ }
219
+
220
+ const handleWrapperPress = () => {
221
+ internalInputRef.current?.focus()
222
+ }
223
+
224
+ return (
225
+ <Pressable onPress={handleWrapperPress}>
226
+ <StyledInputWrapper
227
+ isDisabled={isDisabled}
228
+ containerWidth={containerWidth}
229
+ containerHeight={containerHeight}
230
+ style={[
231
+ {
232
+ borderColor,
233
+ borderWidth: animatedBorderWidth,
234
+ paddingVertical: animatedPaddingVertical,
235
+ paddingHorizontal: animatedPaddingHorizontal
236
+ }
237
+ ]}
238
+ >
239
+ {isFocused && !hasState && (
240
+ <StyledFocusRing
241
+ style={{
242
+ top: -focusBorderWidth,
243
+ left: -focusBorderWidth,
244
+ right: -focusBorderWidth,
245
+ bottom: -focusBorderWidth,
246
+ borderWidth: focusBorderWidth,
247
+ borderColor: focusBorderColor,
248
+ borderRadius: fieldBorderRadius + focusBorderWidth
249
+ }}
250
+ />
251
+ )}
252
+ {leadingIcon && <StyledIconSlot>{leadingIcon}</StyledIconSlot>}
253
+ <StyledInput
254
+ ref={combinedRef}
255
+ style={style}
256
+ placeholderTextColor={colour.field.text.placeholder}
257
+ editable={editable}
258
+ onFocus={handleFocus}
259
+ onBlur={handleBlur}
260
+ {...rest}
261
+ />
262
+ {trailingIcon && <StyledIconSlot>{trailingIcon}</StyledIconSlot>}
263
+ {stateIcon && <StyledIconSlot>{stateIcon}</StyledIconSlot>}
264
+ {actionIcon && <StyledIconSlot>{actionIcon}</StyledIconSlot>}
265
+ </StyledInputWrapper>
266
+ </Pressable>
115
267
  )
116
268
  }
117
269
  )
@@ -0,0 +1,89 @@
1
+ import React from "react"
2
+ import { screen } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+ import { describe, it, expect, vi } from "vitest"
5
+ import { renderWithTheme } from "../../../test-utils"
6
+ import { Link } from "./Link"
7
+
8
+ describe("Link", () => {
9
+ describe("when component is rendering", () => {
10
+ it("renders children text", () => {
11
+ renderWithTheme(<Link>Find out more</Link>)
12
+ expect(screen.getByText("Find out more")).toBeInTheDocument()
13
+ })
14
+
15
+ it("renders with default inline style", () => {
16
+ renderWithTheme(<Link>Inline link</Link>)
17
+ expect(screen.getByText("Inline link")).toBeInTheDocument()
18
+ })
19
+ })
20
+
21
+ describe("when rendering standalone", () => {
22
+ it("renders with trailing arrow icon", () => {
23
+ const { container } = renderWithTheme(<Link standalone>Standalone</Link>)
24
+ expect(screen.getByText("Standalone")).toBeInTheDocument()
25
+ expect(container.querySelector("svg")).toBeInTheDocument()
26
+ })
27
+ })
28
+
29
+ describe("when rendering sizes", () => {
30
+ it.each(["sm", "md", "lg"] as const)("renders %s size", (size) => {
31
+ renderWithTheme(<Link size={size}>Link</Link>)
32
+ expect(screen.getByText("Link")).toBeInTheDocument()
33
+ })
34
+ })
35
+
36
+ describe("when rendering weights", () => {
37
+ it.each(["medium", "semiBold", "bold"] as const)(
38
+ "renders %s weight",
39
+ (weight) => {
40
+ renderWithTheme(<Link weight={weight}>Link</Link>)
41
+ expect(screen.getByText("Link")).toBeInTheDocument()
42
+ }
43
+ )
44
+ })
45
+
46
+ describe("when component is interactive", () => {
47
+ it("calls onPress when pressed", async () => {
48
+ const user = userEvent.setup()
49
+ const onPress = vi.fn()
50
+ renderWithTheme(<Link onPress={onPress}>Press me</Link>)
51
+
52
+ await user.click(screen.getByText("Press me"))
53
+ expect(onPress).toHaveBeenCalledTimes(1)
54
+ })
55
+
56
+ it("does not call onPress when disabled", async () => {
57
+ const user = userEvent.setup()
58
+ const onPress = vi.fn()
59
+ renderWithTheme(
60
+ <Link onPress={onPress} disabled>
61
+ Disabled
62
+ </Link>
63
+ )
64
+
65
+ await user.click(screen.getByText("Disabled"))
66
+ expect(onPress).not.toHaveBeenCalled()
67
+ })
68
+ })
69
+
70
+ describe("accessibility", () => {
71
+ it("has link role", () => {
72
+ renderWithTheme(<Link>Accessible</Link>)
73
+ expect(screen.getByRole("link")).toBeInTheDocument()
74
+ })
75
+
76
+ it("sets disabled accessibility state", () => {
77
+ renderWithTheme(<Link disabled>Disabled</Link>)
78
+ expect(screen.getByRole("link")).toBeDisabled()
79
+ })
80
+ })
81
+
82
+ describe("when using with ref", () => {
83
+ it("forwards ref", () => {
84
+ const ref = React.createRef<any>()
85
+ renderWithTheme(<Link ref={ref}>Ref test</Link>)
86
+ expect(ref.current).toBeTruthy()
87
+ })
88
+ })
89
+ })
@@ -7,6 +7,8 @@ import {
7
7
  ViewStyle
8
8
  } from "react-native"
9
9
  import { useTheme } from "@emotion/react"
10
+ import { ArrowForward } from "@butternutbox/pawprint-icons/core"
11
+ import { Icon } from "../Icon"
10
12
  import { Typography } from "../Typography"
11
13
 
12
14
  export type LinkSize = "sm" | "md" | "lg"
@@ -101,12 +103,11 @@ export const Link = React.forwardRef<View, LinkProps>(
101
103
  {children}
102
104
  </Typography>
103
105
  {standalone && (
104
- <Typography
105
- token={typography.link[weight][size]}
106
- color={pressed ? linkColour.hover : linkColour.default}
107
- >
108
- {" →"}
109
- </Typography>
106
+ <Icon
107
+ icon={ArrowForward}
108
+ size="xs"
109
+ customColour={pressed ? linkColour.hover : linkColour.default}
110
+ />
110
111
  )}
111
112
  </>
112
113
  )}
@@ -1,4 +1,17 @@
1
1
  import React from "react"
2
+ import {
3
+ BCorp,
4
+ ButternutFavicon,
5
+ ButternutPrimary,
6
+ ButternutTabbedBottom,
7
+ ButternutTabbedTop,
8
+ ButternutWordmark,
9
+ PsiBufetFavicon,
10
+ PsiBufetPrimary,
11
+ PsiBufetTabbedBottom,
12
+ PsiBufetTabbedTop,
13
+ PsiBufetWordmark
14
+ } from "./assets"
2
15
  import type { LogoBrand, LogoVariant } from "./Logo"
3
16
 
4
17
  type LogoSvgComponent = React.ComponentType<{
@@ -6,15 +19,27 @@ type LogoSvgComponent = React.ComponentType<{
6
19
  height?: number
7
20
  }>
8
21
 
9
- // Logo registry - consumers should register their logo components here
10
- // or provide them via a registration function
11
22
  const LOGOS: Record<
12
23
  LogoBrand,
13
24
  Partial<Record<LogoVariant, LogoSvgComponent>>
14
25
  > = {
15
- butternut: {},
16
- psibufet: {},
17
- bcorp: {}
26
+ butternut: {
27
+ wordmark: ButternutWordmark,
28
+ primary: ButternutPrimary,
29
+ "tabbed-top": ButternutTabbedTop,
30
+ "tabbed-bottom": ButternutTabbedBottom,
31
+ favicon: ButternutFavicon
32
+ },
33
+ psibufet: {
34
+ wordmark: PsiBufetWordmark,
35
+ primary: PsiBufetPrimary,
36
+ "tabbed-top": PsiBufetTabbedTop,
37
+ "tabbed-bottom": PsiBufetTabbedBottom,
38
+ favicon: PsiBufetFavicon
39
+ },
40
+ bcorp: {
41
+ primary: BCorp
42
+ }
18
43
  }
19
44
 
20
45
  function resolveLogo(
@@ -0,0 +1,108 @@
1
+ import React from "react"
2
+ import { View, StyleSheet, Text } from "react-native"
3
+ import { Logo } from "./Logo"
4
+ import type { LogoProps, LogoBrand, LogoVariant } from "./Logo"
5
+
6
+ export default {
7
+ title: "Atoms/Logo",
8
+ component: Logo,
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "Renders a brand logo SVG for ButternutBox, PsiBufet, or BCorp."
14
+ }
15
+ }
16
+ },
17
+ argTypes: {
18
+ brand: {
19
+ control: { type: "select" },
20
+ options: ["butternut", "psibufet", "bcorp"],
21
+ description: "Brand identity"
22
+ },
23
+ variant: {
24
+ control: { type: "select" },
25
+ options: [
26
+ "wordmark",
27
+ "primary",
28
+ "tabbed-top",
29
+ "tabbed-bottom",
30
+ "favicon"
31
+ ],
32
+ description: "Logo variant"
33
+ }
34
+ }
35
+ }
36
+
37
+ const variants: LogoVariant[] = [
38
+ "wordmark",
39
+ "primary",
40
+ "tabbed-top",
41
+ "tabbed-bottom",
42
+ "favicon"
43
+ ]
44
+
45
+ const brands: LogoBrand[] = ["butternut", "psibufet"]
46
+
47
+ export const Default = (args: LogoProps) => <Logo {...args} />
48
+ Default.args = {
49
+ brand: "butternut",
50
+ variant: "primary",
51
+ "aria-label": "ButternutBox"
52
+ }
53
+
54
+ export const AllVariants = () => (
55
+ <View style={styles.column}>
56
+ {variants.map((variant) => (
57
+ <View key={variant} style={styles.section}>
58
+ <Text style={styles.label}>{variant}</Text>
59
+ <Logo
60
+ brand="butternut"
61
+ variant={variant}
62
+ aria-label={`Butternut ${variant}`}
63
+ />
64
+ </View>
65
+ ))}
66
+ </View>
67
+ )
68
+
69
+ export const AllBrands = () => (
70
+ <View style={styles.column}>
71
+ {brands.map((brand) => (
72
+ <View key={brand} style={styles.section}>
73
+ <Text style={styles.label}>{brand}</Text>
74
+ <Logo brand={brand} variant="primary" aria-label={brand} />
75
+ </View>
76
+ ))}
77
+ </View>
78
+ )
79
+
80
+ export const BCorp = () => (
81
+ <View style={styles.section}>
82
+ <Text style={styles.label}>BCorp</Text>
83
+ <Logo brand="bcorp" aria-label="B Corp Certified" />
84
+ </View>
85
+ )
86
+
87
+ export const Playground = (args: LogoProps) => <Logo {...args} />
88
+ Playground.args = {
89
+ brand: "butternut",
90
+ variant: "primary",
91
+ "aria-label": "Playground logo"
92
+ }
93
+
94
+ const styles = StyleSheet.create({
95
+ column: {
96
+ flexDirection: "column",
97
+ gap: 24
98
+ },
99
+ section: {
100
+ flexDirection: "column",
101
+ gap: 8
102
+ },
103
+ label: {
104
+ fontSize: 13,
105
+ fontWeight: "600",
106
+ color: "#666"
107
+ }
108
+ })
@@ -0,0 +1,56 @@
1
+ import React from "react"
2
+ import { screen } from "@testing-library/react"
3
+ import { describe, it, expect, vi } from "vitest"
4
+ import { renderWithTheme } from "../../../test-utils"
5
+ import { Logo } from "./Logo"
6
+
7
+ const MockLogo = () => <svg data-testid="logo-svg" />
8
+
9
+ vi.mock("./Logo.registry", () => ({
10
+ resolveLogo: (_brand: string, _variant: string) => MockLogo
11
+ }))
12
+
13
+ describe("Logo", () => {
14
+ describe("when component is rendering", () => {
15
+ it("renders the logo", () => {
16
+ renderWithTheme(<Logo brand="butternut" />)
17
+ expect(screen.getByTestId("logo-svg")).toBeInTheDocument()
18
+ })
19
+
20
+ it("renders with primary variant by default", () => {
21
+ renderWithTheme(<Logo brand="butternut" />)
22
+ expect(screen.getByTestId("logo-svg")).toBeInTheDocument()
23
+ })
24
+ })
25
+
26
+ describe("when rendering different brands", () => {
27
+ it.each(["butternut", "psibufet", "bcorp"] as const)(
28
+ "renders %s brand",
29
+ (brand) => {
30
+ renderWithTheme(<Logo brand={brand} />)
31
+ expect(screen.getByTestId("logo-svg")).toBeInTheDocument()
32
+ }
33
+ )
34
+ })
35
+
36
+ describe("accessibility", () => {
37
+ it("sets image role when aria-label is provided", () => {
38
+ renderWithTheme(<Logo brand="butternut" aria-label="ButternutBox" />)
39
+ expect(screen.getByRole("image")).toBeInTheDocument()
40
+ expect(screen.getByLabelText("ButternutBox")).toBeInTheDocument()
41
+ })
42
+
43
+ it("does not set role when aria-label is not provided", () => {
44
+ renderWithTheme(<Logo brand="butternut" />)
45
+ expect(screen.queryByRole("image")).not.toBeInTheDocument()
46
+ })
47
+ })
48
+
49
+ describe("when using with ref", () => {
50
+ it("forwards ref", () => {
51
+ const ref = React.createRef<any>()
52
+ renderWithTheme(<Logo ref={ref} brand="butternut" />)
53
+ expect(ref.current).toBeTruthy()
54
+ })
55
+ })
56
+ })