@butternutbox/pawprint-native 0.3.2 → 0.4.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 (30) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/CHANGELOG.md +8 -0
  3. package/dist/index.cjs +965 -151
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +246 -5
  6. package/dist/index.d.ts +246 -5
  7. package/dist/index.js +960 -151
  8. package/dist/index.js.map +1 -1
  9. package/package.json +2 -1
  10. package/src/components/atoms/Hint/Hint.tsx +1 -2
  11. package/src/components/atoms/Input/InputField.tsx +7 -1
  12. package/src/components/molecules/Animated/Animated.tsx +12 -3
  13. package/src/components/molecules/Countdown/Countdown.stories.tsx +218 -0
  14. package/src/components/molecules/Countdown/Countdown.tsx +315 -0
  15. package/src/components/molecules/Countdown/index.ts +2 -0
  16. package/src/components/molecules/ProductDisplayCard/ProductDisplayCard.stories.tsx +248 -0
  17. package/src/components/molecules/ProductDisplayCard/ProductDisplayCard.test.tsx +198 -0
  18. package/src/components/molecules/ProductDisplayCard/ProductDisplayCard.tsx +243 -0
  19. package/src/components/molecules/ProductDisplayCard/index.ts +5 -0
  20. package/src/components/molecules/ProductListingCard/Badge.tsx +65 -0
  21. package/src/components/molecules/ProductListingCard/Grid.tsx +59 -0
  22. package/src/components/molecules/ProductListingCard/ProductListingCard.stories.tsx +209 -0
  23. package/src/components/molecules/ProductListingCard/ProductListingCard.tsx +235 -0
  24. package/src/components/molecules/ProductListingCard/index.ts +2 -0
  25. package/src/components/molecules/TabNavigation/TabNavigation.stories.tsx +183 -0
  26. package/src/components/molecules/TabNavigation/TabNavigation.tsx +354 -0
  27. package/src/components/molecules/TabNavigation/index.ts +7 -0
  28. package/src/components/molecules/index.ts +4 -0
  29. package/src/utils/index.ts +1 -0
  30. package/src/utils/token.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butternutbox/pawprint-native",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -33,6 +33,7 @@
33
33
  "@rn-primitives/slider": "^1.4.0",
34
34
  "@rn-primitives/slot": "^1.0.5",
35
35
  "@rn-primitives/switch": "^1.4.0",
36
+ "@rn-primitives/tabs": "^1.4.0",
36
37
  "@rn-primitives/tooltip": "^1.4.0",
37
38
  "date-fns": "^4.1.0",
38
39
  "expo": "^55.0.9"
@@ -1,6 +1,7 @@
1
1
  import React from "react"
2
2
  import { View, ViewProps } from "react-native"
3
3
  import styled from "@emotion/native"
4
+ import { parseTokenValue } from "../../../utils"
4
5
 
5
6
  type HintVariant = "default" | "success" | "warning" | "error"
6
7
 
