@butternutbox/pawprint-native 1.2.0 → 1.3.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 +7 -7
- package/CHANGELOG.md +6 -0
- package/dist/index.cjs +57 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +273 -236
- package/dist/index.d.ts +273 -236
- package/dist/index.js +57 -38
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/molecules/Menu/Menu.stories.tsx +19 -55
- package/src/components/molecules/Menu/Menu.test.tsx +55 -1
- package/src/components/molecules/Menu/Menu.tsx +56 -31
- package/src/components/molecules/Menu/MenuItem.tsx +32 -39
- package/src/components/molecules/Menu/hooks/index.ts +1 -0
- package/src/components/molecules/Menu/hooks/useMenuTrigger.test.ts +48 -0
- package/src/components/molecules/Menu/hooks/useMenuTrigger.ts +40 -0
- package/src/components/molecules/Menu/index.ts +2 -1
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from "react"
|
|
2
2
|
import { View, StyleSheet } from "react-native"
|
|
3
3
|
import { Menu as MenuIcon } from "@butternutbox/pawprint-icons/core"
|
|
4
4
|
import { Menu } from "./Menu"
|
|
5
|
+
import { useMenuTrigger } from "./hooks"
|
|
5
6
|
import { IconButton } from "../../atoms/IconButton"
|
|
6
7
|
|
|
7
8
|
export default {
|
|
8
9
|
title: "Molecules/Menu",
|
|
9
10
|
component: Menu,
|
|
10
11
|
argTypes: {
|
|
11
|
-
open: { control: "boolean", description: "Whether the menu is visible" },
|
|
12
12
|
align: {
|
|
13
13
|
control: "select",
|
|
14
14
|
options: ["left", "right"],
|
|
@@ -28,58 +28,31 @@ const styles = StyleSheet.create({
|
|
|
28
28
|
}
|
|
29
29
|
})
|
|
30
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
31
|
// --- Playground ---
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
32
|
+
// Tap the burger to open; an outside tap (or the OS back gesture) dismisses via
|
|
33
|
+
// `onClose`. The menu is driven by local state rather than an `open` control
|
|
34
|
+
// because the RN Modal overlays Storybook's controls panel, which would leave
|
|
35
|
+
// it stuck open.
|
|
53
36
|
|
|
54
37
|
export const Playground = {
|
|
55
38
|
args: {
|
|
56
|
-
open: true,
|
|
57
39
|
align: "right",
|
|
58
|
-
width:
|
|
40
|
+
width: 280
|
|
59
41
|
},
|
|
60
42
|
render: (args: React.ComponentProps<typeof Menu>) => {
|
|
61
|
-
const
|
|
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])
|
|
43
|
+
const { triggerRef, open, menuProps } = useMenuTrigger()
|
|
66
44
|
|
|
67
45
|
return (
|
|
68
|
-
<View>
|
|
69
|
-
<View ref={
|
|
46
|
+
<View style={styles.bar}>
|
|
47
|
+
<View ref={triggerRef} collapsable={false}>
|
|
70
48
|
<IconButton
|
|
71
49
|
icon={MenuIcon}
|
|
72
50
|
variant="text"
|
|
73
51
|
aria-label="Open menu"
|
|
74
|
-
onPress={
|
|
52
|
+
onPress={open}
|
|
75
53
|
/>
|
|
76
54
|
</View>
|
|
77
|
-
<Menu
|
|
78
|
-
{...args}
|
|
79
|
-
open={open}
|
|
80
|
-
onClose={() => setOpen(false)}
|
|
81
|
-
anchorOffset={anchorOffset}
|
|
82
|
-
>
|
|
55
|
+
<Menu {...args} {...menuProps}>
|
|
83
56
|
<Menu.Item onPress={() => {}}>Option 1</Menu.Item>
|
|
84
57
|
<Menu.Item onPress={() => {}}>Option 2</Menu.Item>
|
|
85
58
|
<Menu.Item variant="destructive" onPress={() => {}}>
|
|
@@ -99,30 +72,21 @@ export const Playground = {
|
|
|
99
72
|
export const Interactive = {
|
|
100
73
|
name: "Interactive / Header trigger",
|
|
101
74
|
render: () => {
|
|
102
|
-
const
|
|
103
|
-
const { ref, anchorOffset, measure } = useBarAnchor()
|
|
75
|
+
const { triggerRef, open, close, menuProps } = useMenuTrigger()
|
|
104
76
|
|
|
105
77
|
return (
|
|
106
|
-
<View>
|
|
107
|
-
<View ref={
|
|
78
|
+
<View style={styles.bar}>
|
|
79
|
+
<View ref={triggerRef} collapsable={false}>
|
|
108
80
|
<IconButton
|
|
109
81
|
icon={MenuIcon}
|
|
110
82
|
variant="text"
|
|
111
83
|
aria-label="Open menu"
|
|
112
|
-
onPress={
|
|
84
|
+
onPress={open}
|
|
113
85
|
/>
|
|
114
86
|
</View>
|
|
115
|
-
<Menu
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
>
|
|
87
|
+
<Menu {...menuProps}>
|
|
88
|
+
<Menu.Item onPress={close}>Option 1</Menu.Item>
|
|
89
|
+
<Menu.Item variant="destructive" divider={false} onPress={close}>
|
|
126
90
|
Option 2
|
|
127
91
|
</Menu.Item>
|
|
128
92
|
</Menu>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react"
|
|
2
|
+
import { Dimensions } from "react-native"
|
|
2
3
|
import { screen, fireEvent } from "@testing-library/react"
|
|
3
4
|
import { describe, it, expect, vi } from "vitest"
|
|
4
5
|
import { renderWithTheme } from "../../../test-utils"
|
|
@@ -15,7 +16,11 @@ const BasicMenu = ({
|
|
|
15
16
|
onDiscounts?: () => void
|
|
16
17
|
onLogOut?: () => void
|
|
17
18
|
}) => (
|
|
18
|
-
<Menu
|
|
19
|
+
<Menu
|
|
20
|
+
open={open}
|
|
21
|
+
onClose={onClose}
|
|
22
|
+
anchor={{ x: 200, y: 20, width: 120, height: 40 }}
|
|
23
|
+
>
|
|
19
24
|
<Menu.Item onPress={onDiscounts}>Discount codes</Menu.Item>
|
|
20
25
|
<Menu.Item variant="destructive" onPress={onLogOut}>
|
|
21
26
|
Log out
|
|
@@ -36,12 +41,61 @@ describe("Menu", () => {
|
|
|
36
41
|
expect(screen.getByText("Log out")).toBeInTheDocument()
|
|
37
42
|
})
|
|
38
43
|
|
|
44
|
+
it("still renders with the deprecated anchorOffset", () => {
|
|
45
|
+
renderWithTheme(
|
|
46
|
+
<Menu open onClose={() => {}} anchorOffset={80}>
|
|
47
|
+
<Menu.Item onPress={() => {}}>Discount codes</Menu.Item>
|
|
48
|
+
</Menu>
|
|
49
|
+
)
|
|
50
|
+
expect(screen.getByText("Discount codes")).toBeInTheDocument()
|
|
51
|
+
})
|
|
52
|
+
|
|
39
53
|
it("exposes each row as a menuitem", () => {
|
|
40
54
|
renderWithTheme(<BasicMenu />)
|
|
41
55
|
expect(screen.getAllByRole("menuitem")).toHaveLength(2)
|
|
42
56
|
})
|
|
43
57
|
})
|
|
44
58
|
|
|
59
|
+
describe("positioning", () => {
|
|
60
|
+
it("hangs the card below the trigger, right edges aligned", () => {
|
|
61
|
+
renderWithTheme(<BasicMenu />)
|
|
62
|
+
const anchor = screen.getByRole("menu").parentElement!
|
|
63
|
+
expect(anchor.style.top).toBe("68px")
|
|
64
|
+
expect(anchor.style.right).toBe(
|
|
65
|
+
`${Dimensions.get("window").width - 320}px`
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("keeps its position while closing after the anchor is cleared", () => {
|
|
70
|
+
const anchor = { x: 200, y: 20, width: 120, height: 40 }
|
|
71
|
+
const { rerender } = renderWithTheme(
|
|
72
|
+
<Menu open onClose={() => {}} anchor={anchor}>
|
|
73
|
+
<Menu.Item onPress={() => {}}>Discount codes</Menu.Item>
|
|
74
|
+
</Menu>
|
|
75
|
+
)
|
|
76
|
+
rerender(
|
|
77
|
+
<Menu open onClose={() => {}}>
|
|
78
|
+
<Menu.Item onPress={() => {}}>Discount codes</Menu.Item>
|
|
79
|
+
</Menu>
|
|
80
|
+
)
|
|
81
|
+
expect(screen.getByRole("menu").parentElement!.style.top).toBe("68px")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("lines up left edges when aligned left", () => {
|
|
85
|
+
renderWithTheme(
|
|
86
|
+
<Menu
|
|
87
|
+
open
|
|
88
|
+
onClose={() => {}}
|
|
89
|
+
align="left"
|
|
90
|
+
anchor={{ x: 24, y: 20, width: 160, height: 40 }}
|
|
91
|
+
>
|
|
92
|
+
<Menu.Item onPress={() => {}}>Discount codes</Menu.Item>
|
|
93
|
+
</Menu>
|
|
94
|
+
)
|
|
95
|
+
expect(screen.getByRole("menu").parentElement!.style.left).toBe("24px")
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
45
99
|
describe("interaction", () => {
|
|
46
100
|
it("fires an item's onPress when tapped", () => {
|
|
47
101
|
const onLogOut = vi.fn()
|
|
@@ -1,26 +1,41 @@
|
|
|
1
|
-
import React from "react"
|
|
2
|
-
import { Modal, Pressable, View } from "react-native"
|
|
1
|
+
import React, { useState } from "react"
|
|
2
|
+
import { Modal, Pressable, View, useWindowDimensions } from "react-native"
|
|
3
3
|
import styled from "@emotion/native"
|
|
4
|
+
import { useTheme } from "@emotion/react"
|
|
4
5
|
import { MenuItem } from "./MenuItem"
|
|
5
6
|
|
|
6
7
|
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
7
8
|
|
|
8
9
|
export type MenuAlign = "left" | "right"
|
|
9
10
|
|
|
11
|
+
/** A rect in window coordinates, as returned by `View#measureInWindow`. */
|
|
12
|
+
export type MenuAnchorRect = {
|
|
13
|
+
x: number
|
|
14
|
+
y: number
|
|
15
|
+
width: number
|
|
16
|
+
height: number
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
export type MenuProps = {
|
|
11
20
|
/** Whether the menu is visible. */
|
|
12
21
|
open: boolean
|
|
13
22
|
/** Called when the user taps outside the card or dismisses via the OS. */
|
|
14
23
|
onClose: () => void
|
|
15
24
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
25
|
+
* The trigger's rect in window coordinates (from `measureInWindow`). The
|
|
26
|
+
* card drops just below it, with its `align` edge lined up with the
|
|
27
|
+
* trigger's.
|
|
28
|
+
*/
|
|
29
|
+
anchor?: MenuAnchorRect
|
|
30
|
+
/**
|
|
31
|
+
* @deprecated Pass `anchor` so the card hangs from the trigger. Distance, in
|
|
32
|
+
* px, from the top of the screen to the bottom edge of the bar the card drops
|
|
33
|
+
* flush beneath, inset from the screen edge. Ignored when `anchor` is set.
|
|
19
34
|
*/
|
|
20
35
|
anchorOffset?: number
|
|
21
36
|
/** Which edge the card aligns to. Defaults to `right`. */
|
|
22
37
|
align?: MenuAlign
|
|
23
|
-
/** Card width in px. Defaults to
|
|
38
|
+
/** Card width in px. Defaults to 280 (capped at 90% of the window). */
|
|
24
39
|
width?: number
|
|
25
40
|
/** Accessibility label for the outside-tap dismiss layer. */
|
|
26
41
|
closeAccessibilityLabel?: string
|
|
@@ -40,15 +55,8 @@ const DismissLayer = styled(Pressable)({
|
|
|
40
55
|
bottom: 0
|
|
41
56
|
})
|
|
42
57
|
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
}
|
|
58
|
+
const Anchor = styled(View)({
|
|
59
|
+
position: "absolute"
|
|
52
60
|
})
|
|
53
61
|
|
|
54
62
|
// A Pressable (rather than a View) so taps on the card's own chrome are
|
|
@@ -59,7 +67,6 @@ const Card = styled(Pressable)<{ width: number }>(({ theme, width }) => {
|
|
|
59
67
|
|
|
60
68
|
return {
|
|
61
69
|
width,
|
|
62
|
-
maxWidth: "90%",
|
|
63
70
|
borderRadius: parseTokenValue(borderRadius.md),
|
|
64
71
|
overflow: "hidden",
|
|
65
72
|
backgroundColor: background.surface.default,
|
|
@@ -78,12 +85,33 @@ const Card = styled(Pressable)<{ width: number }>(({ theme, width }) => {
|
|
|
78
85
|
const MenuRoot = ({
|
|
79
86
|
open,
|
|
80
87
|
onClose,
|
|
88
|
+
anchor,
|
|
81
89
|
anchorOffset = 0,
|
|
82
90
|
align = "right",
|
|
83
|
-
width =
|
|
91
|
+
width = 280,
|
|
84
92
|
closeAccessibilityLabel = "Close menu",
|
|
85
93
|
children
|
|
86
94
|
}: MenuProps) => {
|
|
95
|
+
const theme = useTheme()
|
|
96
|
+
const { width: windowWidth } = useWindowDimensions()
|
|
97
|
+
const { spacing } = theme.tokens.semantics.dimensions
|
|
98
|
+
|
|
99
|
+
const [lastAnchor, setLastAnchor] = useState(anchor)
|
|
100
|
+
if (anchor && anchor !== lastAnchor) setLastAnchor(anchor)
|
|
101
|
+
const shownAnchor = anchor ?? lastAnchor
|
|
102
|
+
|
|
103
|
+
const position = shownAnchor
|
|
104
|
+
? {
|
|
105
|
+
top: shownAnchor.y + shownAnchor.height + parseTokenValue(spacing.xs),
|
|
106
|
+
...(align === "right"
|
|
107
|
+
? { right: windowWidth - (shownAnchor.x + shownAnchor.width) }
|
|
108
|
+
: { left: shownAnchor.x })
|
|
109
|
+
}
|
|
110
|
+
: {
|
|
111
|
+
top: anchorOffset,
|
|
112
|
+
[align]: parseTokenValue(spacing.md)
|
|
113
|
+
}
|
|
114
|
+
|
|
87
115
|
return (
|
|
88
116
|
<Modal
|
|
89
117
|
visible={open}
|
|
@@ -97,9 +125,9 @@ const MenuRoot = ({
|
|
|
97
125
|
accessible={false}
|
|
98
126
|
accessibilityLabel={closeAccessibilityLabel}
|
|
99
127
|
/>
|
|
100
|
-
<Anchor
|
|
128
|
+
<Anchor style={position}>
|
|
101
129
|
<Card
|
|
102
|
-
width={width}
|
|
130
|
+
width={Math.min(width, windowWidth * 0.9)}
|
|
103
131
|
onPress={() => {}}
|
|
104
132
|
accessible={false}
|
|
105
133
|
accessibilityRole="menu"
|
|
@@ -114,23 +142,20 @@ const MenuRoot = ({
|
|
|
114
142
|
MenuRoot.displayName = "Menu"
|
|
115
143
|
|
|
116
144
|
/**
|
|
117
|
-
* A dropdown menu that
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
145
|
+
* A dropdown menu that hangs beneath its trigger. Tapping outside the card
|
|
146
|
+
* dismisses it; the page behind is left undarkened. The consumer owns
|
|
147
|
+
* open/close state and supplies the rows as `Menu.Item` children; the
|
|
148
|
+
* app-specific content stays at the call site.
|
|
121
149
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
150
|
+
* `useMenuTrigger` measures the trigger and supplies the `anchor`, open state
|
|
151
|
+
* and `onClose` as `menuProps`.
|
|
124
152
|
*
|
|
125
153
|
* @example
|
|
126
154
|
* ```tsx
|
|
127
|
-
* const
|
|
128
|
-
* const [barHeight, setBarHeight] = useState(0)
|
|
155
|
+
* const { triggerRef, open, menuProps } = useMenuTrigger()
|
|
129
156
|
*
|
|
130
|
-
* <
|
|
131
|
-
*
|
|
132
|
-
* </View>
|
|
133
|
-
* <Menu open={open} onClose={() => setOpen(false)} anchorOffset={barHeight}>
|
|
157
|
+
* <Pressable ref={triggerRef} onPress={open}>...</Pressable>
|
|
158
|
+
* <Menu {...menuProps}>
|
|
134
159
|
* <Menu.Item onPress={onDiscounts}>Discount codes</Menu.Item>
|
|
135
160
|
* <Menu.Item variant="destructive" divider={false} onPress={onLogOut}>
|
|
136
161
|
* Log out
|
|
@@ -2,6 +2,7 @@ import React from "react"
|
|
|
2
2
|
import { Pressable, type PressableProps } from "react-native"
|
|
3
3
|
import styled from "@emotion/native"
|
|
4
4
|
import { useTheme } from "@emotion/react"
|
|
5
|
+
import { Divider } from "../../atoms/Divider"
|
|
5
6
|
import { Typography } from "../../atoms/Typography"
|
|
6
7
|
|
|
7
8
|
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
@@ -10,8 +11,8 @@ export type MenuItemVariant = "action" | "destructive"
|
|
|
10
11
|
|
|
11
12
|
type MenuItemOwnProps = {
|
|
12
13
|
/**
|
|
13
|
-
* `action` renders the label in the
|
|
14
|
-
*
|
|
14
|
+
* `action` renders the label in the link colour, `destructive` in the error
|
|
15
|
+
* colour (e.g. "Log out"). Defaults to `action`.
|
|
15
16
|
*/
|
|
16
17
|
variant?: MenuItemVariant
|
|
17
18
|
onPress: () => void
|
|
@@ -19,7 +20,7 @@ type MenuItemOwnProps = {
|
|
|
19
20
|
children: React.ReactNode
|
|
20
21
|
/**
|
|
21
22
|
* Whether to render a divider beneath the row. Defaults to `true`; set
|
|
22
|
-
* `false` on the last row so the
|
|
23
|
+
* `false` on the last row so the divider doesn't sit against the card's
|
|
23
24
|
* rounded bottom edge.
|
|
24
25
|
*/
|
|
25
26
|
divider?: boolean
|
|
@@ -28,27 +29,21 @@ type MenuItemOwnProps = {
|
|
|
28
29
|
export type MenuItemProps = MenuItemOwnProps &
|
|
29
30
|
Omit<PressableProps, keyof MenuItemOwnProps | "children">
|
|
30
31
|
|
|
31
|
-
|
|
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 }) => {
|
|
32
|
+
const Row = styled(Pressable)(({ theme }) => {
|
|
35
33
|
const { spacing } = theme.tokens.semantics.dimensions
|
|
36
|
-
const { colour } = theme.tokens.semantics
|
|
37
34
|
|
|
38
35
|
return {
|
|
39
36
|
width: "100%",
|
|
40
|
-
alignItems: "
|
|
37
|
+
alignItems: "flex-start",
|
|
41
38
|
justifyContent: "center",
|
|
42
39
|
paddingVertical: parseTokenValue(spacing.md),
|
|
43
|
-
paddingHorizontal: parseTokenValue(spacing.md)
|
|
44
|
-
borderBottomWidth: divider ? 1 : 0,
|
|
45
|
-
borderBottomColor: colour.border.divider
|
|
40
|
+
paddingHorizontal: parseTokenValue(spacing.md)
|
|
46
41
|
}
|
|
47
42
|
})
|
|
48
43
|
|
|
49
44
|
/**
|
|
50
|
-
* A single row inside a {@link Menu}. Renders its label
|
|
51
|
-
* `variant`, with a divider beneath it unless `divider` is set to `false`.
|
|
45
|
+
* A single row inside a {@link Menu}. Renders its label left-aligned, coloured
|
|
46
|
+
* by `variant`, with a divider beneath it unless `divider` is set to `false`.
|
|
52
47
|
*
|
|
53
48
|
* @example
|
|
54
49
|
* ```tsx
|
|
@@ -64,11 +59,11 @@ export const MenuItem = React.forwardRef<
|
|
|
64
59
|
const theme = useTheme()
|
|
65
60
|
const { colour, typography } = theme.tokens.semantics
|
|
66
61
|
|
|
67
|
-
// The
|
|
68
|
-
// semantic-key `color` prop
|
|
69
|
-
//
|
|
62
|
+
// The link colour lives under `colour.text.link`, which Typography's
|
|
63
|
+
// semantic-key `color` prop excludes — so we render in token mode (font
|
|
64
|
+
// token + hex colour). Both colours still come from tokens.
|
|
70
65
|
const labelColour =
|
|
71
|
-
variant === "destructive" ? colour.text.error : colour.text.
|
|
66
|
+
variant === "destructive" ? colour.text.error : colour.text.link.default
|
|
72
67
|
|
|
73
68
|
const label =
|
|
74
69
|
typeof children === "string" || typeof children === "number"
|
|
@@ -76,27 +71,25 @@ export const MenuItem = React.forwardRef<
|
|
|
76
71
|
: undefined
|
|
77
72
|
|
|
78
73
|
return (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)}
|
|
99
|
-
</Row>
|
|
74
|
+
<>
|
|
75
|
+
<Row
|
|
76
|
+
ref={ref}
|
|
77
|
+
onPress={onPress}
|
|
78
|
+
accessible
|
|
79
|
+
accessibilityRole="menuitem"
|
|
80
|
+
accessibilityLabel={label}
|
|
81
|
+
{...rest}
|
|
82
|
+
>
|
|
83
|
+
{label !== undefined ? (
|
|
84
|
+
<Typography token={typography.body.medium.md} color={labelColour}>
|
|
85
|
+
{label}
|
|
86
|
+
</Typography>
|
|
87
|
+
) : (
|
|
88
|
+
children
|
|
89
|
+
)}
|
|
90
|
+
</Row>
|
|
91
|
+
{divider && <Divider />}
|
|
92
|
+
</>
|
|
100
93
|
)
|
|
101
94
|
})
|
|
102
95
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useMenuTrigger } from "./useMenuTrigger"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react"
|
|
2
|
+
import type { View } from "react-native"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
import { useMenuTrigger } from "./useMenuTrigger"
|
|
5
|
+
|
|
6
|
+
const measurable = { x: 200, y: 20, width: 120, height: 40 }
|
|
7
|
+
|
|
8
|
+
const renderTrigger = () => {
|
|
9
|
+
const hook = renderHook(() => useMenuTrigger())
|
|
10
|
+
hook.result.current.triggerRef.current = {
|
|
11
|
+
measureInWindow: (
|
|
12
|
+
callback: (x: number, y: number, width: number, height: number) => void
|
|
13
|
+
) =>
|
|
14
|
+
callback(measurable.x, measurable.y, measurable.width, measurable.height)
|
|
15
|
+
} as unknown as View
|
|
16
|
+
return hook
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("useMenuTrigger", () => {
|
|
20
|
+
it("starts closed", () => {
|
|
21
|
+
const { result } = renderTrigger()
|
|
22
|
+
expect(result.current.isOpen).toBe(false)
|
|
23
|
+
expect(result.current.menuProps).toEqual({
|
|
24
|
+
open: false,
|
|
25
|
+
onClose: result.current.close,
|
|
26
|
+
anchor: undefined
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("opens anchored to the measured trigger", () => {
|
|
31
|
+
const { result } = renderTrigger()
|
|
32
|
+
act(() => result.current.open())
|
|
33
|
+
expect(result.current.isOpen).toBe(true)
|
|
34
|
+
expect(result.current.menuProps.open).toBe(true)
|
|
35
|
+
expect(result.current.menuProps.anchor).toEqual(measurable)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("closes from close and from the menu's onClose", () => {
|
|
39
|
+
const { result } = renderTrigger()
|
|
40
|
+
act(() => result.current.open())
|
|
41
|
+
act(() => result.current.close())
|
|
42
|
+
expect(result.current.isOpen).toBe(false)
|
|
43
|
+
|
|
44
|
+
act(() => result.current.open())
|
|
45
|
+
act(() => result.current.menuProps.onClose())
|
|
46
|
+
expect(result.current.isOpen).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react"
|
|
2
|
+
import type { View } from "react-native"
|
|
3
|
+
import type { MenuAnchorRect } from "../Menu"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wires a trigger to a {@link Menu}: measures the trigger when it opens so the
|
|
7
|
+
* card hangs beneath it.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* const { triggerRef, open, close, menuProps } = useMenuTrigger()
|
|
12
|
+
*
|
|
13
|
+
* <Pressable ref={triggerRef} onPress={open}>...</Pressable>
|
|
14
|
+
* <Menu {...menuProps}>
|
|
15
|
+
* <Menu.Item onPress={close}>Discount codes</Menu.Item>
|
|
16
|
+
* </Menu>
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export const useMenuTrigger = () => {
|
|
20
|
+
const triggerRef = useRef<View>(null)
|
|
21
|
+
const [anchor, setAnchor] = useState<MenuAnchorRect>()
|
|
22
|
+
|
|
23
|
+
const open = useCallback(() => {
|
|
24
|
+
triggerRef.current?.measureInWindow((x, y, width, height) =>
|
|
25
|
+
setAnchor({ x, y, width, height })
|
|
26
|
+
)
|
|
27
|
+
}, [])
|
|
28
|
+
|
|
29
|
+
const close = useCallback(() => setAnchor(undefined), [])
|
|
30
|
+
|
|
31
|
+
const isOpen = anchor !== undefined
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
triggerRef,
|
|
35
|
+
isOpen,
|
|
36
|
+
open,
|
|
37
|
+
close,
|
|
38
|
+
menuProps: { open: isOpen, onClose: close, anchor }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { Menu } from "./Menu"
|
|
2
|
-
export type { MenuProps, MenuAlign } from "./Menu"
|
|
2
|
+
export type { MenuProps, MenuAlign, MenuAnchorRect } from "./Menu"
|
|
3
3
|
export { MenuItem } from "./MenuItem"
|
|
4
4
|
export type { MenuItemProps, MenuItemVariant } from "./MenuItem"
|
|
5
|
+
export { useMenuTrigger } from "./hooks"
|