@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
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { LayoutChangeEvent, StyleSheet, View, ViewProps } from "react-native"
|
|
3
|
+
import Svg, {
|
|
4
|
+
Defs,
|
|
5
|
+
LinearGradient as SvgLinearGradient,
|
|
6
|
+
Rect,
|
|
7
|
+
Stop
|
|
8
|
+
} from "react-native-svg"
|
|
9
|
+
import Reanimated, {
|
|
10
|
+
Easing,
|
|
11
|
+
useAnimatedStyle,
|
|
12
|
+
useReducedMotion,
|
|
13
|
+
useSharedValue,
|
|
14
|
+
withDelay,
|
|
15
|
+
withRepeat,
|
|
16
|
+
withSequence,
|
|
17
|
+
withTiming
|
|
18
|
+
} from "react-native-reanimated"
|
|
19
|
+
import styled from "@emotion/native"
|
|
20
|
+
import { useTheme } from "@emotion/react"
|
|
21
|
+
import {
|
|
22
|
+
Close,
|
|
23
|
+
Gift,
|
|
24
|
+
KeyboardArrowRight,
|
|
25
|
+
KeyboardArrowUp
|
|
26
|
+
} from "@butternutbox/pawprint-icons/core"
|
|
27
|
+
import { Icon, type PawprintIcon } from "../../atoms/Icon"
|
|
28
|
+
import { IconButton, type IconButtonVariant } from "../../atoms/IconButton"
|
|
29
|
+
import { Link } from "../../atoms/Link"
|
|
30
|
+
import { Typography } from "../../atoms/Typography"
|
|
31
|
+
|
|
32
|
+
type FloatingActionButtonVariant = "primary" | "secondary"
|
|
33
|
+
type FloatingActionButtonIconButtonIcon = "chevronUp" | "chevronRight" | "close"
|
|
34
|
+
type FloatingActionButtonAnimation = "shine" | "nudge"
|
|
35
|
+
|
|
36
|
+
type FloatingActionButtonBaseProps = {
|
|
37
|
+
variant?: FloatingActionButtonVariant
|
|
38
|
+
icon?: PawprintIcon
|
|
39
|
+
children: React.ReactNode
|
|
40
|
+
linkLabel: string
|
|
41
|
+
animation?: FloatingActionButtonAnimation
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The link is mandatory, so at least one of `linkHref` / `onLinkPress` is
|
|
45
|
+
// required.
|
|
46
|
+
type FloatingActionButtonLinkProps =
|
|
47
|
+
| { linkHref: string; onLinkPress?: () => void }
|
|
48
|
+
| { linkHref?: string; onLinkPress: () => void }
|
|
49
|
+
|
|
50
|
+
type FloatingActionButtonIconButtonProps = {
|
|
51
|
+
iconButtonIcon: FloatingActionButtonIconButtonIcon
|
|
52
|
+
iconButtonLabel: string
|
|
53
|
+
iconButtonVariant?: IconButtonVariant
|
|
54
|
+
onIconButtonPress?: () => void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type FloatingActionButtonOwnProps = FloatingActionButtonBaseProps &
|
|
58
|
+
FloatingActionButtonLinkProps &
|
|
59
|
+
FloatingActionButtonIconButtonProps
|
|
60
|
+
|
|
61
|
+
export type FloatingActionButtonProps = FloatingActionButtonOwnProps &
|
|
62
|
+
Omit<ViewProps, keyof FloatingActionButtonBaseProps | "iconButtonIcon">
|
|
63
|
+
|
|
64
|
+
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
65
|
+
|
|
66
|
+
const ICON_BUTTON_CONFIG = {
|
|
67
|
+
chevronUp: { icon: KeyboardArrowUp, defaultVariant: "filled" },
|
|
68
|
+
chevronRight: { icon: KeyboardArrowRight, defaultVariant: "filled" },
|
|
69
|
+
close: { icon: Close, defaultVariant: "text" }
|
|
70
|
+
} as const
|
|
71
|
+
|
|
72
|
+
// TODO: Add token — no motion tokens exist yet
|
|
73
|
+
const ANIMATION_DELAY = 1500
|
|
74
|
+
const ANIMATION_ITERATIONS = 3
|
|
75
|
+
const SHINE_SWEEP = 1100
|
|
76
|
+
const SHINE_REST = 4900
|
|
77
|
+
const SHINE_BAND_RATIO = 0.4
|
|
78
|
+
// TODO: Add token — highlight opacity
|
|
79
|
+
const SHINE_OPACITY = 0.55
|
|
80
|
+
const NUDGE_STEP = 200
|
|
81
|
+
const NUDGE_REST = 3200
|
|
82
|
+
const NUDGE_DISTANCE = 3
|
|
83
|
+
|
|
84
|
+
const ShineOverlay = ({
|
|
85
|
+
radius,
|
|
86
|
+
highlight
|
|
87
|
+
}: {
|
|
88
|
+
radius: number
|
|
89
|
+
highlight: string
|
|
90
|
+
}) => {
|
|
91
|
+
const reducedMotion = useReducedMotion()
|
|
92
|
+
const [width, setWidth] = React.useState(0)
|
|
93
|
+
const progress = useSharedValue(0)
|
|
94
|
+
const bandWidth = width * SHINE_BAND_RATIO
|
|
95
|
+
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
if (reducedMotion || !width) return
|
|
98
|
+
progress.value = withDelay(
|
|
99
|
+
ANIMATION_DELAY,
|
|
100
|
+
withRepeat(
|
|
101
|
+
withSequence(
|
|
102
|
+
withTiming(1, {
|
|
103
|
+
duration: SHINE_SWEEP,
|
|
104
|
+
easing: Easing.inOut(Easing.ease)
|
|
105
|
+
}),
|
|
106
|
+
withDelay(SHINE_REST, withTiming(0, { duration: 0 }))
|
|
107
|
+
),
|
|
108
|
+
ANIMATION_ITERATIONS
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
}, [progress, reducedMotion, width])
|
|
112
|
+
|
|
113
|
+
const bandStyle = useAnimatedStyle(() => ({
|
|
114
|
+
transform: [
|
|
115
|
+
{
|
|
116
|
+
translateX:
|
|
117
|
+
-bandWidth * 1.5 + progress.value * (width + bandWidth * 2.5)
|
|
118
|
+
},
|
|
119
|
+
{ skewX: "-20deg" }
|
|
120
|
+
]
|
|
121
|
+
}))
|
|
122
|
+
|
|
123
|
+
if (reducedMotion) return null
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<View
|
|
127
|
+
pointerEvents="none"
|
|
128
|
+
accessible={false}
|
|
129
|
+
importantForAccessibility="no-hide-descendants"
|
|
130
|
+
onLayout={(e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width)}
|
|
131
|
+
style={[
|
|
132
|
+
StyleSheet.absoluteFill,
|
|
133
|
+
{ borderRadius: radius, overflow: "hidden" }
|
|
134
|
+
]}
|
|
135
|
+
>
|
|
136
|
+
{width > 0 && (
|
|
137
|
+
<Reanimated.View
|
|
138
|
+
style={[
|
|
139
|
+
{ position: "absolute", top: 0, bottom: 0, width: bandWidth },
|
|
140
|
+
bandStyle
|
|
141
|
+
]}
|
|
142
|
+
>
|
|
143
|
+
<Svg width="100%" height="100%">
|
|
144
|
+
<Defs>
|
|
145
|
+
<SvgLinearGradient id="fabShine" x1="0" y1="0" x2="1" y2="0">
|
|
146
|
+
<Stop offset="0" stopColor={highlight} stopOpacity={0} />
|
|
147
|
+
<Stop
|
|
148
|
+
offset="0.5"
|
|
149
|
+
stopColor={highlight}
|
|
150
|
+
stopOpacity={SHINE_OPACITY}
|
|
151
|
+
/>
|
|
152
|
+
<Stop offset="1" stopColor={highlight} stopOpacity={0} />
|
|
153
|
+
</SvgLinearGradient>
|
|
154
|
+
</Defs>
|
|
155
|
+
<Rect width="100%" height="100%" fill="url(#fabShine)" />
|
|
156
|
+
</Svg>
|
|
157
|
+
</Reanimated.View>
|
|
158
|
+
)}
|
|
159
|
+
</View>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Nudge variants are passed to IconButton as its `icon`, so only the glyph
|
|
164
|
+
// moves (matching web) while the button itself stays put. Created once at module
|
|
165
|
+
// level so the components stay stable across renders.
|
|
166
|
+
const createNudgingIcon = (
|
|
167
|
+
IconComponent: PawprintIcon,
|
|
168
|
+
axis: "translateX" | "translateY",
|
|
169
|
+
direction: 1 | -1
|
|
170
|
+
): PawprintIcon => {
|
|
171
|
+
const NudgingIcon = (props: React.ComponentProps<PawprintIcon>) => {
|
|
172
|
+
const reducedMotion = useReducedMotion()
|
|
173
|
+
const offset = useSharedValue(0)
|
|
174
|
+
|
|
175
|
+
React.useEffect(() => {
|
|
176
|
+
if (reducedMotion) return
|
|
177
|
+
const step = (to: number) =>
|
|
178
|
+
withTiming(to, {
|
|
179
|
+
duration: NUDGE_STEP,
|
|
180
|
+
easing: Easing.inOut(Easing.ease)
|
|
181
|
+
})
|
|
182
|
+
const distance = NUDGE_DISTANCE * direction
|
|
183
|
+
offset.value = withDelay(
|
|
184
|
+
ANIMATION_DELAY,
|
|
185
|
+
withRepeat(
|
|
186
|
+
withSequence(
|
|
187
|
+
step(distance),
|
|
188
|
+
step(0),
|
|
189
|
+
step(distance),
|
|
190
|
+
step(0),
|
|
191
|
+
withDelay(NUDGE_REST, withTiming(0, { duration: 0 }))
|
|
192
|
+
),
|
|
193
|
+
ANIMATION_ITERATIONS
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
}, [offset, reducedMotion])
|
|
197
|
+
|
|
198
|
+
const nudgeStyle = useAnimatedStyle(() =>
|
|
199
|
+
axis === "translateX"
|
|
200
|
+
? { transform: [{ translateX: offset.value }] }
|
|
201
|
+
: { transform: [{ translateY: offset.value }] }
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<Reanimated.View style={nudgeStyle}>
|
|
206
|
+
<IconComponent {...props} />
|
|
207
|
+
</Reanimated.View>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
NudgingIcon.displayName = `Nudging(${IconComponent.displayName ?? "Icon"})`
|
|
211
|
+
return NudgingIcon
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const NUDGE_ICONS = {
|
|
215
|
+
chevronUp: createNudgingIcon(KeyboardArrowUp, "translateY", -1),
|
|
216
|
+
chevronRight: createNudgingIcon(KeyboardArrowRight, "translateX", 1)
|
|
217
|
+
} as const
|
|
218
|
+
|
|
219
|
+
const StyledRoot = styled(View)<{
|
|
220
|
+
rootGap: number
|
|
221
|
+
rootPaddingH: number
|
|
222
|
+
rootPaddingV: number
|
|
223
|
+
rootBgColor: string
|
|
224
|
+
rootBorderWidth: number
|
|
225
|
+
rootBorderColor: string
|
|
226
|
+
rootBorderRadius: number
|
|
227
|
+
rootBoxShadow: string
|
|
228
|
+
}>(
|
|
229
|
+
({
|
|
230
|
+
rootGap,
|
|
231
|
+
rootPaddingH,
|
|
232
|
+
rootPaddingV,
|
|
233
|
+
rootBgColor,
|
|
234
|
+
rootBorderWidth,
|
|
235
|
+
rootBorderColor,
|
|
236
|
+
rootBorderRadius,
|
|
237
|
+
rootBoxShadow
|
|
238
|
+
}) => ({
|
|
239
|
+
flexDirection: "row",
|
|
240
|
+
alignItems: "center",
|
|
241
|
+
width: "100%",
|
|
242
|
+
gap: rootGap,
|
|
243
|
+
paddingHorizontal: rootPaddingH,
|
|
244
|
+
paddingVertical: rootPaddingV,
|
|
245
|
+
backgroundColor: rootBgColor,
|
|
246
|
+
borderWidth: rootBorderWidth,
|
|
247
|
+
borderColor: rootBorderColor,
|
|
248
|
+
borderRadius: rootBorderRadius,
|
|
249
|
+
boxShadow: rootBoxShadow
|
|
250
|
+
})
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const StyledIconContainer = styled(View)<{
|
|
254
|
+
containerSize: number
|
|
255
|
+
containerRadius: number
|
|
256
|
+
containerBgColor: string
|
|
257
|
+
}>(({ containerSize, containerRadius, containerBgColor }) => ({
|
|
258
|
+
alignItems: "center",
|
|
259
|
+
justifyContent: "center",
|
|
260
|
+
flexShrink: 0,
|
|
261
|
+
width: containerSize,
|
|
262
|
+
height: containerSize,
|
|
263
|
+
borderRadius: containerRadius,
|
|
264
|
+
backgroundColor: containerBgColor
|
|
265
|
+
}))
|
|
266
|
+
|
|
267
|
+
const StyledContent = styled(View)({
|
|
268
|
+
flex: 1,
|
|
269
|
+
minWidth: 0,
|
|
270
|
+
alignItems: "flex-start",
|
|
271
|
+
justifyContent: "center"
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Floating pill that surfaces a short message with a link and an
|
|
276
|
+
* icon button. Used for persistent prompts such as offers and
|
|
277
|
+
* reminders.
|
|
278
|
+
*
|
|
279
|
+
* The component does not position itself — the consumer is responsible for
|
|
280
|
+
* placing it (e.g. `position: "absolute"` at the bottom right) so it can avoid
|
|
281
|
+
* other floating elements on the screen. It fills the width of its container.
|
|
282
|
+
*
|
|
283
|
+
* The icon button is always size `md` and limited to three icons: `chevronUp`,
|
|
284
|
+
* `chevronRight` and `close`. Its style defaults to the Figma pairing —
|
|
285
|
+
* `filled` (primary) for the chevrons and `text` for `close` — and can be
|
|
286
|
+
* overridden with `iconButtonVariant`.
|
|
287
|
+
*
|
|
288
|
+
* Note: Unlike the web version, handlers are `onLinkPress` and
|
|
289
|
+
* `onIconButtonPress` (web: `onLinkClick` / `onIconButtonClick`). The shadow
|
|
290
|
+
* uses `boxShadow`, which requires React Native 0.76+ (New Architecture).
|
|
291
|
+
*
|
|
292
|
+
* @param {"primary" | "secondary"} [variant="primary"] - Colour scheme.
|
|
293
|
+
* `primary` is pink with a white icon container; `secondary` is white with a
|
|
294
|
+
* cream icon container.
|
|
295
|
+
* @param {PawprintIcon} [icon=Gift] - Icon shown in the circular container on
|
|
296
|
+
* the left. Rendered at size `xl` in the primary icon colour.
|
|
297
|
+
* @param {React.ReactNode} children - Message copy. Nest a bold `Typography`
|
|
298
|
+
* to emphasise part of it.
|
|
299
|
+
* @param {string} linkLabel - Link text shown below the message. Use "Close"
|
|
300
|
+
* to dismiss, or e.g. "See more" when the icon button is `close`.
|
|
301
|
+
* @param {string} [linkHref] - **(required without `onLinkPress`)** URL opened
|
|
302
|
+
* via `Linking` when pressed.
|
|
303
|
+
* @param {() => void} [onLinkPress] - **(required without `linkHref`)** Link
|
|
304
|
+
* press handler, e.g. to dismiss, navigate to a screen or open a Modal or
|
|
305
|
+
* Drawer. Takes precedence over `linkHref`.
|
|
306
|
+
* @param {"shine" | "nudge"} [animation] - Optional attention animation, off
|
|
307
|
+
* by default. Runs 3 times after a short delay, then stops, and is disabled
|
|
308
|
+
* when the user prefers reduced motion. `shine` sweeps a soft highlight
|
|
309
|
+
* across the pill; `nudge` moves the chevron towards its direction (no effect
|
|
310
|
+
* with `close`).
|
|
311
|
+
* @param {"chevronUp" | "chevronRight" | "close"} iconButtonIcon - Icon button
|
|
312
|
+
* on the right.
|
|
313
|
+
* @param {"filled" | "outlined" | "text"} [iconButtonVariant] - Icon button
|
|
314
|
+
* style. Defaults to `filled` for `chevronUp` / `chevronRight` and `text`
|
|
315
|
+
* for `close`.
|
|
316
|
+
* @param {string} iconButtonLabel - Accessible label for the icon button.
|
|
317
|
+
* @param {() => void} [onIconButtonPress] - Icon button press handler.
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* ```tsx
|
|
321
|
+
* import { FloatingActionButton, Typography } from "@butternutbox/pawprint-native"
|
|
322
|
+
*
|
|
323
|
+
* <FloatingActionButton
|
|
324
|
+
* variant="primary"
|
|
325
|
+
* linkLabel="Close"
|
|
326
|
+
* onLinkPress={dismiss}
|
|
327
|
+
* iconButtonIcon="chevronUp"
|
|
328
|
+
* iconButtonLabel="View offer"
|
|
329
|
+
* onIconButtonPress={openDrawer}
|
|
330
|
+
* >
|
|
331
|
+
* <Typography size="lg" weight="bold">
|
|
332
|
+
* Free
|
|
333
|
+
* </Typography>{" "}
|
|
334
|
+
* gift in your next box
|
|
335
|
+
* </FloatingActionButton>
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
export const FloatingActionButton = React.forwardRef<
|
|
339
|
+
View,
|
|
340
|
+
FloatingActionButtonProps
|
|
341
|
+
>(
|
|
342
|
+
(
|
|
343
|
+
{
|
|
344
|
+
variant = "primary",
|
|
345
|
+
icon = Gift,
|
|
346
|
+
children,
|
|
347
|
+
linkLabel,
|
|
348
|
+
linkHref,
|
|
349
|
+
onLinkPress,
|
|
350
|
+
animation,
|
|
351
|
+
iconButtonIcon,
|
|
352
|
+
iconButtonLabel,
|
|
353
|
+
iconButtonVariant,
|
|
354
|
+
onIconButtonPress,
|
|
355
|
+
...rest
|
|
356
|
+
},
|
|
357
|
+
ref
|
|
358
|
+
) => {
|
|
359
|
+
const theme = useTheme()
|
|
360
|
+
const { floatingActionButton } = theme.tokens.components
|
|
361
|
+
const { colour, dimensions } = theme.tokens.semantics
|
|
362
|
+
const { shadow } = colour
|
|
363
|
+
const iconButton = ICON_BUTTON_CONFIG[iconButtonIcon]
|
|
364
|
+
|
|
365
|
+
const iconButtonElement = (
|
|
366
|
+
<IconButton
|
|
367
|
+
icon={
|
|
368
|
+
animation === "nudge" && iconButtonIcon !== "close"
|
|
369
|
+
? NUDGE_ICONS[iconButtonIcon]
|
|
370
|
+
: iconButton.icon
|
|
371
|
+
}
|
|
372
|
+
variant={iconButtonVariant ?? iconButton.defaultVariant}
|
|
373
|
+
size="md"
|
|
374
|
+
aria-label={iconButtonLabel}
|
|
375
|
+
onPress={onIconButtonPress}
|
|
376
|
+
/>
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<StyledRoot
|
|
381
|
+
ref={ref}
|
|
382
|
+
rootGap={parseTokenValue(floatingActionButton.spacing.gap)}
|
|
383
|
+
rootPaddingH={parseTokenValue(
|
|
384
|
+
floatingActionButton.spacing.horizontalPadding
|
|
385
|
+
)}
|
|
386
|
+
rootPaddingV={parseTokenValue(
|
|
387
|
+
floatingActionButton.spacing.verticalPadding
|
|
388
|
+
)}
|
|
389
|
+
rootBgColor={floatingActionButton.colour.background[variant]}
|
|
390
|
+
rootBorderWidth={parseTokenValue(
|
|
391
|
+
floatingActionButton.borderWidth.default
|
|
392
|
+
)}
|
|
393
|
+
rootBorderColor={floatingActionButton.colour.border[variant]}
|
|
394
|
+
rootBorderRadius={parseTokenValue(
|
|
395
|
+
floatingActionButton.borderRadius.default
|
|
396
|
+
)}
|
|
397
|
+
rootBoxShadow={`${shadow.md.offsetX} ${shadow.md.offsetY} ${shadow.md.blur} ${shadow.md.spread} ${shadow.md.color}`}
|
|
398
|
+
{...rest}
|
|
399
|
+
>
|
|
400
|
+
<StyledIconContainer
|
|
401
|
+
containerSize={parseTokenValue(
|
|
402
|
+
theme.tokens.primitives.dimension.sizing["44"]
|
|
403
|
+
)}
|
|
404
|
+
containerRadius={parseTokenValue(dimensions.borderRadius.round)}
|
|
405
|
+
containerBgColor={
|
|
406
|
+
variant === "secondary"
|
|
407
|
+
? colour.background.container.secondary
|
|
408
|
+
: colour.background.container.default
|
|
409
|
+
}
|
|
410
|
+
>
|
|
411
|
+
<Icon icon={icon} size="xl" colour="primary" />
|
|
412
|
+
</StyledIconContainer>
|
|
413
|
+
<StyledContent>
|
|
414
|
+
<Typography variant="body" size="lg" weight="medium">
|
|
415
|
+
{children}
|
|
416
|
+
</Typography>
|
|
417
|
+
<Link
|
|
418
|
+
href={linkHref}
|
|
419
|
+
onPress={onLinkPress}
|
|
420
|
+
size="md"
|
|
421
|
+
weight="medium"
|
|
422
|
+
{...(!linkHref && { accessibilityRole: "button" as const })}
|
|
423
|
+
>
|
|
424
|
+
{linkLabel}
|
|
425
|
+
</Link>
|
|
426
|
+
</StyledContent>
|
|
427
|
+
{iconButtonElement}
|
|
428
|
+
{animation === "shine" && (
|
|
429
|
+
<ShineOverlay
|
|
430
|
+
radius={parseTokenValue(floatingActionButton.borderRadius.default)}
|
|
431
|
+
highlight={theme.tokens.primitives.colour.system.neutral["1"]}
|
|
432
|
+
/>
|
|
433
|
+
)}
|
|
434
|
+
</StyledRoot>
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
FloatingActionButton.displayName = "FloatingActionButton"
|
|
440
|
+
|
|
441
|
+
export type {
|
|
442
|
+
FloatingActionButtonVariant,
|
|
443
|
+
FloatingActionButtonIconButtonIcon,
|
|
444
|
+
FloatingActionButtonAnimation
|
|
445
|
+
}
|
|
@@ -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()
|