@butternutbox/pawprint-native 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butternutbox/pawprint-native",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -324,6 +324,13 @@ export const Platform = {
324
324
  select: (obj: any) => obj.ios ?? obj.default
325
325
  }
326
326
 
327
+ export const Keyboard = {
328
+ addListener: (_event: string, _handler: () => void) => ({
329
+ remove: () => {}
330
+ }),
331
+ dismiss: () => {}
332
+ }
333
+
327
334
  // ─── Type exports (re-exported as empty for TS compat) ───────────────────
328
335
  export type ViewProps = any
329
336
  export type TextProps = any
@@ -1,10 +1,24 @@
1
1
  import React from "react"
2
2
 
3
+ const SelectCtx = React.createContext<{
4
+ open: boolean
5
+ setOpen: (open: boolean) => void
6
+ }>({ open: false, setOpen: () => {} })
7
+
3
8
  export const Root = React.forwardRef<any, any>(
4
9
  (
5
- { value, defaultValue, onValueChange, disabled, children, ...rest },
10
+ {
11
+ value,
12
+ defaultValue,
13
+ onValueChange,
14
+ disabled,
15
+ children,
16
+ onOpenChange,
17
+ ...rest
18
+ },
6
19
  ref
7
20
  ) => {
21
+ const [open, setOpenState] = React.useState(false)
8
22
  const [internalValue, setInternalValue] = React.useState(
9
23
  value ?? defaultValue ?? null
10
24
  )
@@ -17,12 +31,19 @@ export const Root = React.forwardRef<any, any>(
17
31
  }
18
32
  }
19
33
 
34
+ const setOpen = (nextOpen: boolean) => {
35
+ setOpenState(nextOpen)
36
+ onOpenChange?.(nextOpen)
37
+ }
38
+
20
39
  return (
21
- <div ref={ref} data-disabled={disabled} {...rest}>
22
- {typeof children === "function"
23
- ? children({ value: currentValue, onChange: handleChange })
24
- : children}
25
- </div>
40
+ <SelectCtx.Provider value={{ open, setOpen }}>
41
+ <div ref={ref} data-disabled={disabled} {...rest}>
42
+ {typeof children === "function"
43
+ ? children({ value: currentValue, onChange: handleChange })
44
+ : children}
45
+ </div>
46
+ </SelectCtx.Provider>
26
47
  )
27
48
  }
28
49
  )
@@ -30,11 +51,18 @@ Root.displayName = "SelectPrimitive.Root"
30
51
 
