@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.
@@ -0,0 +1,273 @@
1
+ import React from "react"
2
+ import { View, ViewProps } from "react-native"
3
+ import Svg, { Path } from "react-native-svg"
4
+ import { useSafeAreaInsets } from "react-native-safe-area-context"
5
+ import styled from "@emotion/native"
6
+ import { useTheme, type Theme } from "@emotion/react"
7
+ import { ArrowBack } from "@butternutbox/pawprint-icons/core"
8
+ import { IconButton } from "../../atoms/IconButton"
9
+ import { Typography } from "../../atoms/Typography"
10
+ import {
11
+ NavigationHeaderButton,
12
+ NavigationHeaderIconButton
13
+ } from "./NavigationHeaderButton"
14
+ import {
15
+ NavigationHeaderContext,
16
+ getIconButtonColour,
17
+ type NavigationHeaderContextValue,
18
+ type NavigationHeaderSurface
19
+ } from "./NavigationHeaderContext"
20
+
21
+ type NavigationHeaderVariant = "primary" | "secondary"
22
+
23
+ type NavigationHeaderOwnProps = {
24
+ title: string
25
+ variant?: NavigationHeaderVariant
26
+ surface?: NavigationHeaderSurface
27
+ onBack?: () => void
28
+ backLabel?: string
29
+ actions?: React.ReactNode
30
+ showRadiusPips?: boolean
31
+ safeAreaTop?: boolean
32
+ }
33
+
34
+ export type NavigationHeaderProps = NavigationHeaderOwnProps &
35
+ Omit<ViewProps, keyof NavigationHeaderOwnProps | "children">
36
+
37
+ const parseTokenValue = (value: string): number => parseFloat(value)
38
+
39
+ const getSurfaceColour = (theme: Theme, surface: NavigationHeaderSurface) => {
40
+ const { navigationHeader } = theme.tokens.components
41
+ const { background } = theme.tokens.semantics.colour
42
+
43
+ return {
44
+ brand: navigationHeader.colour.surface.default,
45
+ subtle: background.surface.secondary,
46
+ plain: background.surface.default
47
+ }[surface]
48
+ }
49
+
50
+ const StyledNavContainer = styled(View)<{
51
+ containerPaddingTop: number
52
+ containerPaddingV: number
53
+ containerPaddingH: number
54
+ containerGap: number
55
+ containerBgColor: string
56
+ }>(
57
+ ({
58
+ containerPaddingTop,
59
+ containerPaddingV,
60
+ containerPaddingH,
61
+ containerGap,
62
+ containerBgColor
63
+ }) => ({
64
+ flexDirection: "row",
65
+ alignItems: "center",
66
+ paddingTop: containerPaddingTop + containerPaddingV,
67
+ paddingBottom: containerPaddingV,
68
+ paddingHorizontal: containerPaddingH,
69
+ gap: containerGap,
70
+ backgroundColor: containerBgColor
71
+ })
72
+ )
73
+
74
+ const StyledTitleGroup = styled(View)<{
75
+ titleGroupGap: number
76
+ titleGroupMinHeight: number
77
+ }>(({ titleGroupGap, titleGroupMinHeight }) => ({
78
+ flex: 1,
79
+ flexDirection: "row",
80
+ alignItems: "center",
81
+ minWidth: 0,
82
+ minHeight: titleGroupMinHeight,
83
+ gap: titleGroupGap
84
+ }))
85
+
86
+ const StyledTitle = styled(View)({
87
+ flex: 1,
88
+ minWidth: 0
89
+ })
90
+
91
+ const StyledActionGroup = styled(View)<{ actionGroupGap: number }>(
92
+ ({ actionGroupGap }) => ({
93
+ flexDirection: "row",
94
+ alignItems: "center",
95
+ flexShrink: 0,
96
+ gap: actionGroupGap
97
+ })
98
+ )
99
+
100
+ const StyledRadiusPips = styled(View)({
101
+ flexDirection: "row",
102
+ justifyContent: "space-between"
103
+ })
104
+
105
+ const RadiusPips = ({ radius, colour }: { radius: number; colour: string }) => (
106
+ <StyledRadiusPips
107
+ accessible={false}
108
+ importantForAccessibility="no-hide-descendants"
109
+ >
110
+ <Svg width={radius} height={radius}>
111
+ <Path
112
+ d={`M0 0H${radius}A${radius} ${radius} 0 0 0 0 ${radius}Z`}
113
+ fill={colour}
114
+ />
115
+ </Svg>
116
+ <Svg width={radius} height={radius}>
117
+ <Path
118
+ d={`M${radius} 0H0A${radius} ${radius} 0 0 1 ${radius} ${radius}Z`}
119
+ fill={colour}
120
+ />
121
+ </Svg>
122
+ </StyledRadiusPips>
123
+ )
124
+
125
+ /**
126
+ * Navigation header for the native app. Gives users context of where they
127
+ * are, a way back, and the most important page-level actions.
128
+ *
129
+ * - `primary` — landing pages: large title, no back button.
130
+ * - `secondary` — child pages: back button and a smaller title.
131
+ *
132
+ * Actions are passed via `actions`. Use `NavigationHeader.Button` and
133
+ * `NavigationHeader.IconButton` for the header defaults, which follow the
134
+ * surface; their `variant` and `colour` can be overridden. Keep to at most
135
+ * three.
136
+ *
137
+ * Note: Unlike the web version, there is no desktop layout, the header extends
138
+ * behind the status bar by applying the top safe area inset (`safeAreaTop`),
139
+ * and `onBack` is a press handler.
140
+ *
141
+ * @param {string} title - Page title, truncated to one line.
142
+ * @param {"primary" | "secondary"} [variant="primary"] - Header type.
143
+ * @param {"brand" | "subtle" | "plain"} [surface="brand"] - Background colour.
144
+ * `brand` is the yellow navigation surface, `subtle` the cream page surface
145
+ * and `plain` the default page surface.
146
+ * @param {() => void} [onBack] - Back button press handler. `secondary` only.
147
+ * @param {string} [backLabel="Back"] - Accessible label for the back button.
148
+ * The back button is `tertiary` on the `brand` surface and `secondary`
149
+ * otherwise.
150
+ * @param {React.ReactNode} [actions] - Right-hand action group.
151
+ * @param {boolean} [showRadiusPips] - Shows the rounded corners below the
152
+ * header. Defaults to `true` on the `brand` surface only.
153
+ * @param {boolean} [safeAreaTop=true] - Pads the header by the top safe area
154
+ * inset.
155
+ *
156
+ * @example
157
+ * ```tsx
158
+ * import { NavigationHeader } from "@butternutbox/pawprint-native"
159
+ *
160
+ * <NavigationHeader
161
+ * variant="secondary"
162
+ * title="Recipes"
163
+ * onBack={navigation.goBack}
164
+ * actions={
165
+ * <NavigationHeader.Button onPress={editQuantity}>
166
+ * Pouch quantity
167
+ * </NavigationHeader.Button>
168
+ * }
169
+ * />
170
+ * ```
171
+ */
172
+ const NavigationHeaderRoot = React.forwardRef<View, NavigationHeaderProps>(
173
+ (
174
+ {
175
+ title,
176
+ variant = "primary",
177
+ surface = "brand",
178
+ onBack,
179
+ backLabel = "Back",
180
+ actions,
181
+ showRadiusPips = surface === "brand",
182
+ safeAreaTop = true,
183
+ ...rest
184
+ },
185
+ ref
186
+ ) => {
187
+ const theme = useTheme()
188
+ const { top } = useSafeAreaInsets()
189
+ const { navigationHeader, buttons } = theme.tokens.components
190
+ const { spacing } = navigationHeader
191
+ const surfaceColour = getSurfaceColour(theme, surface)
192
+ const contextValue = React.useMemo<NavigationHeaderContextValue>(
193
+ () => ({ surface }),
194
+ [surface]
195
+ )
196
+
197
+ return (
198
+ <NavigationHeaderContext.Provider value={contextValue}>
199
+ <View ref={ref} {...rest}>
200
+ <StyledNavContainer
201
+ containerPaddingTop={safeAreaTop ? top : 0}
202
+ containerPaddingV={parseTokenValue(
203
+ spacing.navContainer.verticalPadding
204
+ )}
205
+ containerPaddingH={parseTokenValue(
206
+ spacing.navContainer.horizontalPadding
207
+ )}
208
+ containerGap={parseTokenValue(spacing.navContainer.gap)}
209
+ containerBgColor={surfaceColour}
210
+ >
211
+ <StyledTitleGroup
212
+ titleGroupGap={parseTokenValue(spacing.titleGroup.gap)}
213
+ titleGroupMinHeight={parseTokenValue(buttons.size.md.minHeight)}
214
+ >
215
+ {variant === "secondary" && (
216
+ <IconButton
217
+ icon={ArrowBack}
218
+ variant="filled"
219
+ colour={getIconButtonColour(contextValue)}
220
+ size="md"
221
+ aria-label={backLabel}
222
+ accessibilityRole="button"
223
+ onPress={onBack}
224
+ />
225
+ )}
226
+ <StyledTitle>
227
+ <Typography
228
+ token={navigationHeader.typography[variant]}
229
+ color={navigationHeader.colour.text.default}
230
+ accessibilityRole="header"
231
+ noWrap
232
+ >
233
+ {title}
234
+ </Typography>
235
+ </StyledTitle>
236
+ </StyledTitleGroup>
237
+ {actions && (
238
+ <StyledActionGroup
239
+ actionGroupGap={parseTokenValue(spacing.actionGroup.gap)}
240
+ >
241
+ {actions}
242
+ </StyledActionGroup>
243
+ )}
244
+ </StyledNavContainer>
245
+ {showRadiusPips && (
246
+ <RadiusPips
247
+ radius={parseTokenValue(
248
+ theme.tokens.semantics.dimensions.borderRadius.lg
249
+ )}
250
+ colour={surfaceColour}
251
+ />
252
+ )}
253
+ </View>
254
+ </NavigationHeaderContext.Provider>
255
+ )
256
+ }
257
+ )
258
+
259
+ NavigationHeaderRoot.displayName = "NavigationHeader"
260
+
261
+ type NavigationHeaderComponent = React.ForwardRefExoticComponent<
262
+ NavigationHeaderProps & React.RefAttributes<View>
263
+ > & {
264
+ Button: typeof NavigationHeaderButton
265
+ IconButton: typeof NavigationHeaderIconButton
266
+ }
267
+
268
+ export const NavigationHeader = Object.assign(NavigationHeaderRoot, {
269
+ Button: NavigationHeaderButton,
270
+ IconButton: NavigationHeaderIconButton
271
+ }) as NavigationHeaderComponent
272
+
273
+ export type { NavigationHeaderVariant, NavigationHeaderSurface }
@@ -0,0 +1,87 @@
1
+ import React from "react"
2
+ import { View } from "react-native"
3
+ import { Button, type ButtonProps } from "../../atoms/Button"
4
+ import { IconButton, type IconButtonProps } from "../../atoms/IconButton"
5
+ import {
6
+ getButtonDefaults,
7
+ getIconButtonColour,
8
+ useNavigationHeaderContext
9
+ } from "./NavigationHeaderContext"
10
+
11
+ export type NavigationHeaderButtonProps = Omit<
12
+ ButtonProps,
13
+ "size" | "fullWidth"
14
+ >
15
+
16
+ export type NavigationHeaderIconButtonProps = Omit<IconButtonProps, "size">
17
+
18
+ /**
19
+ * Text button for the navigation header action group. A medium `Button` with
20
+ * the header defaults: white `filled` / `tertiary` on the `brand` surface and
21
+ * `outlined` on other surfaces.
22
+ *
23
+ * Passing `variant` or `colour` overrides the default.
24
+ *
25
+ * @param {"filled" | "outlined" | "text"} [variant] - Button style.
26
+ * @param {"primary" | "secondary" | "tertiary"} [colour] - Filled colour.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <NavigationHeader.Button onPress={editQuantity}>
31
+ * Pouch quantity
32
+ * </NavigationHeader.Button>
33
+ * ```
34
+ */
35
+ export const NavigationHeaderButton = React.forwardRef<
36
+ View,
37
+ NavigationHeaderButtonProps
38
+ >(({ variant, colour, ...props }, ref) => {
39
+ const defaults = getButtonDefaults(useNavigationHeaderContext())
40
+
41
+ return (
42
+ <Button
43
+ ref={ref}
44
+ size="md"
45
+ variant={variant ?? defaults.variant}
46
+ colour={colour ?? defaults.colour}
47
+ {...props}
48
+ />
49
+ )
50
+ })
51
+
52
+ NavigationHeaderButton.displayName = "NavigationHeader.Button"
53
+
54
+ /**
55
+ * Icon button for the navigation header action group. A medium filled
56
+ * `IconButton`, `tertiary` on the `brand` surface and `secondary` on other
57
+ * surfaces.
58
+ *
59
+ * Passing `variant` or `colour` overrides the default.
60
+ *
61
+ * @param {PawprintIcon} icon - **(required)** Icon component.
62
+ * @param {string} aria-label - **(required)** Accessible label.
63
+ * @param {"filled" | "outlined" | "text"} [variant="filled"] - Button style.
64
+ * @param {"primary" | "secondary" | "tertiary"} [colour] - Filled colour.
65
+ *
66
+ * @example
67
+ * ```tsx
68
+ * <NavigationHeader.IconButton icon={Add} aria-label="Add dog" />
69
+ * ```
70
+ */
71
+ export const NavigationHeaderIconButton = React.forwardRef<
72
+ View,
73
+ NavigationHeaderIconButtonProps
74
+ >(({ colour, ...props }, ref) => {
75
+ const context = useNavigationHeaderContext()
76
+
77
+ return (
78
+ <IconButton
79
+ ref={ref}
80
+ size="md"
81
+ colour={colour ?? getIconButtonColour(context)}
82
+ {...props}
83
+ />
84
+ )
85
+ })
86
+
87
+ NavigationHeaderIconButton.displayName = "NavigationHeader.IconButton"
@@ -0,0 +1,38 @@
1
+ import React from "react"
2
+ import type { ButtonColour, ButtonVariant } from "../../atoms/Button"
3
+ import type { IconButtonColour } from "../../atoms/IconButton"
4
+
5
+ type NavigationHeaderSurface = "brand" | "subtle" | "plain"
6
+
7
+ type NavigationHeaderContextValue = {
8
+ surface: NavigationHeaderSurface
9
+ }
10
+
11
+ const NavigationHeaderContext =
12
+ React.createContext<NavigationHeaderContextValue>({ surface: "brand" })
13
+
14
+ const useNavigationHeaderContext = () =>
15
+ React.useContext(NavigationHeaderContext)
16
+
17
+ const getButtonDefaults = ({
18
+ surface
19
+ }: NavigationHeaderContextValue): {
20
+ variant: ButtonVariant
21
+ colour: ButtonColour
22
+ } =>
23
+ surface === "brand"
24
+ ? { variant: "filled", colour: "tertiary" }
25
+ : { variant: "outlined", colour: "primary" }
26
+
27
+ const getIconButtonColour = ({
28
+ surface
29
+ }: NavigationHeaderContextValue): IconButtonColour =>
30
+ surface === "brand" ? "tertiary" : "secondary"
31
+
32
+ export {
33
+ NavigationHeaderContext,
34
+ useNavigationHeaderContext,
35
+ getButtonDefaults,
36
+ getIconButtonColour
37
+ }
38
+ export type { NavigationHeaderSurface, NavigationHeaderContextValue }
@@ -0,0 +1,14 @@
1
+ export { NavigationHeader } from "./NavigationHeader"
2
+ export type {
3
+ NavigationHeaderProps,
4
+ NavigationHeaderVariant,
5
+ NavigationHeaderSurface
6
+ } from "./NavigationHeader"
7
+ export {
8
+ NavigationHeaderButton,
9
+ NavigationHeaderIconButton
10
+ } from "./NavigationHeaderButton"
11
+ export type {
12
+ NavigationHeaderButtonProps,
13
+ NavigationHeaderIconButtonProps
14
+ } from "./NavigationHeaderButton"
@@ -27,3 +27,4 @@ export * from "./ProductListingCard"
27
27
  export * from "./ProductDisplayCard"
28
28
  export * from "./TabNavigation"
29
29
  export * from "./FloatingActionButton"
30
+ export * from "./NavigationHeader"