@@ -10,8 +11,6 @@ type HintOwnProps = {
10
11
 
11
12
  export type HintProps = HintOwnProps & Omit<ViewProps, keyof HintOwnProps>
12
13
 
13
- const parseTokenValue = (value: string): number => parseFloat(value)
14
-
15
14
  const StyledHint = styled(View)<{ hintVariant: HintVariant }>(({
16
15
  theme,
17
16
  hintVariant
@@ -60,13 +60,19 @@ const StyledInputWrapper = styled(Animated.View)<{
60
60
  })
61
61
 
62
62
  const StyledInput = styled(TextInput)(({ theme }) => {
63
- const { colour } = theme.tokens.components.inputs
63
+ const { colour, field } = theme.tokens.components.inputs
64
+ const placeholderTypography = field.placeholder.default
64
65
  return {
65
66
  flex: 1,
66
67
  minWidth: 0,
67
68
  minHeight: 0,
68
69
  padding: 0,
69
70
  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),
70
76
  // Suppress the browser default focus outline on React Native Web —
71
77
  // the focus indicator is rendered on the wrapper instead.
72
78
  ...({
@@ -224,6 +224,13 @@ export type AnimatedProps = {
224
224
  * ```
225
225
  */
226
226
  delay?: number
227
+ /**
228
+ * Whether to play the exit animation when the component unmounts. Defaults
229
+ * to `true`. Set to `false` for an enter-only animation — useful when a
230
+ * parent unmounts the element directly (e.g. tab panels). Mirrors the web
231
+ * `Animated`, whose exit only runs inside `AnimatePresence`.
232
+ */
233
+ exit?: boolean
227
234
  } & ViewProps
228
235
 
229
236
  // ─── Component ────────────────────────────────────────────────────────────────
@@ -232,7 +239,9 @@ export type AnimatedProps = {
232
239
  * Reanimated wrapper that applies a named animation preset to its children.
233
240
  *
234
241
  * Exit animations run automatically when the component unmounts — no wrapper
235
- * component needed (unlike `AnimatePresence` on web).
242
+ * component needed (unlike `AnimatePresence` on web). Pass `exit={false}` for
243
+ * an enter-only animation (matching the web default, where exit requires
244
+ * `AnimatePresence`).
236
245
  *
237
246
  * Respects the OS reduce-motion setting automatically — Reanimated builders
238
247
  * default to `ReduceMotion.System` so no extra hook is needed.
@@ -260,7 +269,7 @@ export type AnimatedProps = {
260
269
  * ```
261
270
  */
262
271
  export const Animated = React.forwardRef<View, AnimatedProps>(
263
- ({ variant = "fade", delay, children, ...props }, ref) => {
272
+ ({ variant = "fade", delay, exit = true, children, ...props }, ref) => {
264
273
  const delayMs =
265
274
  delay !== undefined && delay > 0 ? Math.round(delay * 1000) : 0
266
275
 
@@ -271,7 +280,7 @@ export const Animated = React.forwardRef<View, AnimatedProps>(
271
280
  <Reanimated.View
272
281
  ref={ref}
273
282
  entering={entering}
274
- exiting={EXITING[variant]}
283
+ exiting={exit ? EXITING[variant] : undefined}
275
284
  {...props}
276
285
  >
277
286
  {children}
@@ -0,0 +1,218 @@
1
+ import React from "react"
2
+ import { View, StyleSheet } from "react-native"
3
+ import { Countdown } from "./Countdown"
4
+ import type { CountdownProps } from "./Countdown"
5
+ import { Typography } from "../../atoms/Typography"
6
+
7
+ export default {
8
+ title: "Molecules/Countdown",
9
+ component: Countdown,
10
+ argTypes: {
11
+ layout: {
12
+ control: { type: "select" },
13
+ options: ["stacked", "inline"],
14
+ description: "Visual layout"
15
+ },
16
+ days: {
17
+ control: { type: "number", min: 0 },
18
+ description: "Days remaining"
19
+ },
20
+ hours: {
21
+ control: { type: "number", min: 0 },
22
+ description: "Hours remaining"
23
+ },
24
+ minutes: {
25
+ control: { type: "number", min: 0 },
26
+ description: "Minutes remaining"
27
+ },
28
+ seconds: {
29
+ control: { type: "number", min: 0 },
30
+ description: "Seconds remaining"
31
+ },
32
+ showDays: {
33
+ control: { type: "boolean" },
34
+ description: "Render the days unit and its separator"
35
+ },
36
+ showSeconds: {
37
+ control: { type: "boolean" },
38
+ description: "Render the seconds unit and its separator"
39
+ },
40
+ label: {
41
+ control: { type: "text" },
42
+ description: "Customizable prefix label (inline layout only)"
43
+ }
44
+ }
45
+ }
46
+
47
+ export const Playground = {
48
+ args: {
49
+ layout: "stacked",
50
+ days: 0,
51
+ hours: 0,
52
+ minutes: 0,
53
+ seconds: 0,
54
+ showDays: true,
55
+ showSeconds: true,
56
+ label: "Label:"
57
+ },
58
+ render: (args: CountdownProps) => (
59
+ <View style={styles.container}>
60
+ <Countdown {...args} />
61
+ </View>
62
+ )
63
+ }
64
+
65
+ export const Stacked = {
66
+ name: "Stacked",
67
+ render: () => (
68
+ <View style={styles.container}>
69
+ <Countdown
70
+ layout="stacked"
71
+ days={2}
72
+ hours={9}
73
+ minutes={48}
74
+ seconds={38}
75
+ />
76
+ </View>
77
+ )
78
+ }
79
+
80
+ export const Inline = {
81
+ name: "Inline",
82
+ render: () => (
83
+ <View style={styles.container}>
84
+ <Countdown
85
+ layout="inline"
86
+ label="Label:"
87
+ days={0}
88
+ hours={0}
89
+ minutes={0}
90
+ seconds={0}
91
+ />
92
+ </View>
93
+ )
94
+ }
95
+
96
+ export const WithoutDays = {
97
+ name: "Without Days (under 24h)",
98
+ render: () => (
99
+ <View style={styles.column}>
100
+ <Countdown
101
+ layout="stacked"
102
+ showDays={false}
103
+ hours={3}
104
+ minutes={49}
105
+ seconds={38}
106
+ />
107
+ <Countdown
108
+ layout="inline"
109
+ label="Delivery ends in:"
110
+ showDays={false}
111
+ hours={3}
112
+ minutes={49}
113
+ seconds={38}
114
+ />
115
+ </View>
116
+ )
117
+ }
118
+
119
+ export const WithoutSeconds = {
120
+ name: "Without Seconds",
121
+ render: () => (
122
+ <View style={styles.column}>
123
+ <Countdown
124
+ layout="stacked"
125
+ showSeconds={false}
126
+ days={1}
127
+ hours={6}
128
+ minutes={20}
129
+ />
130
+ <Countdown
131
+ layout="inline"
132
+ label="Sale ends:"
133
+ showSeconds={false}
134
+ days={1}
135
+ hours={6}
136
+ minutes={20}
137
+ />
138
+ </View>
139
+ )
140
+ }
141
+
142
+ export const AllVariants = {
143
+ name: "All Variants",
144
+ render: () => (
145
+ <View style={styles.column}>
146
+ <View style={styles.section}>
147
+ <Typography size="sm" weight="semiBold" color="tertiary">
148
+ Stacked
149
+ </Typography>
150
+ <Countdown
151
+ layout="stacked"
152
+ days={2}
153
+ hours={9}
154
+ minutes={48}
155
+ seconds={38}
156
+ />
157
+ <Countdown
158
+ layout="stacked"
159
+ showDays={false}
160
+ hours={9}
161
+ minutes={48}
162
+ seconds={38}
163
+ />
164
+ <Countdown
165
+ layout="stacked"
166
+ showSeconds={false}
167
+ days={2}
168
+ hours={9}
169
+ minutes={48}
170
+ />
171
+ </View>
172
+ <View style={styles.section}>
173
+ <Typography size="sm" weight="semiBold" color="tertiary">
174
+ Inline
175
+ </Typography>
176
+ <Countdown
177
+ layout="inline"
178
+ label="Label:"
179
+ days={2}
180
+ hours={9}
181
+ minutes={48}
182
+ seconds={38}
183
+ />
184
+ <Countdown
185
+ layout="inline"
186
+ label="Label:"
187
+ showDays={false}
188
+ hours={9}
189
+ minutes={48}
190
+ seconds={38}
191
+ />
192
+ <Countdown
193
+ layout="inline"
194
+ label="Label:"
195
+ showSeconds={false}
196
+ days={2}
197
+ hours={9}
198
+ minutes={48}
199
+ />
200
+ </View>
201
+ </View>
202
+ )
203
+ }
204
+
205
+ const styles = StyleSheet.create({
206
+ container: {
207
+ padding: 16
208
+ },
209
+ column: {
210
+ padding: 16,
211
+ flexDirection: "column",
212
+ gap: 16
213
+ },
214
+ section: {
215
+ flexDirection: "column",
216
+ gap: 8
217
+ }
218
+ })
@@ -0,0 +1,315 @@
1
+ import React from "react"
2
+ import { View, ViewProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import { Typography } from "../../atoms/Typography"
6
+
7
+ type CountdownLayout = "stacked" | "inline"
8
+
9
+ type CountdownUnit = {
10
+ key: "days" | "hours" | "minutes" | "seconds"
11
+ value: number
12
+ label: string
13
+ }
14
+
15
+ type CountdownOwnProps = {
16
+ layout?: CountdownLayout
17
+ days?: number
18
+ hours?: number
19
+ minutes?: number
20
+ seconds?: number
21
+ showDays?: boolean
22
+ showSeconds?: boolean
23
+ label?: React.ReactNode
24
+ }
25
+
26
+ export type CountdownProps = CountdownOwnProps &
27
+ Omit<ViewProps, keyof CountdownOwnProps>
28
+
29
+ const UNIT_LABELS = {
30
+ days: "days",
31
+ hours: "hrs",
32
+ minutes: "min",
33
+ seconds: "secs"
34
+ } as const
35
+
36
+ const parseTokenValue = (value: string): number => parseFloat(value)
37
+
38
+ const pad = (value: number): string =>
39
+ String(Math.max(0, Math.floor(value))).padStart(2, "0")
40
+
41
+ const StackedRoot = styled(View)<{ rootGap: number }>(({ rootGap }) => ({
42
+ flexDirection: "row",
43
+ alignItems: "center",
44
+ alignSelf: "flex-start",
45
+ gap: rootGap
46
+ }))
47
+
48
+ const StackedItem = styled(View)<{
49
+ itemGap: number
50
+ itemVerticalPadding: number
51
+ itemHorizontalPadding: number
52
+ itemBgColor: string
53
+ itemBorderRadius: number
54
+ }>(
55
+ ({
56
+ itemGap,
57
+ itemVerticalPadding,
58
+ itemHorizontalPadding,
59
+ itemBgColor,
60
+ itemBorderRadius
61
+ }) => ({
62
+ flexDirection: "column",
63
+ alignItems: "center",
64
+ justifyContent: "center",
65
+ gap: itemGap,
66
+ paddingVertical: itemVerticalPadding,
67
+ paddingHorizontal: itemHorizontalPadding,
68
+ backgroundColor: itemBgColor,
69
+ borderRadius: itemBorderRadius
70
+ })
71
+ )
72
+
73
+ const StackedCountBox = styled(View)<{
74
+ countPaddingTop: number
75
+ countBgColor: string
76
+ countBorderRadius: number
77
+ }>(({ countPaddingTop, countBgColor, countBorderRadius }) => ({
78
+ alignItems: "center",
79
+ justifyContent: "center",
80
+ // TODO: Add token — fixed count box dimensions are not yet tokenised.
81
+ width: 48,
82
+ height: 36,
83
+ paddingTop: countPaddingTop,
84
+ backgroundColor: countBgColor,
85
+ borderRadius: countBorderRadius
86
+ }))
87
+
88
+ const InlineRoot = styled(View)<{
89
+ rootGap: number
90
+ rootVerticalPadding: number
91
+ rootHorizontalPadding: number
92
+ rootBgColor: string
93
+ rootBorderRadius: number
94
+ }>(
95
+ ({
96
+ rootGap,
97
+ rootVerticalPadding,
98
+ rootHorizontalPadding,
99
+ rootBgColor,
100
+ rootBorderRadius
101
+ }) => ({
102
+ flexDirection: "row",
103
+ alignItems: "center",
104
+ justifyContent: "space-between",
105
+ gap: rootGap,
106
+ width: "100%",
107
+ paddingVertical: rootVerticalPadding,
108
+ paddingHorizontal: rootHorizontalPadding,
109
+ backgroundColor: rootBgColor,
110
+ borderRadius: rootBorderRadius,
111
+ overflow: "hidden"
112
+ })
113
+ )
114
+
115
+ const InlineGroup = styled(View)<{ groupGap: number }>(({ groupGap }) => ({
116
+ flexDirection: "row",
117
+ alignItems: "center",
118
+ justifyContent: "flex-end",
119
+ gap: groupGap
120
+ }))
121
+
122
+ const InlineCount = styled(View)<{ countGap: number }>(({ countGap }) => ({
123
+ flexDirection: "row",
124
+ alignItems: "center",
125
+ gap: countGap
126
+ }))
127
+
128
+ /**
129
+ * Displays the remaining time until an event, deadline, or expiry. Purely
130
+ * presentational — the consuming app computes the remaining time and passes
131
+ * the discrete `days`/`hours`/`minutes`/`seconds` values.
132
+ *
133
+ * Two layouts:
134
+ * - `stacked` — each unit sits in its own styled container (brand fill) with
135
+ * the value above the label, separated by colons. Best for heroes, checkout
136
+ * screens, and promotional cards.
137
+ * - `inline` — a single condensed banner with a customizable prefix `label`
138
+ * followed by the time string on one line. Best for notification banners,
139
+ * sticky footers, and dense UI.
140
+ *
141
+ * Units can be toggled to scale across timeframes: hide `days` when under
142
+ * 24 hours remain (`showDays={false}`) and hide `seconds` to reduce visual
143
+ * noise (`showSeconds={false}`). Toggling a unit also removes its separator.
144
+ * Hours and minutes are always shown.
145
+ *
146
+ * Note: API parity with the web `Countdown`. Unlike the web version, the
147
+ * native root is a `View` and px token strings are parsed to numbers.
148
+ *
149
+ * @param {"stacked" | "inline"} [layout="stacked"] - Visual layout.
150
+ * @param {number} [days=0] - Days remaining (zero-padded to 2 digits).
151
+ * @param {number} [hours=0] - Hours remaining (zero-padded to 2 digits).
152
+ * @param {number} [minutes=0] - Minutes remaining (zero-padded to 2 digits).
153
+ * @param {number} [seconds=0] - Seconds remaining (zero-padded to 2 digits).
154
+ * @param {boolean} [showDays=true] - Whether to render the days unit and its separator.
155
+ * @param {boolean} [showSeconds=true] - Whether to render the seconds unit and its separator.
156
+ * @param {React.ReactNode} [label] - Customizable prefix label, rendered in the `inline` layout only.
157
+ *
158
+ * @example
159
+ * ```tsx
160
+ * import { Countdown } from "@butternutbox/pawprint-native"
161
+ *
162
+ * <Countdown layout="stacked" days={2} hours={9} minutes={48} seconds={38} />
163
+ * <Countdown layout="inline" label="Sale ends:" showDays={false} hours={3} minutes={49} seconds={38} />
164
+ * ```
165
+ */
166
+ export const Countdown = React.forwardRef<View, CountdownProps>(
167
+ (
168
+ {
169
+ layout = "stacked",
170
+ days = 0,
171
+ hours = 0,
172
+ minutes = 0,
173
+ seconds = 0,
174
+ showDays = true,
175
+ showSeconds = true,
176
+ label,
177
+ ...rest
178
+ },
179
+ ref
180
+ ) => {
181
+ const theme = useTheme()
182
+ const { countdown } = theme.tokens.components
183
+ const { countdownItem } = countdown
184
+
185
+ const units: CountdownUnit[] = [
186
+ showDays && {
187
+ key: "days" as const,
188
+ value: days,
189
+ label: UNIT_LABELS.days
190
+ },
191
+ { key: "hours" as const, value: hours, label: UNIT_LABELS.hours },
192
+ { key: "minutes" as const, value: minutes, label: UNIT_LABELS.minutes },
193
+ showSeconds && {
194
+ key: "seconds" as const,
195
+ value: seconds,
196
+ label: UNIT_LABELS.seconds
197
+ }
198
+ ].filter(Boolean) as CountdownUnit[]
199
+
200
+ const colon = (key: string) => (
201
+ <Typography
202
+ key={key}
203
+ token={countdown.typography.colon}
204
+ color={countdown.colour.text.colon}
205
+ >
206
+ :
207
+ </Typography>
208
+ )
209
+
210
+ if (layout === "inline") {
211
+ return (
212
+ <InlineRoot
213
+ ref={ref}
214
+ rootGap={parseTokenValue(countdown.spacing.gap)}
215
+ rootVerticalPadding={parseTokenValue(
216
+ countdown.spacing.verticalPadding
217
+ )}
218
+ rootHorizontalPadding={parseTokenValue(
219
+ countdown.spacing.horizontalPadding
220
+ )}
221
+ rootBgColor={countdown.colour.background.default}
222
+ rootBorderRadius={parseTokenValue(countdown.borderRadius.default)}
223
+ {...rest}
224
+ >
225
+ {label != null && (
226
+ <Typography variant="body" weight="bold" size="md" color="primary">
227
+ {label}
228
+ </Typography>
229
+ )}
230
+ <InlineGroup
231
+ groupGap={parseTokenValue(countdown.countdownGroup.spacing.gap)}
232
+ >
233
+ {units.map((unit, index) => (
234
+ <React.Fragment key={unit.key}>
235
+ {index > 0 && colon(`colon-${unit.key}`)}
236
+ <InlineCount
237
+ countGap={parseTokenValue(countdown.count.spacing.gap)}
238
+ >
239
+ <Typography
240
+ variant="body"
241
+ weight="bold"
242
+ size="md"
243
+ color="primary"
244
+ >
245
+ {pad(unit.value)}
246
+ </Typography>
247
+ <Typography
248
+ variant="body"
249
+ weight="medium"
250
+ size="xs"
251
+ color="secondary"
252
+ >
253
+ {unit.label}
254
+ </Typography>
255
+ </InlineCount>
256
+ </React.Fragment>
257
+ ))}
258
+ </InlineGroup>
259
+ </InlineRoot>
260
+ )
261
+ }
262
+
263
+ return (
264
+ <StackedRoot
265
+ ref={ref}
266
+ rootGap={parseTokenValue(countdown.spacing.gap)}
267
+ {...rest}
268
+ >
269
+ {units.map((unit, index) => (
270
+ <React.Fragment key={unit.key}>
271
+ {index > 0 && colon(`colon-${unit.key}`)}
272
+ <StackedItem
273
+ itemGap={parseTokenValue(countdownItem.spacing.gap)}
274
+ itemVerticalPadding={parseTokenValue(
275
+ countdownItem.spacing.verticalPadding
276
+ )}
277
+ itemHorizontalPadding={parseTokenValue(
278
+ countdownItem.spacing.horizontalPadding
279
+ )}
280
+ itemBgColor={countdownItem.colour.background.default}
281
+ itemBorderRadius={parseTokenValue(
282
+ countdownItem.borderRadius.default
283
+ )}
284
+ >
285
+ <StackedCountBox
286
+ countPaddingTop={parseTokenValue(
287
+ countdownItem.count.spacing.paddingTop
288
+ )}
289
+ countBgColor={countdownItem.count.colour.background.default}
290
+ countBorderRadius={parseTokenValue(
291
+ countdownItem.count.borderRadius.default
292
+ )}
293
+ >
294
+ <Typography
295
+ token={countdownItem.typography.count}
296
+ color={countdownItem.count.colour.text.count}
297
+ >
298
+ {pad(unit.value)}
299
+ </Typography>
300
+ </StackedCountBox>
301
+ <Typography
302
+ token={countdownItem.typography.label}
303
+ color={countdownItem.colour.text.label}
304
+ >
305
+ {unit.label}
306
+ </Typography>
307
+ </StackedItem>
308
+ </React.Fragment>
309
+ ))}
310
+ </StackedRoot>
311
+ )
312
+ }
313
+ )
314
+
315
+ Countdown.displayName = "Countdown"
@@ -0,0 +1,2 @@
1
+ export { Countdown } from "./Countdown"
2
+ export type { CountdownProps } from "./Countdown"