@butternutbox/pawprint-native 0.10.5 → 0.10.7

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.5",
3
+ "version": "0.10.7",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -10,6 +10,7 @@ import * as poses from "@butternutbox/pawprint-illustrations/poses"
10
10
  import * as usps from "@butternutbox/pawprint-illustrations/usps"
11
11
  import * as breeds from "@butternutbox/pawprint-illustrations/breeds"
12
12
  import * as breedSizes from "@butternutbox/pawprint-illustrations/breed-sizes"
13
+ import * as traits from "@butternutbox/pawprint-illustrations/traits"
13
14
 
14
15
  function isComponent(value: unknown): value is PawprintIllustration {
15
16
  if (typeof value === "function") return true
@@ -21,7 +22,8 @@ const categorised: Record<string, Record<string, unknown>> = {
21
22
  poses,
22
23
  usps,
23
24
  breeds,
24
- "breed-sizes": breedSizes
25
+ "breed-sizes": breedSizes,
26
+ traits
25
27
  }
26
28
 
27
29
  const illustrationMap: Record<string, PawprintIllustration> = {}
@@ -26,6 +26,7 @@ type InputFieldOwnProps = {
26
26
  hideStateIcons?: boolean
27
27
  containerWidth?: number
28
28
  containerHeight?: number
29
+ hasInlineHelp?: boolean
29
30
  }
30
31
 
31
32
  export type InputFieldProps = InputFieldOwnProps &
@@ -60,7 +61,10 @@ const StyledInputWrapper = styled(Animated.View)<{
60
61
  }
61
62
  })
62
63
 
