@butternutbox/pawprint-native 1.3.0 → 1.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.
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +23 -0
- package/dist/index.cjs +345 -106
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +62 -3
- package/dist/index.d.ts +62 -3
- package/dist/index.js +313 -77
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__mocks__/react-native.tsx +2 -0
- package/src/components/atoms/Button/Button.stories.tsx +4 -4
- package/src/components/atoms/Button/Button.test.tsx +9 -2
- package/src/components/atoms/Button/Button.tsx +39 -7
- package/src/components/molecules/NavigationHeader/NavigationHeader.stories.tsx +140 -0
- package/src/components/molecules/NavigationHeader/NavigationHeader.test.tsx +110 -0
- package/src/components/molecules/NavigationHeader/NavigationHeader.tsx +273 -0
- package/src/components/molecules/NavigationHeader/NavigationHeaderButton.tsx +87 -0
- package/src/components/molecules/NavigationHeader/NavigationHeaderContext.ts +38 -0
- package/src/components/molecules/NavigationHeader/index.ts +14 -0
- package/src/components/molecules/SelectField/SelectField.test.tsx +18 -0
- package/src/components/molecules/SelectField/SelectField.tsx +12 -0
- package/src/components/molecules/index.ts +1 -0
package/package.json
CHANGED
|
@@ -128,6 +128,7 @@ export const TextInput = React.forwardRef<HTMLInputElement, any>(
|
|
|
128
128
|
placeholderTextColor: _placeholderTextColor,
|
|
129
129
|
onChangeText,
|
|
130
130
|
onChange,
|
|
131
|
+
onBlur,
|
|
131
132
|
value,
|
|
132
133
|
defaultValue,
|
|
133
134
|
placeholder,
|
|
@@ -147,6 +148,7 @@ export const TextInput = React.forwardRef<HTMLInputElement, any>(
|
|
|
147
148
|
placeholder={placeholder}
|
|
148
149
|
disabled={editable === false}
|
|
149
150
|
aria-disabled={accessibilityState?.disabled}
|
|
151
|
+
onBlur={onBlur}
|
|
150
152
|
onChange={(e) => {
|
|
151
153
|
onChangeText?.(e.target.value)
|
|
152
154
|
onChange?.(e)
|
|
@@ -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 =
|
|
154
|
-
|
|
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:
|
|
162
|
-
|
|
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,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
|
+
})
|
|
@@ -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 }
|