@butternutbox/pawprint-native 1.1.3 → 1.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.
@@ -0,0 +1,445 @@
1
+ import React from "react"
2
+ import { LayoutChangeEvent, StyleSheet, View, ViewProps } from "react-native"
3
+ import Svg, {
4
+ Defs,
5
+ LinearGradient as SvgLinearGradient,
6
+ Rect,
7
+ Stop
8
+ } from "react-native-svg"
9
+ import Reanimated, {
10
+ Easing,
11
+ useAnimatedStyle,
12
+ useReducedMotion,
13
+ useSharedValue,
14
+ withDelay,
15
+ withRepeat,
16
+ withSequence,
17
+ withTiming
18
+ } from "react-native-reanimated"
19
+ import styled from "@emotion/native"
20
+ import { useTheme } from "@emotion/react"
21
+ import {
22
+ Close,
23
+ Gift,
24
+ KeyboardArrowRight,
25
+ KeyboardArrowUp
26
+ } from "@butternutbox/pawprint-icons/core"
27
+ import { Icon, type PawprintIcon } from "../../atoms/Icon"
28
+ import { IconButton, type IconButtonVariant } from "../../atoms/IconButton"
29
+ import { Link } from "../../atoms/Link"
30
+ import { Typography } from "../../atoms/Typography"
31
+
32
+ type FloatingActionButtonVariant = "primary" | "secondary"
33
+ type FloatingActionButtonIconButtonIcon = "chevronUp" | "chevronRight" | "close"
34
+ type FloatingActionButtonAnimation = "shine" | "nudge"
35
+
36
+ type FloatingActionButtonBaseProps = {
37
+ variant?: FloatingActionButtonVariant
38
+ icon?: PawprintIcon
39
+ children: React.ReactNode
40
+ linkLabel: string
41
+ animation?: FloatingActionButtonAnimation
42
+ }
43
+
44
+ // The link is mandatory, so at least one of `linkHref` / `onLinkPress` is
45
+ // required.
46
+ type FloatingActionButtonLinkProps =
47
+ | { linkHref: string; onLinkPress?: () => void }
48
+ | { linkHref?: string; onLinkPress: () => void }
49
+
50
+ type FloatingActionButtonIconButtonProps = {
51
+ iconButtonIcon: FloatingActionButtonIconButtonIcon
52
+ iconButtonLabel: string
53
+ iconButtonVariant?: IconButtonVariant
54
+ onIconButtonPress?: () => void
55
+ }
56
+
57
+ type FloatingActionButtonOwnProps = FloatingActionButtonBaseProps &
58
+ FloatingActionButtonLinkProps &
59
+ FloatingActionButtonIconButtonProps
60
+
61
+ export type FloatingActionButtonProps = FloatingActionButtonOwnProps &
62
+ Omit<ViewProps, keyof FloatingActionButtonBaseProps | "iconButtonIcon">
63
+
64
+ const parseTokenValue = (value: string): number => parseFloat(value)
65
+
66
+ const ICON_BUTTON_CONFIG = {
67
+ chevronUp: { icon: KeyboardArrowUp, defaultVariant: "filled" },
68
+ chevronRight: { icon: KeyboardArrowRight, defaultVariant: "filled" },
69
+ close: { icon: Close, defaultVariant: "text" }
70
+ } as const
71
+
72
+ // TODO: Add token — no motion tokens exist yet
73
+ const ANIMATION_DELAY = 1500
74
+ const ANIMATION_ITERATIONS = 3
75
+ const SHINE_SWEEP = 1100
76
+ const SHINE_REST = 4900
77
+ const SHINE_BAND_RATIO = 0.4
78
+ // TODO: Add token — highlight opacity
79
+ const SHINE_OPACITY = 0.55
80
+ const NUDGE_STEP = 200
81
+ const NUDGE_REST = 3200
82
+ const NUDGE_DISTANCE = 3
83
+
84
+ const ShineOverlay = ({
85
+ radius,
86
+ highlight
87
+ }: {
88
+ radius: number
89
+ highlight: string
90
+ }) => {
91
+ const reducedMotion = useReducedMotion()
92
+ const [width, setWidth] = React.useState(0)
93
+ const progress = useSharedValue(0)
94
+ const bandWidth = width * SHINE_BAND_RATIO
95
+
96
+ React.useEffect(() => {
97
+ if (reducedMotion || !width) return
98
+ progress.value = withDelay(
99
+ ANIMATION_DELAY,
100
+ withRepeat(
101
+ withSequence(
102
+ withTiming(1, {
103
+ duration: SHINE_SWEEP,
104
+ easing: Easing.inOut(Easing.ease)
105
+ }),
106
+ withDelay(SHINE_REST, withTiming(0, { duration: 0 }))
107
+ ),
108
+ ANIMATION_ITERATIONS
109
+ )
110
+ )
111
+ }, [progress, reducedMotion, width])
112
+
113
+ const bandStyle = useAnimatedStyle(() => ({
114
+ transform: [
115
+ {
116
+ translateX:
117
+ -bandWidth * 1.5 + progress.value * (width + bandWidth * 2.5)
118
+ },
119
+ { skewX: "-20deg" }
120
+ ]
121
+ }))
122
+
123
+ if (reducedMotion) return null
124
+
125
+ return (
126
+ <View
127
+ pointerEvents="none"
128
+ accessible={false}
129
+ importantForAccessibility="no-hide-descendants"
130
+ onLayout={(e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width)}
131
+ style={[
132
+ StyleSheet.absoluteFill,
133
+ { borderRadius: radius, overflow: "hidden" }
134
+ ]}
135
+ >
136
+ {width > 0 && (
137
+ <Reanimated.View
138
+ style={[
139
+ { position: "absolute", top: 0, bottom: 0, width: bandWidth },
140
+ bandStyle
141
+ ]}
142
+ >
143
+ <Svg width="100%" height="100%">
144
+ <Defs>
145
+ <SvgLinearGradient id="fabShine" x1="0" y1="0" x2="1" y2="0">
146
+ <Stop offset="0" stopColor={highlight} stopOpacity={0} />
147
+ <Stop
148
+ offset="0.5"
149
+ stopColor={highlight}
150
+ stopOpacity={SHINE_OPACITY}
151
+ />
152
+ <Stop offset="1" stopColor={highlight} stopOpacity={0} />
153
+ </SvgLinearGradient>
154
+ </Defs>
155
+ <Rect width="100%" height="100%" fill="url(#fabShine)" />
156
+ </Svg>
157
+ </Reanimated.View>
158
+ )}
159
+ </View>
160
+ )
161
+ }
162
+
163
+ // Nudge variants are passed to IconButton as its `icon`, so only the glyph
164
+ // moves (matching web) while the button itself stays put. Created once at module
165
+ // level so the components stay stable across renders.
166
+ const createNudgingIcon = (
167
+ IconComponent: PawprintIcon,
168
+ axis: "translateX" | "translateY",
169
+ direction: 1 | -1
170
+ ): PawprintIcon => {
171
+ const NudgingIcon = (props: React.ComponentProps<PawprintIcon>) => {
172
+ const reducedMotion = useReducedMotion()
173
+ const offset = useSharedValue(0)
174
+
175
+ React.useEffect(() => {
176
+ if (reducedMotion) return
177
+ const step = (to: number) =>
178
+ withTiming(to, {
179
+ duration: NUDGE_STEP,
180
+ easing: Easing.inOut(Easing.ease)
181
+ })
182
+ const distance = NUDGE_DISTANCE * direction
183
+ offset.value = withDelay(
184
+ ANIMATION_DELAY,
185
+ withRepeat(
186
+ withSequence(
187
+ step(distance),
188
+ step(0),
189
+ step(distance),
190
+ step(0),
191
+ withDelay(NUDGE_REST, withTiming(0, { duration: 0 }))
192
+ ),
193
+ ANIMATION_ITERATIONS
194
+ )
195
+ )
196
+ }, [offset, reducedMotion])
197
+
198
+ const nudgeStyle = useAnimatedStyle(() =>
199
+ axis === "translateX"
200
+ ? { transform: [{ translateX: offset.value }] }
201
+ : { transform: [{ translateY: offset.value }] }
202
+ )
203
+
204
+ return (
205
+ <Reanimated.View style={nudgeStyle}>
206
+ <IconComponent {...props} />
207
+ </Reanimated.View>
208
+ )
209
+ }
210
+ NudgingIcon.displayName = `Nudging(${IconComponent.displayName ?? "Icon"})`
211
+ return NudgingIcon
212
+ }
213
+
214
+ const NUDGE_ICONS = {
215
+ chevronUp: createNudgingIcon(KeyboardArrowUp, "translateY", -1),
216
+ chevronRight: createNudgingIcon(KeyboardArrowRight, "translateX", 1)
217
+ } as const
218
+
219
+ const StyledRoot = styled(View)<{
220
+ rootGap: number
221
+ rootPaddingH: number
222
+ rootPaddingV: number
223
+ rootBgColor: string
224
+ rootBorderWidth: number
225
+ rootBorderColor: string
226
+ rootBorderRadius: number
227
+ rootBoxShadow: string
228
+ }>(
229
+ ({
230
+ rootGap,
231
+ rootPaddingH,
232
+ rootPaddingV,
233
+ rootBgColor,
234
+ rootBorderWidth,
235
+ rootBorderColor,
236
+ rootBorderRadius,
237
+ rootBoxShadow
238
+ }) => ({
239
+ flexDirection: "row",
240
+ alignItems: "center",
241
+ width: "100%",
242
+ gap: rootGap,
243
+ paddingHorizontal: rootPaddingH,
244
+ paddingVertical: rootPaddingV,
245
+ backgroundColor: rootBgColor,
246
+ borderWidth: rootBorderWidth,
247
+ borderColor: rootBorderColor,
248
+ borderRadius: rootBorderRadius,
249
+ boxShadow: rootBoxShadow
250
+ })
251
+ )
252
+
253
+ const StyledIconContainer = styled(View)<{
254
+ containerSize: number
255
+ containerRadius: number
256
+ containerBgColor: string
257
+ }>(({ containerSize, containerRadius, containerBgColor }) => ({
258
+ alignItems: "center",
259
+ justifyContent: "center",
260
+ flexShrink: 0,
261
+ width: containerSize,
262
+ height: containerSize,
263
+ borderRadius: containerRadius,
264
+ backgroundColor: containerBgColor
265
+ }))
266
+
267
+ const StyledContent = styled(View)({
268
+ flex: 1,
269
+ minWidth: 0,
270
+ alignItems: "flex-start",
271
+ justifyContent: "center"
272
+ })
273
+
274
+ /**
275
+ * Floating pill that surfaces a short message with a link and an
276
+ * icon button. Used for persistent prompts such as offers and
277
+ * reminders.
278
+ *
279
+ * The component does not position itself — the consumer is responsible for
280
+ * placing it (e.g. `position: "absolute"` at the bottom right) so it can avoid
281
+ * other floating elements on the screen. It fills the width of its container.
282
+ *
283
+ * The icon button is always size `md` and limited to three icons: `chevronUp`,
284
+ * `chevronRight` and `close`. Its style defaults to the Figma pairing —
285
+ * `filled` (primary) for the chevrons and `text` for `close` — and can be
286
+ * overridden with `iconButtonVariant`.
287
+ *
288
+ * Note: Unlike the web version, handlers are `onLinkPress` and
289
+ * `onIconButtonPress` (web: `onLinkClick` / `onIconButtonClick`). The shadow
290
+ * uses `boxShadow`, which requires React Native 0.76+ (New Architecture).
291
+ *
292
+ * @param {"primary" | "secondary"} [variant="primary"] - Colour scheme.
293
+ * `primary` is pink with a white icon container; `secondary` is white with a
294
+ * cream icon container.
295
+ * @param {PawprintIcon} [icon=Gift] - Icon shown in the circular container on
296
+ * the left. Rendered at size `xl` in the primary icon colour.
297
+ * @param {React.ReactNode} children - Message copy. Nest a bold `Typography`
298
+ * to emphasise part of it.
299
+ * @param {string} linkLabel - Link text shown below the message. Use "Close"
300
+ * to dismiss, or e.g. "See more" when the icon button is `close`.
301
+ * @param {string} [linkHref] - **(required without `onLinkPress`)** URL opened
302
+ * via `Linking` when pressed.
303
+ * @param {() => void} [onLinkPress] - **(required without `linkHref`)** Link
304
+ * press handler, e.g. to dismiss, navigate to a screen or open a Modal or
305
+ * Drawer. Takes precedence over `linkHref`.
306
+ * @param {"shine" | "nudge"} [animation] - Optional attention animation, off
307
+ * by default. Runs 3 times after a short delay, then stops, and is disabled
308
+ * when the user prefers reduced motion. `shine` sweeps a soft highlight
309
+ * across the pill; `nudge` moves the chevron towards its direction (no effect
310
+ * with `close`).
311
+ * @param {"chevronUp" | "chevronRight" | "close"} iconButtonIcon - Icon button
312
+ * on the right.
313
+ * @param {"filled" | "outlined" | "text"} [iconButtonVariant] - Icon button
314
+ * style. Defaults to `filled` for `chevronUp` / `chevronRight` and `text`
315
+ * for `close`.
316
+ * @param {string} iconButtonLabel - Accessible label for the icon button.
317
+ * @param {() => void} [onIconButtonPress] - Icon button press handler.
318
+ *
319
+ * @example
320
+ * ```tsx
321
+ * import { FloatingActionButton, Typography } from "@butternutbox/pawprint-native"
322
+ *
323
+ * <FloatingActionButton
324
+ * variant="primary"
325
+ * linkLabel="Close"
326
+ * onLinkPress={dismiss}
327
+ * iconButtonIcon="chevronUp"
328
+ * iconButtonLabel="View offer"
329
+ * onIconButtonPress={openDrawer}
330
+ * >
331
+ * <Typography size="lg" weight="bold">
332
+ * Free
333
+ * </Typography>{" "}
334
+ * gift in your next box
335
+ * </FloatingActionButton>
336
+ * ```
337
+ */
338
+ export const FloatingActionButton = React.forwardRef<
339
+ View,
340
+ FloatingActionButtonProps
341
+ >(
342
+ (
343
+ {
344
+ variant = "primary",
345
+ icon = Gift,
346
+ children,
347
+ linkLabel,
348
+ linkHref,
349
+ onLinkPress,
350
+ animation,
351
+ iconButtonIcon,
352
+ iconButtonLabel,
353
+ iconButtonVariant,
354
+ onIconButtonPress,
355
+ ...rest
356
+ },
357
+ ref
358
+ ) => {
359
+ const theme = useTheme()
360
+ const { floatingActionButton } = theme.tokens.components
361
+ const { colour, dimensions } = theme.tokens.semantics
362
+ const { shadow } = colour
363
+ const iconButton = ICON_BUTTON_CONFIG[iconButtonIcon]
364
+
365
+ const iconButtonElement = (
366
+ <IconButton
367
+ icon={
368
+ animation === "nudge" && iconButtonIcon !== "close"
369
+ ? NUDGE_ICONS[iconButtonIcon]
370
+ : iconButton.icon
371
+ }
372
+ variant={iconButtonVariant ?? iconButton.defaultVariant}
373
+ size="md"
374
+ aria-label={iconButtonLabel}
375
+ onPress={onIconButtonPress}
376
+ />
377
+ )
378
+
379
+ return (
380
+ <StyledRoot
381
+ ref={ref}
382
+ rootGap={parseTokenValue(floatingActionButton.spacing.gap)}
383
+ rootPaddingH={parseTokenValue(
384
+ floatingActionButton.spacing.horizontalPadding
385
+ )}
386
+ rootPaddingV={parseTokenValue(
387
+ floatingActionButton.spacing.verticalPadding
388
+ )}
389
+ rootBgColor={floatingActionButton.colour.background[variant]}
390
+ rootBorderWidth={parseTokenValue(
391
+ floatingActionButton.borderWidth.default
392
+ )}
393
+ rootBorderColor={floatingActionButton.colour.border[variant]}
394
+ rootBorderRadius={parseTokenValue(
395
+ floatingActionButton.borderRadius.default
396
+ )}
397
+ rootBoxShadow={`${shadow.md.offsetX} ${shadow.md.offsetY} ${shadow.md.blur} ${shadow.md.spread} ${shadow.md.color}`}
398
+ {...rest}
399
+ >
400
+ <StyledIconContainer
401
+ containerSize={parseTokenValue(
402
+ theme.tokens.primitives.dimension.sizing["44"]
403
+ )}
404
+ containerRadius={parseTokenValue(dimensions.borderRadius.round)}
405
+ containerBgColor={
406
+ variant === "secondary"
407
+ ? colour.background.container.secondary
408
+ : colour.background.container.default
409
+ }
410
+ >
411
+ <Icon icon={icon} size="xl" colour="primary" />
412
+ </StyledIconContainer>
413
+ <StyledContent>
414
+ <Typography variant="body" size="lg" weight="medium">
415
+ {children}
416
+ </Typography>
417
+ <Link
418
+ href={linkHref}
419
+ onPress={onLinkPress}
420
+ size="md"
421
+ weight="medium"
422
+ {...(!linkHref && { accessibilityRole: "button" as const })}
423
+ >
424
+ {linkLabel}
425
+ </Link>
426
+ </StyledContent>
427
+ {iconButtonElement}
428
+ {animation === "shine" && (
429
+ <ShineOverlay
430
+ radius={parseTokenValue(floatingActionButton.borderRadius.default)}
431
+ highlight={theme.tokens.primitives.colour.system.neutral["1"]}
432
+ />
433
+ )}
434
+ </StyledRoot>
435
+ )
436
+ }
437
+ )
438
+
439
+ FloatingActionButton.displayName = "FloatingActionButton"
440
+
441
+ export type {
442
+ FloatingActionButtonVariant,
443
+ FloatingActionButtonIconButtonIcon,
444
+ FloatingActionButtonAnimation
445
+ }
@@ -0,0 +1,7 @@
1
+ export { FloatingActionButton } from "./FloatingActionButton"
2
+ export type {
3
+ FloatingActionButtonProps,
4
+ FloatingActionButtonVariant,
5
+ FloatingActionButtonIconButtonIcon,
6
+ FloatingActionButtonAnimation
7
+ } from "./FloatingActionButton"
@@ -26,3 +26,4 @@ export * from "./Countdown"
26
26
  export * from "./ProductListingCard"
27
27
  export * from "./ProductDisplayCard"
28
28
  export * from "./TabNavigation"
29
+ export * from "./FloatingActionButton"