63
- const StyledInput = styled(TextInput)(({ theme }) => {
64
+ const StyledInput = styled(TextInput)<{ hasInlineHelp?: boolean }>(({
65
+ theme,
66
+ hasInlineHelp
67
+ }) => {
64
68
  const { colour, field } = theme.tokens.components.inputs
65
69
  const typography = field.placeholder.default
66
70
  const { fontFamily } = resolveFont(
@@ -71,6 +75,7 @@ const StyledInput = styled(TextInput)(({ theme }) => {
71
75
  const letterSpacing = typography.letterSpacing?.endsWith("%")
72
76
  ? (parseFloat(typography.letterSpacing) / 100) * fontSize
73
77
  : parseFloat(typography.letterSpacing ?? "0")
78
+
74
79
  return {
75
80
  flex: 1,
76
81
  minWidth: 0,
@@ -80,6 +85,7 @@ const StyledInput = styled(TextInput)(({ theme }) => {
80
85
  fontFamily,
81
86
  fontSize,
82
87
  letterSpacing,
88
+ ...(hasInlineHelp ? { position: "relative", top: -8 } : {}),
83
89
  // Suppress the browser default focus outline on React Native Web —
84
90
  // the focus indicator is rendered on the wrapper instead.
85
91
  ...({
@@ -120,6 +126,7 @@ export const InputField = React.forwardRef<TextInput, InputFieldProps>(
120
126
  hideStateIcons,
121
127
  containerWidth,
122
128
  containerHeight,
129
+ hasInlineHelp,
123
130
  style,
124
131
  editable,
125
132
  onFocus,
@@ -270,6 +277,7 @@ export const InputField = React.forwardRef<TextInput, InputFieldProps>(
270
277
  editable={editable}
271
278
  onFocus={handleFocus}
272
279
  onBlur={handleBlur}
280
+ hasInlineHelp={hasInlineHelp}
273
281
  {...rest}
274
282
  />
275
283
  {trailingIcon && <StyledIconSlot>{trailingIcon}</StyledIconSlot>}
@@ -31,6 +31,10 @@ export default {
31
31
  fieldText: {
32
32
  control: { type: "text" },
33
33
  description: "Unit label shown to the right of the input (e.g. kg, lbs)"
34
+ },
35
+ inlineHelpText: {
36
+ control: { type: "text" },
37
+ description: "Help text displayed below the input field"
34
38
  }
35
39
  }
36
40
  }
@@ -155,6 +159,24 @@ export const CustomStateValidationWithSuccess = () => {
155
159
  )
156
160
  }
157
161
 
162
+ export const WithInlineHelpText = () => (
163
+ <View style={styles.column}>
164
+ <NumberInput
165
+ label="Daily amount"
166
+ placeholder="0"
167
+ inlineHelpText="~ 840 kcals"
168
+ description="Help text"
169
+ />
170
+ <NumberInput
171
+ label="Quantity"
172
+ placeholder="0"
173
+ inlineHelpText="per serving"
174
+ fieldText="portions"
175
+ description="Additional context"
176
+ />
177
+ </View>
178
+ )
179
+
158
180
  export const Controlled = () => {
159
181
  const [value, setValue] = React.useState("")
160
182
 
@@ -13,6 +13,7 @@ type NumberInputOwnProps = Pick<
13
13
  "label" | "description" | "error" | "state" | "optionalText" | "onValueChange"
14
14
  > & {
15
15
  fieldText?: string
16
+ inlineHelpText?: string
16
17
  }
17
18
 
18
19
  export type NumberInputProps = NumberInputOwnProps &
@@ -60,7 +61,8 @@ const StyledRoot = styled(View)(({ theme }) => {
60
61
  * </NumberInput.Root>
61
62
  *
62
63
  * @param {string} [label] - Label text (props API)
63
- * @param {string} [description] - Description/help text (props API)
64
+ * @param {string} [description] - Description/help text below the field (props API)
65
+ * @param {string} [inlineHelpText] - Help text displayed below the input field (props API)
64
66
  * @param {string} [error] - Error message to display (props API, does not affect visual state - use state prop for that)
65
67
  * @param {InputState} [state] - Visual state of the input: 'default', 'error', or 'success' (props API)
66
68
  * @param {string} [optionalText] - Optional text to display next to label (props API)
@@ -77,6 +79,7 @@ const NumberInputRoot = React.forwardRef<View, NumberInputProps>(
77
79
  optionalText,
78
80
  onValueChange,
79
81
  fieldText,
82
+ inlineHelpText,
80
83
  children,
81
84
  ...inputProps
82
85
  },
@@ -97,6 +100,7 @@ const NumberInputRoot = React.forwardRef<View, NumberInputProps>(
97
100
  state={state}
98
101
  onChangeText={onValueChange}
99
102
  fieldText={fieldText}
103
+ inlineHelpText={inlineHelpText}
100
104
  {...inputProps}
101
105
  />
102
106
  {description && (
@@ -21,8 +21,16 @@ const StyledFieldTextWrapper = styled(View)({
21
21
  justifyContent: "center"
22
22
  })
23
23
 
24
+ const StyledInputContainer = styled(View)<{
25
+ hasInlineHelp?: boolean
26
+ }>(({ hasInlineHelp }) => ({
27
+ flexDirection: "column",
28
+ gap: hasInlineHelp ? 2 : 0
29
+ }))
30
+
24
31
  type NumberInputFieldOwnProps = {
25
32
  fieldText?: string
33
+ inlineHelpText?: string
26
34
  }
27
35
 
28
36
  export type NumberInputFieldProps = NumberInputFieldOwnProps &
@@ -36,7 +44,7 @@ export type NumberInputFieldProps = NumberInputFieldOwnProps &
36
44
  export const NumberInputField = React.forwardRef<
37
45
  TextInput,
38
46
  NumberInputFieldProps
39
- >(({ fieldText, ...props }, ref) => {
47
+ >(({ fieldText, inlineHelpText, ...props }, ref) => {
40
48
  const theme = useTheme()
41
49
  const { colour, description } = theme.tokens.components.inputs
42
50
  const fieldSize = parseTokenValue(
@@ -48,30 +56,85 @@ export const NumberInputField = React.forwardRef<
48
56
  keyboardType="numeric"
49
57
  hideStateIcons
50
58
  containerWidth={fieldSize}
51
- containerHeight={fieldSize}
59
+ containerHeight={inlineHelpText ? undefined : fieldSize}
60
+ hasInlineHelp={!!inlineHelpText}
52
61
  ref={ref}
53
62
  style={{ textAlign: "center" }}
54
63
  {...props}
55
64
  />
56
65
  )
57
66
 
58
- if (!fieldText) {
59
- return inputElement
60
- }
61
-
62
- return (
63
- <StyledFieldGroup>
64
- {inputElement}
65
- <StyledFieldTextWrapper>
66
- <Typography
67
- token={description.text.default}
68
- color={colour.description.default}
67
+ const fieldContent =
68
+ inlineHelpText && fieldText ? (
69
+ <StyledFieldGroup>
70
+ <StyledInputContainer hasInlineHelp>
71
+ {inputElement}
72
+ <View
73
+ style={{
74
+ position: "absolute",
75
+ bottom: 4,
76
+ left: 0,
77
+ width: fieldSize,
78
+ display: "flex",
79
+ justifyContent: "center",
80
+ alignItems: "center"
81
+ }}
82
+ >
83
+ <Typography
84
+ token={description.text.default}
85
+ color={colour.description.default}
86
+ >
87
+ {inlineHelpText}
88
+ </Typography>
89
+ </View>
90
+ </StyledInputContainer>
91
+ <StyledFieldTextWrapper>
92
+ <Typography
93
+ token={description.text.default}
94
+ color={colour.description.default}
95
+ >
96
+ {fieldText}
97
+ </Typography>
98
+ </StyledFieldTextWrapper>
99
+ </StyledFieldGroup>
100
+ ) : fieldText ? (
101
+ <StyledFieldGroup>
102
+ {inputElement}
103
+ <StyledFieldTextWrapper>
104
+ <Typography
105
+ token={description.text.default}
106
+ color={colour.description.default}
107
+ >
108
+ {fieldText}
109
+ </Typography>
110
+ </StyledFieldTextWrapper>
111
+ </StyledFieldGroup>
112
+ ) : inlineHelpText ? (
113
+ <StyledInputContainer hasInlineHelp>
114
+ {inputElement}
115
+ <View
116
+ style={{
117
+ position: "absolute",
118
+ bottom: 4,
119
+ width: fieldSize,
120
+ display: "flex",
121
+ justifyContent: "center",
122
+ alignItems: "center"
123
+ }}
69
124
  >
70
- {fieldText}
71
- </Typography>
72
- </StyledFieldTextWrapper>
73
- </StyledFieldGroup>
74
- )
125
+ <Typography
126
+ token={description.text.default}
127
+ color={colour.description.default}
128
+ >
129
+ {inlineHelpText}
130
+ </Typography>
131
+ </View>
132
+ </StyledInputContainer>
133
+ ) : (
134
+ inputElement
135
+ )
136
+
137
+ return fieldContent
75
138
  })
76
139
 
77
140
  NumberInputField.displayName = "NumberInput.Field"
@@ -1,7 +1,7 @@
1
1
  import React from "react"
2
2
  import { screen } from "@testing-library/react"
3
3
  import { describe, it, expect } from "vitest"
4
- import { renderWithTheme } from "../../../test-utils"
4
+ import { renderWithTheme, theme } from "../../../test-utils"
5
5
  import { Typography } from "./Typography"
6
6
 
7
7
  describe("Typography", () => {
@@ -64,7 +64,7 @@ describe("Typography", () => {
64
64
  fontSize: "16",
65
65
  lineHeight: "24"
66
66
  }}
67
- color="#000000"
67
+ color={theme.tokens.semantics.colour.text.primary}
68
68
  >
69
69
  Token text
70
70
  </Typography>
@@ -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"
@@ -25,6 +25,10 @@ export default {
25
25
  control: { type: "text" },
26
26
  description: "Help text displayed below the field"
27
27
  },
28
+ inlineHelpText: {
29
+ control: { type: "text" },
30
+ description: "Help text displayed inside the input field"
31
+ },
28
32
  error: {
29
33
  control: { type: "text" },
30
34
  description: "Error message when state is error"
@@ -221,6 +225,25 @@ export const FullWidth = () => {
221
225
  )
222
226
  }
223
227
 
228
+ export const WithInlineHelpText = () => {
229
+ const [value, setValue] = useState(12)
230
+
231
+ return (
232
+ <View style={styles.section}>
233
+ <NumberField
234
+ label="Daily amount"
235
+ size="lg"
236
+ inlineHelpText="~ 840 kcals"
237
+ description="Help text"
238
+ value={String(value)}
239
+ onChangeText={(t) => setValue(Number(t) || 0)}
240
+ onIncrement={() => setValue((v) => v + 1)}
241
+ onDecrement={() => setValue((v) => Math.max(0, v - 1))}
242
+ />
243
+ </View>
244
+ )
245
+ }
246
+
224
247
  export const Playground = {
225
248
  args: {
226
249
  size: "lg",
@@ -5,6 +5,7 @@ import { useTheme } from "@emotion/react"
5
5
  import { IconButton } from "../../atoms/IconButton"
6
6
  import { Add, Remove } from "@butternutbox/pawprint-icons/core"
7
7
  import { resolveFont } from "../../../fonts"
8
+ import { Typography } from "../../atoms/Typography"
8
9
 
9
10
  type NumberFieldSize = "sm" | "lg"
10
11
 
@@ -21,6 +22,7 @@ type NumberFieldInputOwnProps = {
21
22
  decrementDisabled?: boolean
22
23
  min?: number
23
24
  max?: number
25
+ inlineHelpText?: string
24
26
  }
25
27
 
26
28
  export type NumberFieldInputProps = NumberFieldInputOwnProps &
@@ -48,6 +50,7 @@ const StyledFieldWrapper = styled(View)<{
48
50
  }) => ({
49
51
  alignItems: "center",
50
52
  justifyContent: "center",
53
+ position: "relative",
51
54
  width: fullWidth ? "100%" : fieldMinWidth,
52
55
  minWidth: fieldMinWidth,
53
56
  height: fieldHeight,
@@ -79,20 +82,29 @@ const StyledTextInput = styled(TextInput)<{
79
82
  inputFontFamily: string
80
83
  inputFontWeight?: FontWeight
81
84
  inputFontSize: number
82
- }>(({ inputColor, inputFontFamily, inputFontWeight, inputFontSize }) => ({
83
- textAlign: "center" as const,
84
- outlineStyle: "none" as unknown as undefined,
85
- padding: 0,
86
- minWidth: 0,
87
- minHeight: 0,
88
- width: "100%",
89
- height: "100%",
90
- color: inputColor,
91
- fontFamily: inputFontFamily,
92
- ...(inputFontWeight ? { fontWeight: inputFontWeight } : {}),
93
- fontSize: inputFontSize,
94
- lineHeight: 0
95
- }))
85
+ hasInlineHelp?: boolean
86
+ }>(
87
+ ({
88
+ inputColor,
89
+ inputFontFamily,
90
+ inputFontWeight,
91
+ inputFontSize,
92
+ hasInlineHelp
93
+ }) => ({
94
+ textAlign: "center" as const,
95
+ outlineStyle: "none" as unknown as undefined,
96
+ padding: 0,
97
+ minWidth: 0,
98
+ minHeight: 0,
99
+ width: "100%",
100
+ height: "100%",
101
+ color: inputColor,
102
+ fontFamily: inputFontFamily,
103
+ ...(inputFontWeight ? { fontWeight: inputFontWeight } : {}),
104
+ fontSize: inputFontSize,
105
+ ...(hasInlineHelp ? { position: "relative", top: -8 } : {})
106
+ })
107
+ )
96
108
 
97
109
  const StyledRow = styled(View)<{
98
110
  rowGap: number
@@ -107,6 +119,8 @@ const StyledRow = styled(View)<{
107
119
  * Renders decrement button, centered numeric input, increment button,
108
120
  * and optional side text (e.g. "kg").
109
121
  *
122
+ * @param {string} [inlineHelpText] - Help text displayed inside the input field at the bottom
123
+ *
110
124
  * Border states:
111
125
  * - default: `colour.field.border.default`
112
126
  * - focused: `colour.field.border.selected`
@@ -131,6 +145,7 @@ export const NumberFieldInput = React.forwardRef<
131
145
  decrementDisabled = false,
132
146
  min,
133
147
  max,
148
+ inlineHelpText,
134
149
  style,
135
150
  onFocus,
136
151
  onBlur,
@@ -253,6 +268,7 @@ export const NumberFieldInput = React.forwardRef<
253
268
  : undefined
254
269
  }
255
270
  inputFontSize={parseTokenValue(typographyToken.fontSize)}
271
+ hasInlineHelp={!!inlineHelpText}
256
272
  keyboardType="numeric"
257
273
  editable={!disabled}
258
274
  onFocus={handleFocus}
@@ -263,6 +279,25 @@ export const NumberFieldInput = React.forwardRef<
263
279
  style={style}
264
280
  {...rest}
265
281
  />
282
+ {inlineHelpText && (
283
+ <View
284
+ style={{
285
+ position: "absolute",
286
+ bottom: 4,
287
+ width: fieldMinWidth,
288
+ display: "flex",
289
+ justifyContent: "center",
290
+ alignItems: "center"
291
+ }}
292
+ >
293
+ <Typography
294
+ token={inputTokens.description.text.default}
295
+ color={inputTokens.colour.description.default}
296
+ >
297
+ {inlineHelpText}
298
+ </Typography>
299
+ </View>
300
+ )}
266
301
  </StyledFieldWrapper>
267
302
  {showIncrementButton && (
268
303
  <IconButton