@butternutbox/pawprint-native 1.3.1 → 1.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butternutbox/pawprint-native",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -51,6 +51,10 @@ export const useSharedValue = <T,>(initial: T) => ({ value: initial })
51
51
 
52
52
  export const useAnimatedStyle = (_fn: () => object) => ({})
53
53
 
54
+ export const useReducedMotion = () => false
55
+
56
+ export const cancelAnimation = (_value: unknown) => {}
57
+
54
58
  export const runOnJS =
55
59
  <Args extends unknown[], R>(fn: (..._args: Args) => R) =>
56
60
  (...args: Args) =>
@@ -58,6 +62,7 @@ export const runOnJS =
58
62
 
59
63
  export const withTiming = <T,>(value: T) => value
60
64
  export const withSpring = <T,>(value: T) => value
65
+ export const withRepeat = <T,>(value: T) => value
61
66
 
62
67
  // Animation builder stub — supports chaining .duration() / .easing() / .delay()
63
68
  const createBuilder = () => {
@@ -25,3 +25,4 @@ export const Stop = passThrough("Stop")
25
25
  export const G = passThrough("G")
26
26
  export const Path = passThrough("Path")
27
27
  export const ClipPath = passThrough("ClipPath")
28
+ export const Polygon = passThrough("Polygon")
@@ -41,8 +41,8 @@ export default {
41
41
  },
42
42
  colour: {
43
43
  control: { type: "select" },
44
- options: ["primary", "secondary"],
45
- description: "Colour scheme"
44
+ options: ["primary", "secondary", "tertiary"],
45
+ description: "Colour scheme (filled only)"
46
46
  },
47
47
  loading: {
48
48
  control: { type: "boolean" },
@@ -86,7 +86,7 @@ export const AllVariants = () => (
86
86
  {variant}
87
87
  </Typography>
88
88
  <View style={styles.row}>
89
- {(["primary", "secondary"] as const).map((colour) => (
89
+ {(["primary", "secondary", "tertiary"] as const).map((colour) => (
90
90
  <Button key={colour} variant={variant} colour={colour}>
91
91
  {colour}
92
92
  </Button>
@@ -120,7 +120,7 @@ export const AllStates = () => (
120
120
  <View style={styles.column}>
121
121
  {(["filled", "outlined", "text"] as const).map((variant) => (
122
122
  <View key={variant} style={styles.section}>
123
- {(["primary", "secondary"] as const).map((colour) => (
123
+ {(["primary", "secondary", "tertiary"] as const).map((colour) => (
124
124
  <View key={colour} style={styles.section}>
125
125
  <Typography size="sm" weight="semiBold" color="tertiary">
126
126
  {variant} / {colour}
@@ -2,7 +2,7 @@ import React from "react"
2
2
  import { screen } from "@testing-library/react"
3
3
  import userEvent from "@testing-library/user-event"
4
4
  import { describe, it, expect, vi } from "vitest"
5
- import { renderWithTheme } from "../../../test-utils"
5
+ import { renderWithTheme, theme } from "../../../test-utils"
6
6
  import { Button } from "./Button"
7
7
 
8
8
  describe("Button", () => {
@@ -36,13 +36,20 @@ describe("Button", () => {
36
36
  })
37
37
 
38
38
  describe("when rendering colours", () => {
39
- it.each(["primary", "secondary"] as const)(
39
+ it.each(["primary", "secondary", "tertiary"] as const)(
40
40
  "renders %s colour",
41
41
  (colour) => {
42
42
  renderWithTheme(<Button colour={colour}>Colour</Button>)
43
43
  expect(screen.getByText("Colour")).toBeInTheDocument()
44
44
  }
45
45
  )
46
+
47
+ it("applies the tertiary text colour", () => {
48
+ renderWithTheme(<Button colour="tertiary">Tertiary</Button>)
49
+ expect(screen.getByText("Tertiary")).toHaveStyle({
50
+ color: theme.tokens.semantics.colour.text.action.default
51
+ })
52
+ })
46
53
  })
47
54
 
48
55
  describe("when component is interactive", () => {
@@ -1,14 +1,14 @@
1
1
  import React from "react"
2
2
  import { Pressable, View, PressableProps } from "react-native"
3
3
  import styled from "@emotion/native"
4
- import { useTheme } from "@emotion/react"
4
+ import { useTheme, type Theme } from "@emotion/react"
5
5
  import { Typography } from "../Typography"
6
6
  import { Spinner } from "../Spinner"
7
7
  import { resolveFont } from "@butternutbox/pawprint-fonts"
8
8
 
9
9
  type ButtonVariant = "filled" | "outlined" | "text"
10
10
  type ButtonSize = "sm" | "md" | "lg"
11
- type ButtonColour = "primary" | "secondary"
11
+ type ButtonColour = "primary" | "secondary" | "tertiary"
12
12
 
13
13
  type ButtonOwnProps = {
14
14
  variant?: ButtonVariant
@@ -26,6 +26,35 @@ type ButtonProps = ButtonOwnProps &
26
26
 
27
27
  const parseTokenValue = (value: string): number => parseFloat(value)
28
28
 
29
+ type FilledColourTokens = {
30
+ background: {
31
+ default: string
32
+ selected: string
33
+ disabled: string
34
+ }
35
+ text: { default: string; selected?: string }
36
+ }
37
+
38
+ const getFilledColourTokens = (
39
+ theme: Theme,
40
+ colour: ButtonColour
41
+ ): FilledColourTokens => {
42
+ const buttons = theme.tokens.components.buttons
43
+
44
+ if (colour === "tertiary") {
45
+ const { text } = theme.tokens.semantics.colour
46
+ return {
47
+ background: buttons.iconButton.filledButton.colour.background.tertiary,
48
+ text: { default: text.action.default, selected: text.action.inverse }
49
+ }
50
+ }
51
+
52
+ return {
53
+ background: buttons.filledButton.colour.background[colour],
54
+ text: buttons.filledButton.colour.text[colour]
55
+ }
56
+ }
57
+
29
58
  const sizeToTypographyKey: Record<ButtonSize, string> = {
30
59
  lg: "large",
31
60
  md: "medium",
@@ -104,7 +133,7 @@ const StyledSpinnerWrapper = styled(View)({
104
133
  *
105
134
  * @param variant - *(optional)* Visual variant: filled (default), outlined, or text.
106
135
  * @param size - *(optional)* Size variant: sm, md (default), lg
107
- * @param colour - *(optional)* Colour variant: primary (default), secondary
136
+ * @param colour - *(optional)* Colour variant: primary (default), secondary, tertiary. Only applies to filled variant.
108
137
  * @param loading - *(optional)* Shows spinner and disables interaction
109
138
  * @param fullWidth - *(optional)* Stretches button to fill its container
110
139
  * @param startIcon - *(optional)* Leading icon element
@@ -150,16 +179,19 @@ const Button = React.forwardRef<View, ButtonProps>(
150
179
  spinnerColor: string
151
180
  } => {
152
181
  if (variant === "filled") {
153
- const bgTokens = buttons.filledButton.colour.background[colour]
154
- const textToken = buttons.filledButton.colour.text[colour].default
182
+ const { background: bgTokens, text: textTokens } =
183
+ getFilledColourTokens(theme, colour)
155
184
  return {
156
185
  backgroundColor: isDisabled
157
186
  ? bgTokens.disabled
158
187
  : isPressed
159
188
  ? bgTokens.selected
160
189
  : bgTokens.default,
161
- textColor: textToken,
162
- spinnerColor: textToken
190
+ textColor:
191
+ isPressed && textTokens.selected
192
+ ? textTokens.selected
193
+ : textTokens.default,
194
+ spinnerColor: textTokens.default
163
195
  }
164
196
  }
165
197
 
@@ -0,0 +1,21 @@
1
+ import React from "react"
2
+ import { ChefDogLoader } from "./ChefDogLoader"
3
+ import type { ChefDogLoaderProps } from "./ChefDogLoader"
4
+
5
+ export default {
6
+ title: "Atoms/ChefDogLoader",
7
+ component: ChefDogLoader,
8
+ argTypes: {
9
+ size: {
10
+ control: { type: "number" },
11
+ description: "Width and height in points"
12
+ }
13
+ }
14
+ }
15
+
16
+ export const Playground = (args: ChefDogLoaderProps) => (
17
+ <ChefDogLoader {...args} />
18
+ )
19
+ Playground.args = {
20
+ size: 240
21
+ }
@@ -0,0 +1,33 @@
1
+ import React from "react"
2
+ import { View } from "react-native"
3
+ import { screen } from "@testing-library/react"
4
+ import { describe, it, expect } from "vitest"
5
+ import { renderWithTheme } from "../../../test-utils"
6
+ import { ChefDogLoader } from "./ChefDogLoader"
7
+
8
+ describe("ChefDogLoader", () => {
9
+ describe("when component is rendering", () => {
10
+ it("renders with progressbar role", () => {
11
+ renderWithTheme(<ChefDogLoader />)
12
+ expect(screen.getByRole("progressbar")).toBeInTheDocument()
13
+ })
14
+
15
+ it("has accessible loading label", () => {
16
+ renderWithTheme(<ChefDogLoader />)
17
+ expect(screen.getByLabelText("Loading")).toBeInTheDocument()
18
+ })
19
+
20
+ it("accepts a custom accessibility label", () => {
21
+ renderWithTheme(<ChefDogLoader accessibilityLabel="Fetching your plan" />)
22
+ expect(screen.getByLabelText("Fetching your plan")).toBeInTheDocument()
23
+ })
24
+ })
25
+
26
+ describe("when using with ref", () => {
27
+ it("forwards ref", () => {
28
+ const ref = React.createRef<View>()
29
+ renderWithTheme(<ChefDogLoader ref={ref} />)
30
+ expect(ref.current).toBeTruthy()
31
+ })
32
+ })
33
+ })
@@ -0,0 +1,115 @@
1
+ import React from "react"
2
+ import { StyleSheet, View, ViewProps } from "react-native"
3
+ import Svg, { ClipPath, Defs, G, Polygon } from "react-native-svg"
4
+ import Reanimated, {
5
+ cancelAnimation,
6
+ Easing,
7
+ useAnimatedStyle,
8
+ useReducedMotion,
9
+ useSharedValue,
10
+ withRepeat,
11
+ withTiming
12
+ } from "react-native-reanimated"
13
+ import { LabradorChefsHat } from "@butternutbox/pawprint-illustrations/breeds"
14
+
15
+ type ChefDogLoaderOwnProps = {
16
+ size?: number
17
+ }
18
+
19
+ type ChefDogLoaderProps = ChefDogLoaderOwnProps &
20
+ Omit<ViewProps, keyof ChefDogLoaderOwnProps>
21
+
22
+ const DEFAULT_SIZE = 240
23
+ const TAIL_REGION = "0,0 15.5,0 15.5,22.4 12,26.5 0,26.5"
24
+ const BODY_REGION = "15.5,0 48,0 48,48 0,48 0,26.5 12,26.5 15.5,22.4"
25
+ const TAIL_ORIGIN = { x: 13.4 / 48 - 0.5, y: 24.6 / 48 - 0.5 }
26
+ const TAIL_DOWN = -6
27
+ const TAIL_UP = 14
28
+ const WAG_DURATION = 180
29
+
30
+ /**
31
+ * The labrador in a chef's hat wagging its tail, for indeterminate loading states.
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * import { ChefDogLoader } from "@butternutbox/pawprint-native"
36
+ *
37
+ * <ChefDogLoader />
38
+ * <ChefDogLoader size={160} />
39
+ * ```
40
+ *
41
+ * @param size - *(optional)* Width and height in points (default: 240)
42
+ */
43
+ const ChefDogLoader = React.forwardRef<View, ChefDogLoaderProps>(
44
+ ({ size = DEFAULT_SIZE, ...rest }, ref) => {
45
+ const reducedMotion = useReducedMotion()
46
+ const rotation = useSharedValue(TAIL_DOWN)
47
+ const clipId = React.useId().replace(/:/g, "")
48
+
49
+ React.useEffect(() => {
50
+ if (reducedMotion) return
51
+ rotation.value = withRepeat(
52
+ withTiming(TAIL_UP, {
53
+ duration: WAG_DURATION,
54
+ easing: Easing.inOut(Easing.quad)
55
+ }),
56
+ -1,
57
+ true
58
+ )
59
+ return () => cancelAnimation(rotation)
60
+ }, [reducedMotion, rotation])
61
+
62
+ const originX = TAIL_ORIGIN.x * size
63
+ const originY = TAIL_ORIGIN.y * size
64
+
65
+ const tailStyle = useAnimatedStyle(() => ({
66
+ transform: [
67
+ { translateX: originX },
68
+ { translateY: originY },
69
+ { rotate: `${rotation.value}deg` },
70
+ { translateX: -originX },
71
+ { translateY: -originY }
72
+ ]
73
+ }))
74
+
75
+ return (
76
+ <View
77
+ ref={ref}
78
+ accessibilityRole="progressbar"
79
+ accessibilityLabel="Loading"
80
+ {...rest}
81
+ >
82
+ <Svg width={size} height={size} viewBox="0 0 48 48">
83
+ <Defs>
84
+ <ClipPath id={`${clipId}-body`}>
85
+ <Polygon points={BODY_REGION} />
86
+ </ClipPath>
87
+ </Defs>
88
+ <G clipPath={`url(#${clipId}-body)`}>
89
+ <LabradorChefsHat />
90
+ </G>
91
+ </Svg>
92
+ <Reanimated.View
93
+ pointerEvents="none"
94
+ style={[StyleSheet.absoluteFill, tailStyle]}
95
+ >
96
+ <Svg width={size} height={size} viewBox="0 0 48 48">
97
+ <Defs>
98
+ <ClipPath id={`${clipId}-tail`}>
99
+ <Polygon points={TAIL_REGION} />
100
+ </ClipPath>
101
+ </Defs>
102
+ <G clipPath={`url(#${clipId}-tail)`}>
103
+ <LabradorChefsHat />
104
+ </G>
105
+ </Svg>
106
+ </Reanimated.View>
107
+ </View>
108
+ )
109
+ }
110
+ )
111
+
112
+ ChefDogLoader.displayName = "ChefDogLoader"
113
+
114
+ export { ChefDogLoader }
115
+ export type { ChefDogLoaderProps }
@@ -0,0 +1,2 @@
1
+ export { ChefDogLoader } from "./ChefDogLoader"
2
+ export type { ChefDogLoaderProps } from "./ChefDogLoader"
@@ -3,6 +3,7 @@ export * from "./CarouselControls"
3
3
  export * from "./Typography"
4
4
  export * from "./Badge"
5
5
  export * from "./Button"
6
+ export * from "./ChefDogLoader"
6
7
  export * from "./Divider"
7
8
  export * from "./Hint"
8
9
  export * from "./Icon"
@@ -0,0 +1,140 @@
1
+ import React from "react"
2
+ import { View, StyleSheet } from "react-native"
3
+ import { Add, Search, User } from "@butternutbox/pawprint-icons/core"
4
+ import {
5
+ NavigationHeader,
6
+ type NavigationHeaderProps,
7
+ type NavigationHeaderSurface
8
+ } from "./NavigationHeader"
9
+ import { Typography } from "../../atoms/Typography"
10
+
11
+ const actionElements: Record<string, React.ReactNode> = {
12
+ None: undefined,
13
+ Button: <NavigationHeader.Button>Pouch quantity</NavigationHeader.Button>,
14
+ "Icon buttons + Button": (
15
+ <>
16
+ <NavigationHeader.IconButton icon={Search} aria-label="Search" />
17
+ <NavigationHeader.IconButton icon={Add} aria-label="Add" />
18
+ <NavigationHeader.Button>Button</NavigationHeader.Button>
19
+ </>
20
+ ),
21
+ "Primary button": (
22
+ <NavigationHeader.Button variant="filled" colour="primary">
23
+ Button
24
+ </NavigationHeader.Button>
25
+ )
26
+ }
27
+
28
+ export default {
29
+ title: "Molecules/NavigationHeader",
30
+ component: NavigationHeader,
31
+ argTypes: {
32
+ title: {
33
+ control: { type: "text" },
34
+ description: "Page title, truncated to one line."
35
+ },
36
+ variant: {
37
+ control: { type: "select" },
38
+ options: ["primary", "secondary"],
39
+ description:
40
+ "Header type. `primary` for landing pages; `secondary` adds a back button for child pages."
41
+ },
42
+ surface: {
43
+ control: { type: "select" },
44
+ options: ["brand", "subtle", "plain"],
45
+ description: "Background colour."
46
+ },
47
+ backLabel: {
48
+ control: { type: "text" },
49
+ description: "Accessible label for the back button."
50
+ },
51
+ showRadiusPips: {
52
+ control: { type: "boolean" },
53
+ description:
54
+ "Shows the rounded corners below the header. Defaults to `true` on the `brand` surface only."
55
+ },
56
+ safeAreaTop: {
57
+ control: { type: "boolean" },
58
+ description: "Pads the header by the top safe area inset."
59
+ },
60
+ actions: {
61
+ control: { type: "select" },
62
+ options: Object.keys(actionElements),
63
+ mapping: actionElements,
64
+ description: "Right-hand action group."
65
+ }
66
+ }
67
+ }
68
+
69
+ export const Playground = (args: NavigationHeaderProps) => (
70
+ <NavigationHeader {...args} />
71
+ )
72
+ Playground.args = {
73
+ title: "Title",
74
+ variant: "secondary",
75
+ surface: "brand",
76
+ backLabel: "Back",
77
+ safeAreaTop: false,
78
+ actions: actionElements["Icon buttons + Button"]
79
+ }
80
+
81
+ export const AllVariants = () => (
82
+ <View style={styles.column}>
83
+ {(["primary", "secondary"] as const).map((variant) => (
84
+ <NavigationHeader
85
+ key={variant}
86
+ variant={variant}
87
+ title={variant === "primary" ? "Hello, Katie" : "My dog"}
88
+ safeAreaTop={false}
89
+ actions={
90
+ <>
91
+ <NavigationHeader.IconButton icon={User} aria-label="Account" />
92
+ <NavigationHeader.Button>Button</NavigationHeader.Button>
93
+ </>
94
+ }
95
+ />
96
+ ))}
97
+ </View>
98
+ )
99
+
100
+ export const AllSurfaces = () => (
101
+ <View style={styles.column}>
102
+ {(["brand", "subtle", "plain"] as NavigationHeaderSurface[]).map(
103
+ (surface) => (
104
+ <View key={surface} style={styles.section}>
105
+ <Typography size="sm" weight="semiBold" color="tertiary">
106
+ {surface}
107
+ </Typography>
108
+ <NavigationHeader
109
+ variant="secondary"
110
+ surface={surface}
111
+ title="Recipes"
112
+ safeAreaTop={false}
113
+ actions={
114
+ <>
115
+ <NavigationHeader.IconButton icon={Add} aria-label="Add" />
116
+ <NavigationHeader.Button>
117
+ Pouch quantity
118
+ </NavigationHeader.Button>
119
+ </>
120
+ }
121
+ />
122
+ </View>
123
+ )
124
+ )}
125
+ </View>
126
+ )
127
+
128
+ export const LongTitle = () => (
129
+ <NavigationHeader
130
+ variant="secondary"
131
+ title="A very long page title that should truncate before the actions"
132
+ safeAreaTop={false}
133
+ actions={<NavigationHeader.Button>Pouch quantity</NavigationHeader.Button>}
134
+ />
135
+ )
136
+
137
+ const styles = StyleSheet.create({
138
+ column: { flexDirection: "column", gap: 24 },
139
+ section: { gap: 8 }
140
+ })
@@ -0,0 +1,110 @@
1
+ import React from "react"
2
+ import { screen } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+ import { describe, it, expect, vi } from "vitest"
5
+ import { renderWithTheme } from "../../../test-utils"
6
+ import { NavigationHeader } from "./NavigationHeader"
7
+
8
+ const MockIcon = () => <span data-testid="mock-icon" />
9
+
10
+ describe("NavigationHeader", () => {
11
+ describe("when component is rendering", () => {
12
+ it("renders the title", () => {
13
+ renderWithTheme(<NavigationHeader title="Recipes" />)
14
+ expect(screen.getByText("Recipes")).toBeInTheDocument()
15
+ })
16
+
17
+ it.each(["primary", "secondary"] as const)(
18
+ "renders the %s variant",
19
+ (variant) => {
20
+ renderWithTheme(<NavigationHeader title="Recipes" variant={variant} />)
21
+ expect(screen.getByText("Recipes")).toBeInTheDocument()
22
+ }
23
+ )
24
+
25
+ it.each(["brand", "subtle", "plain"] as const)(
26
+ "renders the %s surface",
27
+ (surface) => {
28
+ renderWithTheme(<NavigationHeader title="Recipes" surface={surface} />)
29
+ expect(screen.getByText("Recipes")).toBeInTheDocument()
30
+ }
31
+ )
32
+ })
33
+
34
+ describe("when rendering the back button", () => {
35
+ it("does not render it for the primary variant", () => {
36
+ renderWithTheme(<NavigationHeader title="Home" onBack={vi.fn()} />)
37
+ expect(screen.queryByLabelText("Back")).not.toBeInTheDocument()
38
+ })
39
+
40
+ it("calls onBack when pressed", async () => {
41
+ const user = userEvent.setup()
42
+ const onBack = vi.fn()
43
+ renderWithTheme(
44
+ <NavigationHeader title="Recipes" variant="secondary" onBack={onBack} />
45
+ )
46
+
47
+ await user.click(screen.getByLabelText("Back"))
48
+ expect(onBack).toHaveBeenCalledTimes(1)
49
+ })
50
+
51
+ it("uses a custom back label", () => {
52
+ renderWithTheme(
53
+ <NavigationHeader
54
+ title="Recipes"
55
+ variant="secondary"
56
+ backLabel="Retour"
57
+ />
58
+ )
59
+ expect(screen.getByLabelText("Retour")).toBeInTheDocument()
60
+ })
61
+ })
62
+
63
+ describe("when rendering actions", () => {
64
+ it("renders text and icon actions", async () => {
65
+ const user = userEvent.setup()
66
+ const onPress = vi.fn()
67
+ renderWithTheme(
68
+ <NavigationHeader
69
+ title="Recipes"
70
+ actions={
71
+ <>
72
+ <NavigationHeader.IconButton icon={MockIcon} aria-label="Add" />
73
+ <NavigationHeader.Button onPress={onPress}>
74
+ Pouch quantity
75
+ </NavigationHeader.Button>
76
+ </>
77
+ }
78
+ />
79
+ )
80
+ expect(screen.getByLabelText("Add")).toBeInTheDocument()
81
+
82
+ await user.click(screen.getByText("Pouch quantity"))
83
+ expect(onPress).toHaveBeenCalledTimes(1)
84
+ })
85
+ })
86
+
87
+ describe("when rendering radius pips", () => {
88
+ const countPips = (container: HTMLElement) =>
89
+ container.querySelectorAll('[data-mock="Svg"]').length
90
+
91
+ it("renders them on the brand surface by default", () => {
92
+ const { container } = renderWithTheme(
93
+ <NavigationHeader title="Recipes" />
94
+ )
95
+ expect(countPips(container)).toBe(2)
96
+ })
97
+
98
+ it("hides them on other surfaces unless enabled", () => {
99
+ const { container, rerender } = renderWithTheme(
100
+ <NavigationHeader title="Recipes" surface="subtle" />
101
+ )
102
+ expect(countPips(container)).toBe(0)
103
+
104
+ rerender(
105
+ <NavigationHeader title="Recipes" surface="subtle" showRadiusPips />
106
+ )
107
+ expect(countPips(container)).toBe(2)
108
+ })
109
+ })
110
+ })