@butternutbox/pawprint-native 0.10.4 → 0.10.6

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.10.4",
3
+ "version": "0.10.6",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -273,6 +273,54 @@ Playground.args = {
273
273
  defaultOpen: false
274
274
  }
275
275
 
276
+ export const WithTopContent = () => {
277
+ const [showToast, setShowToast] = useState(false)
278
+
279
+ return (
280
+ <View style={styles.container}>
281
+ <Drawer.Root>
282
+ <Drawer.Trigger>
283
+ <Button variant="filled" colour="primary">
284
+ Open drawer with overlay
285
+ </Button>
286
+ </Drawer.Trigger>
287
+ <Drawer.Portal
288
+ topContent={
289
+ showToast ? (
290
+ <View style={styles.toast}>
291
+ <Typography>Action saved!</Typography>
292
+ </View>
293
+ ) : null
294
+ }
295
+ >
296
+ <Drawer.Overlay />
297
+ <Drawer.Content>
298
+ <Drawer.Header variant="titleAndText">
299
+ <Drawer.Title>Floating content demo</Drawer.Title>
300
+ <Drawer.Close />
301
+ </Drawer.Header>
302
+ <Drawer.Body>
303
+ <Typography>{bodyText}</Typography>
304
+ </Drawer.Body>
305
+ <Drawer.Footer>
306
+ <Button
307
+ variant="filled"
308
+ colour="primary"
309
+ onPress={() => {
310
+ setShowToast(true)
311
+ setTimeout(() => setShowToast(false), 2000)
312
+ }}
313
+ >
314
+ Show overlay
315
+ </Button>
316
+ </Drawer.Footer>
317
+ </Drawer.Content>
318
+ </Drawer.Portal>
319
+ </Drawer.Root>
320
+ </View>
321
+ )
322
+ }
323
+
276
324
  const styles = StyleSheet.create({
277
325
  container: {
278
326
  padding: 16
@@ -281,5 +329,15 @@ const styles = StyleSheet.create({
281
329
  flexDirection: "row",
282
330
  gap: 12,
283
331
  marginBottom: 16
332
+ },
333
+ toast: {
334
+ position: "absolute",
335
+ top: 60,
336
+ left: 16,
337
+ right: 16,
338
+ backgroundColor: "#000",
339
+ padding: 16,
340
+ borderRadius: 8,
341
+ zIndex: 100
284
342
  }
285
343
  })
@@ -135,15 +135,19 @@ DrawerTrigger.displayName = "Drawer.Trigger"
135
135
 
136
136
  // ─── Portal ───────────────────────────────────────────────────────────────────
137
137
 
138
- type DrawerPortalProps = {
138
+ export type DrawerPortalProps = {
139
139
  children: React.ReactNode
140
+ topContent?: React.ReactNode
140
141
  }
141
142
 
142
143
  /**
143
144
  * Renders its children above the rest of the app using React Native `Modal`.
144
145
  * The modal stays mounted during exit animations and unmounts once complete.
146
+ *
147
+ * @param children - Drawer content (Overlay, Content, etc.)
148
+ * @param topContent - *(optional)* Additional content rendered on top of the drawer
145
149
  */
146
- const DrawerPortal = ({ children }: DrawerPortalProps) => {
150
+ const DrawerPortal = ({ children, topContent }: DrawerPortalProps) => {
147
151
  const { modalVisible, closeDrawer } = React.useContext(DrawerContext)
148
152
 
149
153
  if (!modalVisible) return null
@@ -156,7 +160,10 @@ const DrawerPortal = ({ children }: DrawerPortalProps) => {
156
160
  statusBarTranslucent
157
161
  onRequestClose={closeDrawer}
158
162
  >
159
- <View style={styles.modalContainer}>{children}</View>
163
+ <View style={styles.modalContainer}>
164
+ {children}
165
+ {topContent}
166
+ </View>
160
167
  </Modal>
161
168
  )
162
169
  }
@@ -1,5 +1,9 @@
1
1
  export { Drawer } from "./Drawer"
2
- export type { DrawerProps, DrawerTriggerProps } from "./Drawer"
2
+ export type {
3
+ DrawerProps,
4
+ DrawerTriggerProps,
5
+ DrawerPortalProps
6
+ } from "./Drawer"
3
7
  export type { DrawerContentProps } from "./DrawerContent"
4
8
  export type { DrawerOverlayProps } from "./DrawerOverlay"
5
9
  export type { DrawerGrabberProps } from "./DrawerGrabber"
@@ -202,6 +202,25 @@ export const Controlled = () => {
202
202
  )
203
203
  }
204
204
 
205
+ export const FullWidth = () => {
206
+ const [value, setValue] = useState(5)
207
+
208
+ return (
209
+ <View style={styles.section}>
210
+ <NumberField
211
+ label="Weight"
212
+ size="lg"
213
+ fullWidth
214
+ description="Help text"
215
+ value={String(value)}
216
+ onChangeText={(t) => setValue(Number(t) || 0)}
217
+ onIncrement={() => setValue((v) => v + 1)}
218
+ onDecrement={() => setValue((v) => Math.max(0, v - 1))}
219
+ />
220
+ </View>
221
+ )
222
+ }
223
+
205
224
  export const Playground = {
206
225
  args: {
207
226
  size: "lg",
@@ -19,6 +19,7 @@ type NumberFieldOwnProps = {
19
19
  state?: NumberFieldState
20
20
  size?: NumberFieldSize
21
21
  disabled?: boolean
22
+ fullWidth?: boolean
22
23
  onIncrement?: () => void
23
24
  onDecrement?: () => void
24
25
  showIncrementButton?: boolean
@@ -37,9 +38,10 @@ const parseTokenValue = (value: string): number => parseFloat(value)
37
38
 
38
39
  const StyledRoot = styled(View)<{
39
40
  rootGap: number
40
- }>(({ rootGap }) => ({
41
- alignItems: "center",
42
- gap: rootGap
41
+ fullWidth?: boolean
42
+ }>(({ rootGap, fullWidth }) => ({
43
+ gap: rootGap,
44
+ ...(fullWidth && { width: "100%" })
43
45
  }))
44
46
 
45
47
  const StyledLabelGroup = styled(View)<{
@@ -60,6 +62,7 @@ const StyledLabelGroup = styled(View)<{
60
62
  * @param {string} [error] - Error message displayed when state is "error".
61
63
  * @param {"default" | "error" | "success"} [state="default"] - Visual state of the field.
62
64
  * @param {boolean} [disabled=false] - Whether the entire field (input + buttons) is disabled.
65
+ * @param {boolean} [fullWidth=false] - Whether the field should expand to fill available width.
63
66
  * @param {() => void} [onIncrement] - Called when the + button is pressed.
64
67
  * @param {() => void} [onDecrement] - Called when the - button is pressed.
65
68
  * @param {boolean} [showIncrementButton=true] - Whether to show the + button.
@@ -76,6 +79,7 @@ const StyledLabelGroup = styled(View)<{
76
79
  * <NumberField
77
80
  * label="Weight"
78
81
  * size="lg"
82
+ * fullWidth
79
83
  * defaultValue="0"
80
84
  * onIncrement={() => setValue(v => v + 1)}
81
85
  * onDecrement={() => setValue(v => v - 1)}
@@ -94,6 +98,7 @@ export const NumberField = React.forwardRef<View, NumberFieldProps>(
94
98
  state = "default",
95
99
  size = "lg",
96
100
  disabled,
101
+ fullWidth,
97
102
  onIncrement,
98
103
  onDecrement,
99
104
  showIncrementButton,
@@ -114,7 +119,11 @@ export const NumberField = React.forwardRef<View, NumberFieldProps>(
114
119
  size === "lg" ? tokens.typography.large : tokens.typography.small
115
120
 
116
121
  return (
117
- <StyledRoot ref={ref} rootGap={parseTokenValue(sizeTokens.gap)}>
122
+ <StyledRoot
123
+ ref={ref}
124
+ rootGap={parseTokenValue(sizeTokens.gap)}
125
+ fullWidth={fullWidth}
126
+ >
118
127
  {(label || smallLabel) && (
119
128
  <StyledLabelGroup
120
129
  labelGap={parseTokenValue(
@@ -142,6 +151,7 @@ export const NumberField = React.forwardRef<View, NumberFieldProps>(
142
151
  <NumberFieldInput
143
152
  fieldSize={size}
144
153
  state={state}
154
+ fullWidth={fullWidth}
145
155
  disabled={disabled}
146
156
  onIncrement={onIncrement}
147
157
  onDecrement={onDecrement}
@@ -11,6 +11,7 @@ type NumberFieldSize = "sm" | "lg"
11
11
  type NumberFieldInputOwnProps = {
12
12
  fieldSize?: NumberFieldSize
13
13
  state?: "default" | "error" | "success"
14
+ fullWidth?: boolean
14
15
  onIncrement?: () => void
15
16
  onDecrement?: () => void
16
17
  showIncrementButton?: boolean
@@ -34,6 +35,7 @@ const StyledFieldWrapper = styled(View)<{
34
35
  fieldMinWidth: number
35
36
  fieldHeight: number
36
37
  fieldPaddingHorizontal: number
38
+ fullWidth?: boolean
37
39
  }>(
38
40
  ({
39
41
  fieldBgColor,
@@ -41,18 +43,21 @@ const StyledFieldWrapper = styled(View)<{
41
43
  fieldBorderRadius,
42
44
  fieldMinWidth,
43
45
  fieldHeight,
44
- fieldPaddingHorizontal
46
+ fieldPaddingHorizontal,
47
+ fullWidth
45
48
  }) => ({
46
49
  alignItems: "center",
47
50
  justifyContent: "center",
48
- width: fieldMinWidth,
51
+ width: fullWidth ? "100%" : fieldMinWidth,
52
+ minWidth: fieldMinWidth,
49
53
  height: fieldHeight,
50
54
  paddingHorizontal: fieldPaddingHorizontal,
51
55
  backgroundColor: fieldBgColor,
52
56
  borderWidth: 1,
53
57
  borderColor: fieldBorderColor,
54
58
  borderRadius: fieldBorderRadius,
55
- overflow: "hidden"
59
+ overflow: "hidden",
60
+ ...(fullWidth && { flex: 1 })
56
61
  })
57
62
  )
58
63
 
@@ -85,8 +90,7 @@ const StyledTextInput = styled(TextInput)<{
85
90
  color: inputColor,
86
91
  fontFamily: inputFontFamily,
87
92
  ...(inputFontWeight ? { fontWeight: inputFontWeight } : {}),
88
- fontSize: inputFontSize,
89
- lineHeight: 0
93
+ fontSize: inputFontSize
90
94
  }))
91
95
 
92
96
  const StyledRow = styled(View)<{
@@ -94,7 +98,6 @@ const StyledRow = styled(View)<{
94
98
  }>(({ rowGap }) => ({
95
99
  flexDirection: "row",
96
100
  alignItems: "center",
97
- alignSelf: "center",
98
101
  gap: rowGap
99
102
  }))
100
103
 
@@ -117,6 +120,7 @@ export const NumberFieldInput = React.forwardRef<
117
120
  {
118
121
  fieldSize = "lg",
119
122
  state = "default",
123
+ fullWidth,
120
124
  onIncrement,
121
125
  onDecrement,
122
126
  showIncrementButton = true,
@@ -236,6 +240,7 @@ export const NumberFieldInput = React.forwardRef<
236
240
  fieldMinWidth={fieldMinWidth}
237
241
  fieldHeight={fieldHeight}
238
242
  fieldPaddingHorizontal={fieldPaddingHorizontal}
243
+ fullWidth={fullWidth}
239
244
  >
240
245
  <StyledTextInput
241
246
  ref={ref}
@@ -69,12 +69,9 @@ export default {
69
69
  },
70
70
  bannerType: {
71
71
  control: { type: "select" },
72
- options: ["error", "success", "warning", "info"],
73
- description: "Banner notification type"
74
- },
75
- showBannerIcon: {
76
- control: { type: "boolean" },
77
- description: "Show/hide banner notification icon"
72
+ options: ["none", "error", "info"],
73
+ description:
74
+ "Banner variant: none (no icon), error (with icon), info (with icon)"
78
75
  }
79
76
  }
80
77
  }
@@ -91,8 +88,7 @@ Playground.args = {
91
88
  quantity: 1,
92
89
  showQuantityPicker: true,
93
90
  showBanner: true,
94
- bannerType: "info",
95
- showBannerIcon: false,
91
+ bannerType: "none",
96
92
  banner: " High in protein • Natural ingredients • Vet approved",
97
93
  image: placeholderImageUrl
98
94
  }
@@ -134,8 +130,6 @@ export const WithBanner = () => (
134
130
  quantity={1}
135
131
  showQuantityPicker
136
132
  showBanner
137
- bannerType="success"
138
- showBannerIcon={false}
139
133
  banner="High in protein • Natural ingredients • Vet approved"
140
134
  image={placeholderImageUrl}
141
135
  />
@@ -151,8 +145,7 @@ export const WithBannerIcon = () => (
151
145
  quantity={1}
152
146
  showQuantityPicker
153
147
  showBanner
154
- bannerType="success"
155
- showBannerIcon
148
+ bannerType="info"
156
149
  banner="High in protein • Natural ingredients • Vet approved"
157
150
  image={placeholderImageUrl}
158
151
  />
@@ -217,8 +210,6 @@ export const WithMinQuantity = () => (
217
210
  minQuantity={5}
218
211
  showQuantityPicker
219
212
  showBanner
220
- bannerType="info"
221
- showBannerIcon={false}
222
213
  banner="Minimum quantity: 5 pouches required"
223
214
  image={placeholderImageUrl}
224
215
  />
@@ -237,7 +228,7 @@ export const WithImagePress = () => {
237
228
  showQuantityPicker
238
229
  onImagePress={() => setPressed(true)}
239
230
  showBanner={pressed}
240
- bannerType="success"
231
+ bannerType="info"
241
232
  banner="Image pressed!"
242
233
  image={placeholderImageUrl}
243
234
  />
@@ -159,7 +159,6 @@ describe("ProductDisplayCard (Native)", () => {
159
159
  title="Test Product"
160
160
  showBanner={true}
161
161
  banner={<Text>Test Banner</Text>}
162
- bannerType="success"
163
162
  />
164
163
  )
165
164
 
@@ -177,13 +176,13 @@ describe("ProductDisplayCard (Native)", () => {
177
176
  expect(screen.getByText("Error Banner")).toBeTruthy()
178
177
  })
179
178
 
180
- it("displays banner with icon when showBannerIcon is true", () => {
179
+ it("displays banner with icon when bannerType is info", () => {
181
180
  renderWithTheme(
182
181
  <ProductDisplayCard
183
182
  title="Test Product"
184
183
  showBanner={true}
184
+ bannerType="info"
185
185
  banner={<Text>Test Banner</Text>}
186
- showBannerIcon={true}
187
186
  />
188
187
  )
189
188
 
@@ -1,6 +1,7 @@
1
1
  import React from "react"
2
2
  import { View, ViewProps, Image, Pressable } from "react-native"
3
3
  import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
4
5
  import { Typography } from "../../atoms/Typography"
5
6
  import { NumberField } from "../../molecules/NumberField"
6
7
  import { Notification } from "../Notification"
@@ -24,7 +25,7 @@ export type ProductDisplayCardProps = ViewProps & {
24
25
  onImagePress?: () => void
25
26
  banner?: string | React.ReactNode
26
27
  showBanner?: boolean
27
- bannerType?: "error" | "success" | "warning" | "info"
28
+ bannerType?: "error" | "info" | "none"
28
29
  showBannerIcon?: boolean
29
30
  }
30
31
 
@@ -132,6 +133,23 @@ const StyledNotification = styled(Notification as any)(({ theme }) => {
132
133
  }
133
134
  })
134
135
 
136
+ const StyledNoneBanner = styled(View)(({ theme }) => {
137
+ const { spacing, borderRadius } = theme.tokens.semantics.dimensions
138
+ const { colour } = theme.tokens.semantics
139
+ const spacingMd = parseTokenValue(spacing.md)
140
+ const radiusMd = parseTokenValue(borderRadius.md)
141
+ return {
142
+ paddingHorizontal: spacingMd,
143
+ paddingTop: 27,
144
+ paddingBottom: spacingMd,
145
+ backgroundColor: colour.background.container.secondary,
146
+ borderTopLeftRadius: 0,
147
+ borderTopRightRadius: 0,
148
+ borderBottomLeftRadius: radiusMd,
149
+ borderBottomRightRadius: radiusMd
150
+ }
151
+ })
152
+
135
153
  /**
136
154
  * Product Display Card component for showing product information with optional quantity picker.
137
155
  *
@@ -156,8 +174,8 @@ const StyledNotification = styled(Notification as any)(({ theme }) => {
156
174
  * @param image - *(optional)* Image URL string or React element
157
175
  * @param banner - *(optional)* Banner content string or React element to show below card
158
176
  * @param showBanner - *(optional)* Show/hide banner
159
- * @param bannerType - *(optional)* Banner notification type: "error", "success", "warning", or "info" (default)
160
- * @param showBannerIcon - *(optional)* Show/hide banner notification icon
177
+ * @param bannerType - *(optional)* Banner variant: "error" (with icon), "info" (with icon), or "none" (no icon, default)
178
+ * @param showBannerIcon - *(optional)* Show/hide icon in banner (default: true)
161
179
  */
162
180
  export const ProductDisplayCard = React.forwardRef<
163
181
  View,
@@ -182,8 +200,8 @@ export const ProductDisplayCard = React.forwardRef<
182
200
  onImagePress,
183
201
  banner,
184
202
  showBanner = false,
185
- bannerType = "info",
186
- showBannerIcon = false,
203
+ bannerType = "none",
204
+ showBannerIcon = true,
187
205
  ...rest
188
206
  },
189
207
  ref
@@ -260,17 +278,33 @@ export const ProductDisplayCard = React.forwardRef<
260
278
  )
261
279
 
262
280
  if (showBanner && banner) {
281
+ const theme = useTheme()
282
+ const { notifications } = theme.tokens.components
283
+ const notificationTypography = notifications.typography.body
284
+ const notificationColour = notifications.notification.colour.text.body
285
+
263
286
  return (
264
287
  <StyledRootWrapper>
265
288
  {cardContent}
266
289
  <StyledBannerWrapper>
267
- <StyledNotification
268
- variant="inline"
269
- type={bannerType}
270
- showIcon={showBannerIcon}
271
- >
272
- {banner}
273
- </StyledNotification>
290
+ {bannerType === "none" ? (
291
+ <StyledNoneBanner>
292
+ <Typography
293
+ token={notificationTypography}
294
+ color={notificationColour}
295
+ >
296
+ {banner}
297
+ </Typography>
298
+ </StyledNoneBanner>
299
+ ) : (
300
+ <StyledNotification
301
+ variant="inline"
302
+ type={bannerType}
303
+ showIcon={showBannerIcon}
304
+ >
305
+ {banner}
306
+ </StyledNotification>
307
+ )}
274
308
  </StyledBannerWrapper>
275
309
  </StyledRootWrapper>
276
310
  )
@@ -187,6 +187,70 @@ export const CompoundComponents = () => (
187
187
  </View>
188
188
  )
189
189
 
190
+ export const CompoundTiles = () => (
191
+ <View style={styles.column}>
192
+ <View style={styles.section}>
193
+ <Typography size="sm" weight="semiBold" color="tertiary">
194
+ Basic tiles
195
+ </Typography>
196
+ <RadioGroup name="compound-tiles" defaultValue="medium">
197
+ <RadioGroup.Tile value="small" label="Small" subText="For tiny dogs" />
198
+ <RadioGroup.Tile
199
+ value="medium"
200
+ label="Medium"
201
+ subText="For medium dogs"
202
+ />
203
+ <RadioGroup.Tile value="large" label="Large" subText="For big dogs" />
204
+ </RadioGroup>
205
+ </View>
206
+
207
+ <View style={styles.section}>
208
+ <Typography size="sm" weight="semiBold" color="tertiary">
209
+ Tiles with tags
210
+ </Typography>
211
+ <RadioGroup name="tiles-with-tags" defaultValue="1">
212
+ <RadioGroup.Tile
213
+ value="1"
214
+ label="1 pouch over 2 days"
215
+ subText="400g pouches"
216
+ tag={{ children: "Save up to £12.20 per box" }}
217
+ />
218
+ <RadioGroup.Tile
219
+ value="2"
220
+ label="1 pouch per day"
221
+ subText="200g pouches"
222
+ tag={{ children: "Most popular", variant: "promo" }}
223
+ />
224
+ <RadioGroup.Tile
225
+ value="3"
226
+ label="Custom frequency"
227
+ subText="Choose your own schedule"
228
+ />
229
+ </RadioGroup>
230
+ </View>
231
+
232
+ <View style={styles.section}>
233
+ <Typography size="sm" weight="semiBold" color="tertiary">
234
+ Tiles with icons and tags
235
+ </Typography>
236
+ <RadioGroup name="tiles-with-icons" defaultValue="1">
237
+ <RadioGroup.Tile
238
+ value="1"
239
+ label="Most popular"
240
+ subText="Recommended for most dogs"
241
+ tag={{ children: "Best value", variant: "promo", icon: StarRate }}
242
+ />
243
+ <RadioGroup.Tile
244
+ value="2"
245
+ label="Flexible plan"
246
+ subText="Change anytime"
247
+ tag={{ children: "New", variant: "success" }}
248
+ />
249
+ </RadioGroup>
250
+ </View>
251
+ </View>
252
+ )
253
+
190
254
  export const Controlled = () => {
191
255
  const [value, setValue] = useState("chicken")
192
256
 
@@ -264,3 +264,13 @@ export const Radio = React.forwardRef<View, RadioProps>(
264
264
  )
265
265
 
266
266
  Radio.displayName = "Radio"
267
+
268
+ /**
269
+ * RadioTile component for tile-variant radio buttons.
270
+ * Convenience wrapper around Radio with variant="tile" preset.
271
+ */
272
+ export const RadioTile = React.forwardRef<View, Omit<RadioProps, "variant">>(
273
+ (props, ref) => <Radio ref={ref} {...props} variant="tile" />
274
+ )
275
+
276
+ RadioTile.displayName = "RadioTile"
@@ -2,7 +2,7 @@ import React from "react"
2
2
  import { View, ViewProps } from "react-native"
3
3
  import styled from "@emotion/native"
4
4
  import { useTheme } from "@emotion/react"
5
- import { Radio, type RadioOwnProps } from "./Radio"
5
+ import { Radio, RadioTile, type RadioOwnProps } from "./Radio"
6
6
 
7
7
  type RadioGroupOwnProps = {
8
8
  name: string
@@ -135,8 +135,10 @@ type RadioGroupComponent = React.ForwardRefExoticComponent<
135
135
  RadioGroupProps & React.RefAttributes<View>
136
136
  > & {
137
137
  Radio: typeof Radio
138
+ Tile: typeof RadioTile
138
139
  }
139
140
 
140
141
  export const RadioGroup = Object.assign(RadioGroupRoot, {
141
- Radio
142
+ Radio,
143
+ Tile: RadioTile
142
144
  }) as RadioGroupComponent
@@ -1,4 +1,4 @@
1
- export { Radio } from "./Radio"
1
+ export { Radio, RadioTile } from "./Radio"
2
2
  export type { RadioProps, RadioOwnProps } from "./Radio"
3
3
  export { RadioGroup } from "./RadioGroup"
4
4
  export type { RadioGroupProps } from "./RadioGroup"