@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butternutbox/pawprint-native",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -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"
@@ -0,0 +1,4 @@
1
+ export { Menu } from "./Menu"
2
+ export type { MenuProps, MenuAlign } from "./Menu"
3
+ export { MenuItem } from "./MenuItem"
4
+ export type { MenuItemProps, MenuItemVariant } from "./MenuItem"
@@ -18,6 +18,7 @@ export * from "./Slider"
18
18
  export * from "./Notification"
19
19
  export * from "./StackedNotifications"
20
20
  export * from "./Tooltip"
21
+ export * from "./Menu"
21
22
  export * from "./MessageCard"
22
23
  export * from "./DatePicker"
23
24
  export * from "./PictureSelector"