@butternutbox/pawprint-native 0.14.0 → 0.15.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 +6 -0
- package/dist/index.cjs +196 -72
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +95 -1
- package/dist/index.d.ts +95 -1
- package/dist/index.js +195 -73
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/molecules/Menu/Menu.stories.tsx +132 -0
- package/src/components/molecules/Menu/Menu.test.tsx +60 -0
- package/src/components/molecules/Menu/Menu.tsx +143 -0
- package/src/components/molecules/Menu/MenuItem.tsx +103 -0
- package/src/components/molecules/Menu/index.ts +4 -0
- package/src/components/molecules/index.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react"
|
|
2
|
+
import { View, StyleSheet } from "react-native"
|
|
3
|
+
import { Menu as MenuIcon } from "@butternutbox/pawprint-icons/core"
|
|
4
|
+
import { Menu } from "./Menu"
|
|
5
|
+
import { IconButton } from "../../atoms/IconButton"
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
title: "Molecules/Menu",
|
|
9
|
+
component: Menu,
|
|
10
|
+
argTypes: {
|
|
11
|
+
open: { control: "boolean", description: "Whether the menu is visible" },
|
|
12
|
+
align: {
|
|
13
|
+
control: "select",
|
|
14
|
+
options: ["left", "right"],
|
|
15
|
+
description: "Which edge the card aligns to"
|
|
16
|
+
},
|
|
17
|
+
width: { control: "number", description: "Card width in px" }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const styles = StyleSheet.create({
|
|
22
|
+
bar: {
|
|
23
|
+
height: 64,
|
|
24
|
+
flexDirection: "row",
|
|
25
|
+
alignItems: "center",
|
|
26
|
+
justifyContent: "flex-end",
|
|
27
|
+
paddingHorizontal: 8
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// `anchorOffset` is a *window* coordinate — the distance from the top of the
|
|
32
|
+
// screen to the bar's bottom edge. In a real app the header sits at the top of
|
|
33
|
+
// the screen, so this is roughly the bar's height. In Storybook the bar sits
|
|
34
|
+
// lower in the canvas, so we measure its actual on-screen position; otherwise
|
|
35
|
+
// the card drops from the wrong Y and looks offset from the bar.
|
|
36
|
+
const useBarAnchor = () => {
|
|
37
|
+
const ref = useRef<View>(null)
|
|
38
|
+
const [anchorOffset, setAnchorOffset] = useState(0)
|
|
39
|
+
|
|
40
|
+
const measure = () =>
|
|
41
|
+
ref.current?.measureInWindow((_x, y, _w, height) =>
|
|
42
|
+
setAnchorOffset(y + height)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return { ref, anchorOffset, measure }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Playground ---
|
|
49
|
+
// The `open` control seeds the initial state; the burger reopens it and an
|
|
50
|
+
// outside tap (or the OS back gesture) dismisses via `onClose`. Without local
|
|
51
|
+
// state the menu would be stuck open, since the RN Modal overlays Storybook's
|
|
52
|
+
// own controls panel and blocks you from flipping the `open` toggle back off.
|
|
53
|
+
|
|
54
|
+
export const Playground = {
|
|
55
|
+
args: {
|
|
56
|
+
open: true,
|
|
57
|
+
align: "right",
|
|
58
|
+
width: 360
|
|
59
|
+
},
|
|
60
|
+
render: (args: React.ComponentProps<typeof Menu>) => {
|
|
61
|
+
const [open, setOpen] = useState(args.open)
|
|
62
|
+
const { ref, anchorOffset, measure } = useBarAnchor()
|
|
63
|
+
|
|
64
|
+
// Re-sync when the `open` control is toggled in the Storybook panel.
|
|
65
|
+
useEffect(() => setOpen(args.open), [args.open])
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<View>
|
|
69
|
+
<View ref={ref} style={styles.bar} onLayout={measure}>
|
|
70
|
+
<IconButton
|
|
71
|
+
icon={MenuIcon}
|
|
72
|
+
variant="text"
|
|
73
|
+
aria-label="Open menu"
|
|
74
|
+
onPress={() => setOpen(true)}
|
|
75
|
+
/>
|
|
76
|
+
</View>
|
|
77
|
+
<Menu
|
|
78
|
+
{...args}
|
|
79
|
+
open={open}
|
|
80
|
+
onClose={() => setOpen(false)}
|
|
81
|
+
anchorOffset={anchorOffset}
|
|
82
|
+
>
|
|
83
|
+
<Menu.Item onPress={() => {}}>Option 1</Menu.Item>
|
|
84
|
+
<Menu.Item onPress={() => {}}>Option 2</Menu.Item>
|
|
85
|
+
<Menu.Item variant="destructive" onPress={() => {}}>
|
|
86
|
+
Option 3
|
|
87
|
+
</Menu.Item>
|
|
88
|
+
<Menu.Item variant="destructive" divider={false} onPress={() => {}}>
|
|
89
|
+
Option 4
|
|
90
|
+
</Menu.Item>
|
|
91
|
+
</Menu>
|
|
92
|
+
</View>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Interactive (tap the burger) ---
|
|
98
|
+
|
|
99
|
+
export const Interactive = {
|
|
100
|
+
name: "Interactive / Header trigger",
|
|
101
|
+
render: () => {
|
|
102
|
+
const [open, setOpen] = useState(false)
|
|
103
|
+
const { ref, anchorOffset, measure } = useBarAnchor()
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<View>
|
|
107
|
+
<View ref={ref} style={styles.bar} onLayout={measure}>
|
|
108
|
+
<IconButton
|
|
109
|
+
icon={MenuIcon}
|
|
110
|
+
variant="text"
|
|
111
|
+
aria-label="Open menu"
|
|
112
|
+
onPress={() => setOpen(true)}
|
|
113
|
+
/>
|
|
114
|
+
</View>
|
|
115
|
+
<Menu
|
|
116
|
+
open={open}
|
|
117
|
+
onClose={() => setOpen(false)}
|
|
118
|
+
anchorOffset={anchorOffset}
|
|
119
|
+
>
|
|
120
|
+
<Menu.Item onPress={() => setOpen(false)}>Option 1</Menu.Item>
|
|
121
|
+
<Menu.Item
|
|
122
|
+
variant="destructive"
|
|
123
|
+
divider={false}
|
|
124
|
+
onPress={() => setOpen(false)}
|
|
125
|
+
>
|
|
126
|
+
Option 2
|
|
127
|
+
</Menu.Item>
|
|
128
|
+
</Menu>
|
|
129
|
+
</View>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { screen, fireEvent } from "@testing-library/react"
|
|
3
|
+
import { describe, it, expect, vi } from "vitest"
|
|
4
|
+
import { renderWithTheme } from "../../../test-utils"
|
|
5
|
+
import { Menu } from "./Menu"
|
|
6
|
+
|
|
7
|
+
const BasicMenu = ({
|
|
8
|
+
open = true,
|
|
9
|
+
onClose = () => {},
|
|
10
|
+
onDiscounts = () => {},
|
|
11
|
+
onLogOut = () => {}
|
|
12
|
+
}: {
|
|
13
|
+
open?: boolean
|
|
14
|
+
onClose?: () => void
|
|
15
|
+
onDiscounts?: () => void
|
|
16
|
+
onLogOut?: () => void
|
|
17
|
+
}) => (
|
|
18
|
+
<Menu open={open} onClose={onClose} anchorOffset={80}>
|
|
19
|
+
<Menu.Item onPress={onDiscounts}>Discount codes</Menu.Item>
|
|
20
|
+
<Menu.Item variant="destructive" onPress={onLogOut}>
|
|
21
|
+
Log out
|
|
22
|
+
</Menu.Item>
|
|
23
|
+
</Menu>
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
describe("Menu", () => {
|
|
27
|
+
describe("rendering", () => {
|
|
28
|
+
it("does not render items when closed", () => {
|
|
29
|
+
renderWithTheme(<BasicMenu open={false} />)
|
|
30
|
+
expect(screen.queryByText("Discount codes")).not.toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("renders every item when open", () => {
|
|
34
|
+
renderWithTheme(<BasicMenu />)
|
|
35
|
+
expect(screen.getByText("Discount codes")).toBeInTheDocument()
|
|
36
|
+
expect(screen.getByText("Log out")).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("exposes each row as a menuitem", () => {
|
|
40
|
+
renderWithTheme(<BasicMenu />)
|
|
41
|
+
expect(screen.getAllByRole("menuitem")).toHaveLength(2)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("interaction", () => {
|
|
46
|
+
it("fires an item's onPress when tapped", () => {
|
|
47
|
+
const onLogOut = vi.fn()
|
|
48
|
+
renderWithTheme(<BasicMenu onLogOut={onLogOut} />)
|
|
49
|
+
fireEvent.click(screen.getByText("Log out"))
|
|
50
|
+
expect(onLogOut).toHaveBeenCalledTimes(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("does not close when a row is tapped (consumer decides)", () => {
|
|
54
|
+
const onClose = vi.fn()
|
|
55
|
+
renderWithTheme(<BasicMenu onClose={onClose} />)
|
|
56
|
+
fireEvent.click(screen.getByText("Discount codes"))
|
|
57
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { Modal, Pressable, View } from "react-native"
|
|
3
|
+
import styled from "@emotion/native"
|
|
4
|
+
import { MenuItem } from "./MenuItem"
|
|
5
|
+
|
|
6
|
+
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
7
|
+
|
|
8
|
+
export type MenuAlign = "left" | "right"
|
|
9
|
+
|
|
10
|
+
export type MenuProps = {
|
|
11
|
+
/** Whether the menu is visible. */
|
|
12
|
+
open: boolean
|
|
13
|
+
/** Called when the user taps outside the card or dismisses via the OS. */
|
|
14
|
+
onClose: () => void
|
|
15
|
+
/**
|
|
16
|
+
* Distance, in px, from the top of the screen to where the menu drops from —
|
|
17
|
+
* typically the bottom edge of the bar/header the menu hangs beneath, so the
|
|
18
|
+
* card opens flush below it.
|
|
19
|
+
*/
|
|
20
|
+
anchorOffset?: number
|
|
21
|
+
/** Which edge the card aligns to. Defaults to `right`. */
|
|
22
|
+
align?: MenuAlign
|
|
23
|
+
/** Card width in px. Defaults to 360 (capped at 90% of the screen). */
|
|
24
|
+
width?: number
|
|
25
|
+
/** Accessibility label for the outside-tap dismiss layer. */
|
|
26
|
+
closeAccessibilityLabel?: string
|
|
27
|
+
/** `Menu.Item` rows. */
|
|
28
|
+
children: React.ReactNode
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Full-screen, transparent tap-catcher that dismisses on any outside press.
|
|
32
|
+
// Sits beneath the card so the card renders over it. The page is left
|
|
33
|
+
// undarkened — matching web and the designs — so this layer is invisible and
|
|
34
|
+
// only exists to capture outside taps.
|
|
35
|
+
const DismissLayer = styled(Pressable)({
|
|
36
|
+
position: "absolute",
|
|
37
|
+
top: 0,
|
|
38
|
+
left: 0,
|
|
39
|
+
right: 0,
|
|
40
|
+
bottom: 0
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Positions the card against the aligned edge, flush beneath the anchor
|
|
44
|
+
// (`top` set inline to `anchorOffset`).
|
|
45
|
+
const Anchor = styled(View)<{ align: MenuAlign }>(({ theme, align }) => {
|
|
46
|
+
const inset = parseTokenValue(theme.tokens.semantics.dimensions.spacing.md)
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
position: "absolute",
|
|
50
|
+
...(align === "right" ? { right: inset } : { left: inset })
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// A Pressable (rather than a View) so taps on the card's own chrome are
|
|
55
|
+
// captured and don't fall through to the DismissLayer, which would dismiss it.
|
|
56
|
+
const Card = styled(Pressable)<{ width: number }>(({ theme, width }) => {
|
|
57
|
+
const { borderRadius } = theme.tokens.semantics.dimensions
|
|
58
|
+
const { shadow, background } = theme.tokens.semantics.colour
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
width,
|
|
62
|
+
maxWidth: "90%",
|
|
63
|
+
borderRadius: parseTokenValue(borderRadius.md),
|
|
64
|
+
overflow: "hidden",
|
|
65
|
+
backgroundColor: background.surface.default,
|
|
66
|
+
// Shadow token colour carries its own alpha, so opacity stays at 1.
|
|
67
|
+
shadowColor: shadow.md.color,
|
|
68
|
+
shadowOffset: {
|
|
69
|
+
width: parseTokenValue(shadow.md.offsetX),
|
|
70
|
+
height: parseTokenValue(shadow.md.offsetY)
|
|
71
|
+
},
|
|
72
|
+
shadowOpacity: 1,
|
|
73
|
+
shadowRadius: parseTokenValue(shadow.md.blur),
|
|
74
|
+
elevation: 8
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const MenuRoot = ({
|
|
79
|
+
open,
|
|
80
|
+
onClose,
|
|
81
|
+
anchorOffset = 0,
|
|
82
|
+
align = "right",
|
|
83
|
+
width = 360,
|
|
84
|
+
closeAccessibilityLabel = "Close menu",
|
|
85
|
+
children
|
|
86
|
+
}: MenuProps) => {
|
|
87
|
+
return (
|
|
88
|
+
<Modal
|
|
89
|
+
visible={open}
|
|
90
|
+
transparent
|
|
91
|
+
statusBarTranslucent
|
|
92
|
+
animationType="fade"
|
|
93
|
+
onRequestClose={onClose}
|
|
94
|
+
>
|
|
95
|
+
<DismissLayer
|
|
96
|
+
onPress={onClose}
|
|
97
|
+
accessible={false}
|
|
98
|
+
accessibilityLabel={closeAccessibilityLabel}
|
|
99
|
+
/>
|
|
100
|
+
<Anchor align={align} style={{ top: anchorOffset }}>
|
|
101
|
+
<Card
|
|
102
|
+
width={width}
|
|
103
|
+
onPress={() => {}}
|
|
104
|
+
accessible={false}
|
|
105
|
+
accessibilityRole="menu"
|
|
106
|
+
>
|
|
107
|
+
{children}
|
|
108
|
+
</Card>
|
|
109
|
+
</Anchor>
|
|
110
|
+
</Modal>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
MenuRoot.displayName = "Menu"
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A dropdown menu that expands beneath an anchor (typically a header/bar).
|
|
118
|
+
* Tapping outside the card dismisses it; the page behind is left undarkened.
|
|
119
|
+
* The consumer owns open/close state and supplies the rows as `Menu.Item`
|
|
120
|
+
* children; the app-specific content stays at the call site.
|
|
121
|
+
*
|
|
122
|
+
* Pass `anchorOffset` (the anchor bar's bottom edge in window coordinates) so
|
|
123
|
+
* the card drops just below it.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```tsx
|
|
127
|
+
* const [open, setOpen] = useState(false)
|
|
128
|
+
* const [barHeight, setBarHeight] = useState(0)
|
|
129
|
+
*
|
|
130
|
+
* <View onLayout={(e) => setBarHeight(e.nativeEvent.layout.height)}>
|
|
131
|
+
* <Pressable onPress={() => setOpen(true)}>...</Pressable>
|
|
132
|
+
* </View>
|
|
133
|
+
* <Menu open={open} onClose={() => setOpen(false)} anchorOffset={barHeight}>
|
|
134
|
+
* <Menu.Item onPress={onDiscounts}>Discount codes</Menu.Item>
|
|
135
|
+
* <Menu.Item variant="destructive" divider={false} onPress={onLogOut}>
|
|
136
|
+
* Log out
|
|
137
|
+
* </Menu.Item>
|
|
138
|
+
* </Menu>
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
export const Menu = Object.assign(MenuRoot, {
|
|
142
|
+
Item: MenuItem
|
|
143
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { Pressable, type PressableProps } from "react-native"
|
|
3
|
+
import styled from "@emotion/native"
|
|
4
|
+
import { useTheme } from "@emotion/react"
|
|
5
|
+
import { Typography } from "../../atoms/Typography"
|
|
6
|
+
|
|
7
|
+
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
8
|
+
|
|
9
|
+
export type MenuItemVariant = "action" | "destructive"
|
|
10
|
+
|
|
11
|
+
type MenuItemOwnProps = {
|
|
12
|
+
/**
|
|
13
|
+
* `action` renders the label in the brand action colour, `destructive` in
|
|
14
|
+
* the error colour (e.g. "Log out"). Defaults to `action`.
|
|
15
|
+
*/
|
|
16
|
+
variant?: MenuItemVariant
|
|
17
|
+
onPress: () => void
|
|
18
|
+
/** The label. A string is rendered with the row's own Typography. */
|
|
19
|
+
children: React.ReactNode
|
|
20
|
+
/**
|
|
21
|
+
* Whether to render a divider beneath the row. Defaults to `true`; set
|
|
22
|
+
* `false` on the last row so the border doesn't sit against the card's
|
|
23
|
+
* rounded bottom edge.
|
|
24
|
+
*/
|
|
25
|
+
divider?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type MenuItemProps = MenuItemOwnProps &
|
|
29
|
+
Omit<PressableProps, keyof MenuItemOwnProps | "children">
|
|
30
|
+
|
|
31
|
+
// A full-width, centre-aligned row. `divider` paints the bottom border; set it
|
|
32
|
+
// `false` on the last row so the border doesn't sit against the card's rounded
|
|
33
|
+
// bottom edge.
|
|
34
|
+
const Row = styled(Pressable)<{ divider: boolean }>(({ theme, divider }) => {
|
|
35
|
+
const { spacing } = theme.tokens.semantics.dimensions
|
|
36
|
+
const { colour } = theme.tokens.semantics
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
width: "100%",
|
|
40
|
+
alignItems: "center",
|
|
41
|
+
justifyContent: "center",
|
|
42
|
+
paddingVertical: parseTokenValue(spacing.md),
|
|
43
|
+
paddingHorizontal: parseTokenValue(spacing.md),
|
|
44
|
+
borderBottomWidth: divider ? 1 : 0,
|
|
45
|
+
borderBottomColor: colour.border.divider
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A single row inside a {@link Menu}. Renders its label centred, coloured by
|
|
51
|
+
* `variant`, with a divider beneath it unless `divider` is set to `false`.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```tsx
|
|
55
|
+
* <Menu.Item variant="destructive" divider={false} onPress={onLogOut}>
|
|
56
|
+
* Log out
|
|
57
|
+
* </Menu.Item>
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export const MenuItem = React.forwardRef<
|
|
61
|
+
React.ComponentRef<typeof Pressable>,
|
|
62
|
+
MenuItemProps
|
|
63
|
+
>(({ variant = "action", onPress, children, divider = true, ...rest }, ref) => {
|
|
64
|
+
const theme = useTheme()
|
|
65
|
+
const { colour, typography } = theme.tokens.semantics
|
|
66
|
+
|
|
67
|
+
// The action colour lives under `colour.text.action`, which Typography's
|
|
68
|
+
// semantic-key `color` prop deliberately excludes — so we render in token
|
|
69
|
+
// mode (font token + hex colour). Both colours still come from tokens.
|
|
70
|
+
const labelColour =
|
|
71
|
+
variant === "destructive" ? colour.text.error : colour.text.action.default
|
|
72
|
+
|
|
73
|
+
const label =
|
|
74
|
+
typeof children === "string" || typeof children === "number"
|
|
75
|
+
? String(children)
|
|
76
|
+
: undefined
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Row
|
|
80
|
+
ref={ref}
|
|
81
|
+
divider={divider}
|
|
82
|
+
onPress={onPress}
|
|
83
|
+
accessible
|
|
84
|
+
accessibilityRole="menuitem"
|
|
85
|
+
accessibilityLabel={label}
|
|
86
|
+
{...rest}
|
|
87
|
+
>
|
|
88
|
+
{label !== undefined ? (
|
|
89
|
+
<Typography
|
|
90
|
+
token={typography.body.medium.md}
|
|
91
|
+
align="center"
|
|
92
|
+
color={labelColour}
|
|
93
|
+
>
|
|
94
|
+
{label}
|
|
95
|
+
</Typography>
|
|
96
|
+
) : (
|
|
97
|
+
children
|
|
98
|
+
)}
|
|
99
|
+
</Row>
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
MenuItem.displayName = "Menu.Item"
|