@butternutbox/pawprint-native 1.1.3 → 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 +9 -9
- package/CHANGELOG.md +12 -0
- package/dist/index.cjs +326 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +362 -234
- package/dist/index.d.ts +362 -234
- package/dist/index.js +326 -39
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/molecules/FloatingActionButton/FloatingActionButton.stories.tsx +304 -0
- package/src/components/molecules/FloatingActionButton/FloatingActionButton.tsx +445 -0
- package/src/components/molecules/FloatingActionButton/index.ts +7 -0
- 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/src/components/molecules/index.ts +1 -0
|
@@ -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"
|