31
52
  export const Trigger = React.forwardRef<any, any>(
32
53
  ({ asChild, children, ...rest }, ref) => {
54
+ const { open, setOpen } = React.useContext(SelectCtx)
55
+ const handlePress = () => setOpen(!open)
56
+
33
57
  if (asChild && React.isValidElement(children)) {
34
- return React.cloneElement(children, { ref, ...rest } as any)
58
+ return React.cloneElement(children, {
59
+ ref,
60
+ onPress: handlePress,
61
+ ...rest
62
+ } as any)
35
63
  }
36
64
  return (
37
- <button ref={ref} role="button" {...rest}>
65
+ <button ref={ref} role="button" onClick={handlePress} {...rest}>
38
66
  {children}
39
67
  </button>
40
68
  )
@@ -59,22 +87,20 @@ Value.displayName = "SelectPrimitive.Value"
59
87
  export const Portal = ({ children }: any) => <>{children}</>
60
88
  Portal.displayName = "SelectPrimitive.Portal"
61
89
 
62
- export const Viewport = React.forwardRef<any, any>(
63
- ({ children, ...rest }, ref) => (
64
- <div ref={ref} {...rest}>
65
- {children}
66
- </div>
67
- )
68
- )
69
- Viewport.displayName = "SelectPrimitive.Viewport"
70
-
71
90
  export const Overlay = React.forwardRef<any, any>(
72
- ({ asChild, children, ...rest }, ref) => {
73
- if (asChild && React.isValidElement(children)) {
74
- return React.cloneElement(children, { ref, ...rest } as any)
91
+ ({ onPress, closeOnPress = true, children, ...rest }, ref) => {
92
+ const { setOpen } = React.useContext(SelectCtx)
93
+ const handlePress = () => {
94
+ if (closeOnPress) setOpen(false)
95
+ onPress?.()
75
96
  }
76
97
  return (
77
- <div ref={ref} {...rest}>
98
+ <div
99
+ ref={ref}
100
+ data-testid="select-overlay"
101
+ onClick={handlePress}
102
+ {...rest}
103
+ >
78
104
  {children}
79
105
  </div>
80
106
  )
@@ -82,8 +108,19 @@ export const Overlay = React.forwardRef<any, any>(
82
108
  )
83
109
  Overlay.displayName = "SelectPrimitive.Overlay"
84
110
 
111
+ export const Viewport = React.forwardRef<any, any>(
112
+ ({ children, ...rest }, ref) => (
113
+ <div ref={ref} {...rest}>
114
+ {children}
115
+ </div>
116
+ )
117
+ )
118
+ Viewport.displayName = "SelectPrimitive.Viewport"
119
+
85
120
  export const Content = React.forwardRef<any, any>(
86
121
  ({ asChild, children, ...rest }, ref) => {
122
+ const { open } = React.useContext(SelectCtx)
123
+ if (!open) return null
87
124
  if (asChild && React.isValidElement(children)) {
88
125
  return React.cloneElement(children, { ref, ...rest } as any)
89
126
  }
@@ -13,6 +13,7 @@ import {
13
13
  Error as ErrorIcon,
14
14
  CheckCircle as SuccessIcon
15
15
  } from "@butternutbox/pawprint-icons/core"
16
+ import { resolveFont } from "../../../fonts"
16
17
  import { Icon } from "../Icon"
17
18
 
18
19
  export type InputState = "default" | "error" | "success"
@@ -61,18 +62,24 @@ const StyledInputWrapper = styled(Animated.View)<{
61
62
 
62
63
  const StyledInput = styled(TextInput)(({ theme }) => {
63
64
  const { colour, field } = theme.tokens.components.inputs
64
- const placeholderTypography = field.placeholder.default
65
+ const typography = field.placeholder.default
66
+ const { fontFamily } = resolveFont(
67
+ typography.fontFamily,
68
+ typography.fontWeight
69
+ )
70
+ const fontSize = parseTokenValue(typography.fontSize)
71
+ const letterSpacing = typography.letterSpacing?.endsWith("%")
72
+ ? (parseFloat(typography.letterSpacing) / 100) * fontSize
73
+ : parseFloat(typography.letterSpacing ?? "0")
65
74
  return {
66
75
  flex: 1,
67
76
  minWidth: 0,
68
77
  minHeight: 0,
69
78
  padding: 0,
70
79
  color: colour.field.text.default,
71
- fontSize: parseTokenValue(placeholderTypography.fontSize),
72
- fontFamily: placeholderTypography.fontFamily,
73
- fontWeight: placeholderTypography.fontWeight,
74
- lineHeight: parseTokenValue(placeholderTypography.lineHeight),
75
- letterSpacing: parseTokenValue(placeholderTypography.letterSpacing),
80
+ fontFamily,
81
+ fontSize,
82
+ letterSpacing,
76
83
  // Suppress the browser default focus outline on React Native Web —
77
84
  // the focus indicator is rendered on the wrapper instead.
78
85
  ...({
@@ -275,3 +282,27 @@ export const InputField = React.forwardRef<TextInput, InputFieldProps>(
275
282
  )
276
283
 
277
284
  InputField.displayName = "Input.Field"
285
+
286
+ /**
287
+ * Bare text input with design-system typography and colors applied.
288
+ * No container, border, or padding — use inside components that provide
289
+ * their own visual wrapper (e.g. SelectField trigger).
290
+ */
291
+ export const InputText = React.forwardRef<TextInput, TextInputProps>(
292
+ ({ placeholderTextColor, ...rest }, ref) => {
293
+ const theme = useTheme()
294
+ const { colour } = theme.tokens.components.inputs
295
+
296
+ return (
297
+ <StyledInput
298
+ ref={ref}
299
+ placeholderTextColor={
300
+ placeholderTextColor ?? colour.field.text.placeholder
301
+ }
302
+ {...rest}
303
+ />
304
+ )
305
+ }
306
+ )
307
+
308
+ InputText.displayName = "Input.Text"
@@ -2,7 +2,7 @@ export { Input } from "./Input"
2
2
  export type { InputProps } from "./Input"
3
3
  export { InputLabel } from "./InputLabel"
4
4
  export type { InputLabelProps } from "./InputLabel"
5
- export { InputField } from "./InputField"
5
+ export { InputField, InputText } from "./InputField"
6
6
  export type { InputFieldProps } from "./InputField"
7
7
  export { InputDescription } from "./InputDescription"
8
8
  export type { InputDescriptionProps } from "./InputDescription"
@@ -1,6 +1,7 @@
1
1
  import React from "react"
2
2
  import { View, StyleSheet } from "react-native"
3
3
  import { ProductDisplayCard } from "./ProductDisplayCard"
4
+ import { Typography } from "../../atoms/Typography"
4
5
  import type { ProductDisplayCardProps } from "./ProductDisplayCard"
5
6
 
6
7
  const placeholderImageUrl =
@@ -205,3 +206,77 @@ export const AllVariants = () => (
205
206
  />
206
207
  </View>
207
208
  )
209
+
210
+ export const WithMinQuantity = () => (
211
+ <View style={styles.column}>
212
+ <ProductDisplayCard
213
+ title="Bulk Recipe Pack"
214
+ subtext="+£2.50/pouch"
215
+ showSubtext
216
+ quantity={5}
217
+ minQuantity={5}
218
+ showQuantityPicker
219
+ showBanner
220
+ bannerType="info"
221
+ showBannerIcon={false}
222
+ banner="Minimum quantity: 5 pouches required"
223
+ image={placeholderImageUrl}
224
+ />
225
+ </View>
226
+ )
227
+
228
+ export const WithImagePress = () => {
229
+ const [pressed, setPressed] = React.useState(false)
230
+ return (
231
+ <View style={styles.column}>
232
+ <ProductDisplayCard
233
+ title="Recipe Name"
234
+ subtext="+£0.00/pouch"
235
+ showSubtext
236
+ quantity={1}
237
+ showQuantityPicker
238
+ onImagePress={() => setPressed(true)}
239
+ showBanner={pressed}
240
+ bannerType="success"
241
+ banner="Image pressed!"
242
+ image={placeholderImageUrl}
243
+ />
244
+ </View>
245
+ )
246
+ }
247
+
248
+ export const WithCustomBanner = () => (
249
+ <View style={styles.column}>
250
+ <ProductDisplayCard
251
+ title="Recipe Name"
252
+ subtext="+£0.00/pouch"
253
+ showSubtext
254
+ quantity={1}
255
+ showQuantityPicker
256
+ showBanner
257
+ bannerType="info"
258
+ showBannerIcon={false}
259
+ banner={
260
+ <View style={{ gap: 8 }}>
261
+ <View style={{ gap: 4 }}>
262
+ <Typography variant="body" size="sm" weight="semiBold">
263
+ Nutritional Benefits
264
+ </Typography>
265
+ </View>
266
+ <View style={{ gap: 4 }}>
267
+ <Typography variant="body" size="xs" color="secondary">
268
+ • High in protein
269
+ </Typography>
270
+ <Typography variant="body" size="xs" color="secondary">
271
+ • Natural ingredients
272
+ </Typography>
273
+ <Typography variant="body" size="xs" color="secondary">
274
+ • Vet approved
275
+ </Typography>
276
+ </View>
277
+ </View>
278
+ }
279
+ image={placeholderImageUrl}
280
+ />
281
+ </View>
282
+ )
@@ -1,5 +1,5 @@
1
1
  import React from "react"
2
- import { View, ViewProps, Image } from "react-native"
2
+ import { View, ViewProps, Image, Pressable } from "react-native"
3
3
  import styled from "@emotion/native"
4
4
  import { Typography } from "../../atoms/Typography"
5
5
  import { NumberField } from "../../molecules/NumberField"
@@ -17,25 +17,28 @@ export type ProductDisplayCardProps = ViewProps & {
17
17
  hideQuantityButtons?: boolean
18
18
  incrementDisabled?: boolean
19
19
  decrementDisabled?: boolean
20
+ minQuantity?: number
20
21
  image?: string | React.ReactNode
21
22
  imageBackgroundColor?: string
22
23
  thumbnailWidth?: number
23
- banner?: React.ReactNode
24
+ onImagePress?: () => void
25
+ banner?: string | React.ReactNode
24
26
  showBanner?: boolean
25
27
  bannerType?: "error" | "success" | "warning" | "info"
26
28
  showBannerIcon?: boolean
27
29
  }
28
30
 
29
- const StyledCardContainer = styled(View)(({ theme }) => {
31
+ const StyledCardContainer = styled(View, {
32
+ shouldForwardProp: (prop) => prop !== "showBanner"
33
+ })<{ showBanner?: boolean }>(({ theme }) => {
30
34
  const { spacing, borderRadius } = theme.tokens.semantics.dimensions
31
35
  const { colour } = theme.tokens.semantics
32
36
  const spacingMd = parseTokenValue(spacing.md)
33
37
  const spacingXl = parseTokenValue(spacing.xl)
38
+ const radiusMd = parseTokenValue(borderRadius.md)
34
39
  return {
35
40
  flexDirection: "row",
36
41
  width: "100%",
37
- // Cap at the Figma mobile card width (can be narrower — responsive).
38
- maxWidth: 358,
39
42
  // The 148px image sets the baseline height; the card grows if the content
40
43
  // is taller (e.g. a long or translated title wrapping to multiple lines)
41
44
  // so nothing is clipped.
@@ -48,9 +51,12 @@ const StyledCardContainer = styled(View)(({ theme }) => {
48
51
  shadowOpacity: 0.05,
49
52
  shadowRadius: spacingXl - 4,
50
53
  elevation: 5,
51
- borderRadius: parseTokenValue(borderRadius.md),
54
+ borderRadius: radiusMd,
52
55
  overflow: "hidden",
53
- zIndex: 1
56
+ zIndex: 1,
57
+ // Don't apply negative margin when banner is shown; banner wrapper handles positioning
58
+ marginBottom: 0,
59
+ flex: 1
54
60
  }
55
61
  })
56
62
 
@@ -88,20 +94,23 @@ const StyledTextContainer = styled(View)(() => ({
88
94
 
89
95
  const StyledBannerWrapper = styled(View)(({ theme }) => {
90
96
  const { spacing, borderRadius } = theme.tokens.semantics.dimensions
97
+ const radiusMd = parseTokenValue(borderRadius.md)
98
+ const spacingMd = parseTokenValue(spacing.md)
91
99
  return {
92
100
  width: "100%",
93
- // Tuck the banner up behind the card by the corner radius so its square top
94
- // corners hide under the card's rounded bottom; the rest sits below.
95
- marginTop: -parseTokenValue(borderRadius.md),
96
- paddingBottom: parseTokenValue(spacing.md)
101
+ // Tuck the banner up to overlap with card bottom; the 27px notification padding
102
+ // shows the cream reveal under the card's rounded corners
103
+ marginTop: -(radiusMd + spacingMd),
104
+ marginBottom: 0,
105
+ paddingTop: spacingMd,
106
+ paddingBottom: spacingMd
97
107
  }
98
108
  })
99
109
 
100
110
  const StyledRootWrapper = styled(View)(() => {
101
111
  return {
102
112
  flexDirection: "column",
103
- width: "100%",
104
- maxWidth: 358
113
+ width: "100%"
105
114
  }
106
115
  })
107
116
 
@@ -144,8 +153,8 @@ const StyledNotification = styled(Notification as any)(({ theme }) => {
144
153
  * @param quantity - *(optional)* Current quantity value
145
154
  * @param onQuantityChange - *(optional)* Callback when quantity changes
146
155
  * @param showQuantityPicker - *(optional)* Show/hide quantity picker
147
- * @param image - *(optional)* Image element or content
148
- * @param banner - *(optional)* Banner content to show below card
156
+ * @param image - *(optional)* Image URL string or React element
157
+ * @param banner - *(optional)* Banner content string or React element to show below card
149
158
  * @param showBanner - *(optional)* Show/hide banner
150
159
  * @param bannerType - *(optional)* Banner notification type: "error", "success", "warning", or "info" (default)
151
160
  * @param showBannerIcon - *(optional)* Show/hide banner notification icon
@@ -166,9 +175,11 @@ export const ProductDisplayCard = React.forwardRef<
166
175
  hideQuantityButtons = false,
167
176
  incrementDisabled = false,
168
177
  decrementDisabled = false,
178
+ minQuantity = 1,
169
179
  image,
170
180
  imageBackgroundColor,
171
181
  thumbnailWidth,
182
+ onImagePress,
172
183
  banner,
173
184
  showBanner = false,
174
185
  bannerType = "info",
@@ -178,28 +189,34 @@ export const ProductDisplayCard = React.forwardRef<
178
189
  ref
179
190
  ) => {
180
191
  const handleQuantityChange = (newQuantity: number) => {
181
- if (newQuantity >= 1) {
192
+ if (newQuantity >= minQuantity) {
182
193
  onQuantityChange?.(newQuantity)
183
194
  }
184
195
  }
185
196
 
186
197
  const cardContent = (
187
- <StyledCardContainer ref={ref} {...rest}>
198
+ <StyledCardContainer
199
+ ref={ref}
200
+ showBanner={showBanner && !!banner}
201
+ {...rest}
202
+ >
188
203
  {image && (
189
- <StyledImage
190
- imageBackgroundColor={imageBackgroundColor}
191
- thumbnailWidth={thumbnailWidth}
192
- >
193
- {typeof image === "string" ? (
194
- <Image
195
- source={{ uri: image }}
196
- resizeMode="cover"
197
- style={{ flex: 1, width: "100%" }}
198
- />
199
- ) : (
200
- image
201
- )}
202
- </StyledImage>
204
+ <Pressable disabled={!onImagePress} onPress={onImagePress}>
205
+ <StyledImage
206
+ imageBackgroundColor={imageBackgroundColor}
207
+ thumbnailWidth={thumbnailWidth}
208
+ >
209
+ {typeof image === "string" ? (
210
+ <Image
211
+ source={{ uri: image }}
212
+ resizeMode="cover"
213
+ style={{ flex: 1, width: "100%" }}
214
+ />
215
+ ) : (
216
+ image
217
+ )}
218
+ </StyledImage>
219
+ </Pressable>
203
220
  )}
204
221
 
205
222
  <StyledContentArea>
@@ -228,7 +245,7 @@ export const ProductDisplayCard = React.forwardRef<
228
245
  }}
229
246
  onIncrement={() => handleQuantityChange(quantity + 1)}
230
247
  onDecrement={() => handleQuantityChange(quantity - 1)}
231
- min={1}
248
+ min={minQuantity}
232
249
  accessible
233
250
  accessibilityLabel={`Quantity: ${quantity}`}
234
251
  showIncrementButton={!hideQuantityButtons}
@@ -1,5 +1,5 @@
1
1
  import React from "react"
2
- import { View } from "react-native"
2
+ import { View, Text } from "react-native"
3
3
  import { ProductListingCard } from "./ProductListingCard"
4
4
  import { Badge } from "./Badge"
5
5
  import { ThumbsUpFilledPrimary } from "@butternutbox/pawprint-icons/marketing"
@@ -207,3 +207,67 @@ export const WithWasPrice = {
207
207
  </View>
208
208
  )
209
209
  }
210
+
211
+ export const WithStringImage = {
212
+ render: () => (
213
+ <View style={{ width: 232, alignSelf: "flex-start" }}>
214
+ <ProductListingCard
215
+ image="https://placehold.co/232x232/E8E8E8/666?text=Product"
216
+ imageAlt="Product Image"
217
+ badge={defaultBadge}
218
+ category="Daily supplements"
219
+ title="Fish Oil"
220
+ size="200ml"
221
+ price="From £7.49"
222
+ showBadge={true}
223
+ showCategory={true}
224
+ showTitle={true}
225
+ showSize={true}
226
+ showPrice={true}
227
+ />
228
+ </View>
229
+ )
230
+ }
231
+
232
+ export const WithCustomComponent = {
233
+ render: () => (
234
+ <View style={{ width: 232, alignSelf: "flex-start" }}>
235
+ <ProductListingCard
236
+ image={
237
+ <View
238
+ style={{
239
+ flex: 1,
240
+ backgroundColor: "#F5E6D3",
241
+ justifyContent: "center",
242
+ alignItems: "center"
243
+ }}
244
+ >
245
+ <View
246
+ style={{
247
+ width: 80,
248
+ height: 80,
249
+ backgroundColor: "#D4A574",
250
+ borderRadius: 40,
251
+ justifyContent: "center",
252
+ alignItems: "center"
253
+ }}
254
+ >
255
+ <Text style={{ fontSize: 40 }}>🐟</Text>
256
+ </View>
257
+ </View>
258
+ }
259
+ imageAlt="Custom component"
260
+ badge={defaultBadge}
261
+ category="Daily supplements"
262
+ title="Fish Oil"
263
+ size="200ml"
264
+ price="From £7.49"
265
+ showBadge={true}
266
+ showCategory={true}
267
+ showTitle={true}
268
+ showSize={true}
269
+ showPrice={true}
270
+ />
271
+ </View>
272
+ )
273
+ }
@@ -12,7 +12,7 @@ import { Badge } from "./Badge"
12
12
  import { Grid } from "./Grid"
13
13
 
14
14
  type ProductListingCardOwnProps = {
15
- image?: ImageSourcePropType
15
+ image?: string | React.ReactNode | ImageSourcePropType
16
16
  imageAlt?: string
17
17
  badge?: React.ReactNode
18
18
  category?: string
@@ -145,11 +145,21 @@ const _ProductListingCard = React.forwardRef<View, ProductListingCardProps>(
145
145
  <ImageContainer>
146
146
  {image && (
147
147
  <View style={{ flex: 1, width: "100%", height: "100%" }}>
148
- <StyledImage
149
- source={image}
150
- accessibilityLabel={imageAlt}
151
- accessible
152
- />
148
+ {typeof image === "string" ? (
149
+ <StyledImage
150
+ source={{ uri: image }}
151
+ accessibilityLabel={imageAlt}
152
+ accessible
153
+ />
154
+ ) : React.isValidElement(image) ? (
155
+ image
156
+ ) : (
157
+ <StyledImage
158
+ source={image as ImageSourcePropType}
159
+ accessibilityLabel={imageAlt}
160
+ accessible
161
+ />
162
+ )}
153
163
  </View>
154
164
  )}
155
165
  {showBadge && badge && <BadgeContainer>{badge}</BadgeContainer>}