@butternutbox/pawprint-native 0.23.3 → 0.24.2
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 +18 -0
- package/dist/index.cjs +69 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +70 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/molecules/ProductDisplayCard/ProductDisplayCard.tsx +9 -2
- package/src/components/molecules/Progress/Progress.stories.tsx +37 -0
- package/src/components/molecules/Progress/Progress.tsx +37 -2
- package/src/components/molecules/Progress/index.ts +1 -1
- package/src/components/molecules/StackedNotifications/StackedNotifications.test.tsx +58 -1
- package/src/components/molecules/StackedNotifications/StackedNotifications.tsx +83 -17
package/package.json
CHANGED
|
@@ -27,6 +27,7 @@ export type ProductDisplayCardProps = ViewProps & {
|
|
|
27
27
|
incrementDisabled?: boolean
|
|
28
28
|
decrementDisabled?: boolean
|
|
29
29
|
minQuantity?: number
|
|
30
|
+
quantityStep?: number
|
|
30
31
|
image?: string | React.ReactNode
|
|
31
32
|
imageBackgroundColor?: string
|
|
32
33
|
thumbnailWidth?: number
|
|
@@ -196,6 +197,7 @@ const StyledNoneBanner = styled(View)(({ theme }) => {
|
|
|
196
197
|
* @param quantityBadge - *(optional)* Show a quantity badge over the image instead
|
|
197
198
|
* of the picker (renders the current `quantity`). Mutually exclusive with the
|
|
198
199
|
* quantity picker; defaults to `false`. Used on OOB recipe and Extras cards.
|
|
200
|
+
* @param quantityStep - *(optional)* Increment/decrement amount (default: 1)
|
|
199
201
|
* @param image - *(optional)* Image URL string or React element
|
|
200
202
|
* @param banner - *(optional)* Banner content string or React element to show below card
|
|
201
203
|
* @param showBanner - *(optional)* Show/hide banner
|
|
@@ -220,6 +222,7 @@ export const ProductDisplayCard = React.forwardRef<
|
|
|
220
222
|
incrementDisabled = false,
|
|
221
223
|
decrementDisabled = false,
|
|
222
224
|
minQuantity = 1,
|
|
225
|
+
quantityStep = 1,
|
|
223
226
|
image,
|
|
224
227
|
imageBackgroundColor,
|
|
225
228
|
thumbnailWidth,
|
|
@@ -294,8 +297,12 @@ export const ProductDisplayCard = React.forwardRef<
|
|
|
294
297
|
handleQuantityChange(value)
|
|
295
298
|
}
|
|
296
299
|
}}
|
|
297
|
-
onIncrement={() =>
|
|
298
|
-
|
|
300
|
+
onIncrement={() =>
|
|
301
|
+
handleQuantityChange(quantity + quantityStep)
|
|
302
|
+
}
|
|
303
|
+
onDecrement={() =>
|
|
304
|
+
handleQuantityChange(quantity - quantityStep)
|
|
305
|
+
}
|
|
299
306
|
min={minQuantity}
|
|
300
307
|
accessible
|
|
301
308
|
accessibilityLabel={`Quantity: ${quantity}`}
|
|
@@ -3,6 +3,7 @@ import { View, StyleSheet } from "react-native"
|
|
|
3
3
|
import { Progress } from "./Progress"
|
|
4
4
|
import type { ProgressProps } from "./Progress"
|
|
5
5
|
import { Typography } from "../../atoms/Typography"
|
|
6
|
+
import { Button } from "../../atoms/Button"
|
|
6
7
|
|
|
7
8
|
export default {
|
|
8
9
|
title: "Molecules/Progress",
|
|
@@ -33,6 +34,10 @@ export default {
|
|
|
33
34
|
description: {
|
|
34
35
|
control: { type: "text" },
|
|
35
36
|
description: "Helper text rendered above the track (right)"
|
|
37
|
+
},
|
|
38
|
+
animate: {
|
|
39
|
+
control: { type: "boolean" },
|
|
40
|
+
description: "Ease the indicator to its new width instead of snapping"
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
}
|
|
@@ -53,6 +58,38 @@ export const Playground = {
|
|
|
53
58
|
)
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
const DEMO_MAX = 6
|
|
62
|
+
|
|
63
|
+
export const Stepped = {
|
|
64
|
+
name: "Stepped (animation)",
|
|
65
|
+
render: () => {
|
|
66
|
+
const [step, setStep] = React.useState(1)
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<View style={styles.column}>
|
|
70
|
+
<Progress
|
|
71
|
+
value={step}
|
|
72
|
+
max={DEMO_MAX}
|
|
73
|
+
label="Animated"
|
|
74
|
+
description={`${step}/${DEMO_MAX}`}
|
|
75
|
+
/>
|
|
76
|
+
<Progress
|
|
77
|
+
value={step}
|
|
78
|
+
max={DEMO_MAX}
|
|
79
|
+
animate={false}
|
|
80
|
+
label="Snapping"
|
|
81
|
+
description={`${step}/${DEMO_MAX}`}
|
|
82
|
+
/>
|
|
83
|
+
<View style={styles.section}>
|
|
84
|
+
<Button onPress={() => setStep((s) => (s % DEMO_MAX) + 1)}>
|
|
85
|
+
Next step
|
|
86
|
+
</Button>
|
|
87
|
+
</View>
|
|
88
|
+
</View>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
56
93
|
export const Small = {
|
|
57
94
|
name: "Small",
|
|
58
95
|
render: () => (
|
|
@@ -3,6 +3,13 @@ import { View, ViewProps } from "react-native"
|
|
|
3
3
|
import styled from "@emotion/native"
|
|
4
4
|
import { useTheme } from "@emotion/react"
|
|
5
5
|
import * as ProgressPrimitive from "@rn-primitives/progress"
|
|
6
|
+
import Reanimated, {
|
|
7
|
+
Easing,
|
|
8
|
+
useAnimatedStyle,
|
|
9
|
+
useReducedMotion,
|
|
10
|
+
useSharedValue,
|
|
11
|
+
withTiming
|
|
12
|
+
} from "react-native-reanimated"
|
|
6
13
|
import { Typography } from "../../atoms/Typography"
|
|
7
14
|
|
|
8
15
|
type ProgressSize = "sm" | "lg"
|
|
@@ -16,11 +23,22 @@ type ProgressOwnProps = {
|
|
|
16
23
|
label?: React.ReactNode
|
|
17
24
|
description?: React.ReactNode
|
|
18
25
|
getValueLabel?: (value: number, max: number) => string
|
|
26
|
+
animate?: boolean
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export type ProgressProps = ProgressOwnProps &
|
|
22
30
|
Omit<ViewProps, keyof ProgressOwnProps>
|
|
23
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Timing the indicator eases with. Exported so a consumer overlaying something
|
|
34
|
+
* on the track (a marker, a mascot) can run its own animation off the same
|
|
35
|
+
* config and stay pinned to the leading edge of the fill.
|
|
36
|
+
*/
|
|
37
|
+
export const PROGRESS_ANIMATION = {
|
|
38
|
+
duration: 450,
|
|
39
|
+
easing: Easing.inOut(Easing.cubic)
|
|
40
|
+
} as const
|
|
41
|
+
|
|
24
42
|
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
25
43
|
|
|
26
44
|
const StyledRoot = styled(View)<{ rootGap: number }>(({ rootGap }) => ({
|
|
@@ -52,7 +70,7 @@ const StyledTrack = styled(View)<{
|
|
|
52
70
|
overflow: "hidden"
|
|
53
71
|
}))
|
|
54
72
|
|
|
55
|
-
const StyledIndicator = styled(View)<{
|
|
73
|
+
const StyledIndicator = styled(Reanimated.View)<{
|
|
56
74
|
indicatorBorderRadius: number
|
|
57
75
|
indicatorBgColor: string
|
|
58
76
|
}>(({ indicatorBorderRadius, indicatorBgColor }) => ({
|
|
@@ -78,6 +96,8 @@ const clamp = (value: number, min: number, max: number) =>
|
|
|
78
96
|
* @param {React.ReactNode} [label] - Label rendered in the top-left above the track.
|
|
79
97
|
* @param {React.ReactNode} [description] - Helper text rendered in the top-right above the track.
|
|
80
98
|
* @param {(value: number, max: number) => string} [getValueLabel] - Accessible value label.
|
|
99
|
+
* @param {boolean} [animate=true] - Ease the indicator to its new width instead of
|
|
100
|
+
* snapping. Ignored when the OS has reduced motion enabled.
|
|
81
101
|
*
|
|
82
102
|
* @example
|
|
83
103
|
* ```tsx
|
|
@@ -96,6 +116,7 @@ export const Progress = React.forwardRef<View, ProgressProps>(
|
|
|
96
116
|
label,
|
|
97
117
|
description,
|
|
98
118
|
getValueLabel,
|
|
119
|
+
animate = true,
|
|
99
120
|
...rest
|
|
100
121
|
},
|
|
101
122
|
ref
|
|
@@ -117,6 +138,20 @@ export const Progress = React.forwardRef<View, ProgressProps>(
|
|
|
117
138
|
const typographyForSize =
|
|
118
139
|
size === "lg" ? typography.large : typography.small
|
|
119
140
|
|
|
141
|
+
const reducedMotion = useReducedMotion()
|
|
142
|
+
const shouldAnimate = animate && !reducedMotion
|
|
143
|
+
const fill = useSharedValue(percent)
|
|
144
|
+
|
|
145
|
+
React.useEffect(() => {
|
|
146
|
+
fill.value = shouldAnimate
|
|
147
|
+
? withTiming(percent, PROGRESS_ANIMATION)
|
|
148
|
+
: percent
|
|
149
|
+
}, [fill, percent, shouldAnimate])
|
|
150
|
+
|
|
151
|
+
const indicatorStyle = useAnimatedStyle(() => ({
|
|
152
|
+
width: `${fill.value}%`
|
|
153
|
+
}))
|
|
154
|
+
|
|
120
155
|
const trackSize = { lg: track.size.large, sm: track.size.small }[size]
|
|
121
156
|
const trackHeight = parseTokenValue(trackSize.height)
|
|
122
157
|
const trackMinWidth = parseTokenValue(trackSize.mindWidth)
|
|
@@ -171,7 +206,7 @@ export const Progress = React.forwardRef<View, ProgressProps>(
|
|
|
171
206
|
<StyledIndicator
|
|
172
207
|
indicatorBorderRadius={indicatorBorderRadius}
|
|
173
208
|
indicatorBgColor={indicatorBgColor}
|
|
174
|
-
style={
|
|
209
|
+
style={indicatorStyle}
|
|
175
210
|
/>
|
|
176
211
|
</ProgressPrimitive.Indicator>
|
|
177
212
|
</StyledTrack>
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { Progress } from "./Progress"
|
|
1
|
+
export { Progress, PROGRESS_ANIMATION } from "./Progress"
|
|
2
2
|
export type { ProgressProps } from "./Progress"
|
|
@@ -3,7 +3,10 @@ import { screen } from "@testing-library/react"
|
|
|
3
3
|
import userEvent from "@testing-library/user-event"
|
|
4
4
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
|
5
5
|
import { renderWithTheme } from "../../../test-utils"
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
StackedNotifications,
|
|
8
|
+
computeBackfillIds
|
|
9
|
+
} from "./StackedNotifications"
|
|
7
10
|
import type { StackedNotificationItem } from "./StackedNotifications"
|
|
8
11
|
|
|
9
12
|
const A: StackedNotificationItem = {
|
|
@@ -200,3 +203,57 @@ describe("StackedNotifications", () => {
|
|
|
200
203
|
})
|
|
201
204
|
})
|
|
202
205
|
})
|
|
206
|
+
|
|
207
|
+
describe("computeBackfillIds", () => {
|
|
208
|
+
// This decides whether a card slides in or fades in. Tested separately
|
|
209
|
+
// here because the tests above use a mocked animation library, so they
|
|
210
|
+
// can't actually check which animation a card gets.
|
|
211
|
+
|
|
212
|
+
it("flags an item that was queued but not yet visible as a backfill", () => {
|
|
213
|
+
const result = computeBackfillIds(
|
|
214
|
+
[C, B, A],
|
|
215
|
+
new Set(["a", "b", "c"]),
|
|
216
|
+
new Set(["c", "b"])
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
expect(result).toEqual(new Set(["a"]))
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it("does not flag a genuinely new item that was never queued before", () => {
|
|
223
|
+
const result = computeBackfillIds(
|
|
224
|
+
[C, B, A],
|
|
225
|
+
new Set(["b", "c"]),
|
|
226
|
+
new Set(["c", "b"])
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
expect(result).toEqual(new Set())
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it("does not flag an item that was already visible", () => {
|
|
233
|
+
const result = computeBackfillIds(
|
|
234
|
+
[C, B, A],
|
|
235
|
+
new Set(["a", "b", "c"]),
|
|
236
|
+
new Set(["a", "b", "c"])
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
expect(result).toEqual(new Set())
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("only considers items present in the current layers", () => {
|
|
243
|
+
// "a" was queued and hidden before, but it's not shown this time, so
|
|
244
|
+
// it shouldn't show up in the result at all.
|
|
245
|
+
const result = computeBackfillIds(
|
|
246
|
+
[C, B],
|
|
247
|
+
new Set(["a", "b", "c"]),
|
|
248
|
+
new Set(["c"])
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
expect(result).toEqual(new Set(["b"]))
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it("flags nothing on an initial mount, where nothing was previously queued", () => {
|
|
255
|
+
const result = computeBackfillIds([C, B, A], new Set(), new Set())
|
|
256
|
+
|
|
257
|
+
expect(result).toEqual(new Set())
|
|
258
|
+
})
|
|
259
|
+
})
|
|
@@ -44,15 +44,16 @@ const SPRING_CONFIG = { damping: 20, stiffness: 260, mass: 0.5 }
|
|
|
44
44
|
const CARD_ENTERING = FadeInDown.duration(200).easing(Easing.inOut(Easing.quad))
|
|
45
45
|
const CARD_EXITING = FadeOutDown.duration(200)
|
|
46
46
|
|
|
47
|
-
// Bottom-anchored so scaling never moves a card's bottom edge — only
|
|
48
|
-
// translateY does. That's what makes the peek reveal an exact, constant
|
|
49
|
-
// distance regardless of a card's own message length.
|
|
50
47
|
const CARD_BASE_STYLE = {
|
|
51
48
|
position: "absolute" as const,
|
|
52
49
|
bottom: 0,
|
|
53
50
|
left: 0,
|
|
54
|
-
right: 0
|
|
55
|
-
|
|
51
|
+
right: 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEPTH_LAYER_STYLE = {
|
|
55
|
+
transformOrigin: "50% 100%" as const,
|
|
56
|
+
width: "100%" as const
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
// Floats over content pinned to the bottom. `box-none` lets touches pass
|
|
@@ -79,11 +80,35 @@ const StyledStack = styled(View)({
|
|
|
79
80
|
width: "100%"
|
|
80
81
|
})
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Works out which cards are only now becoming visible because they were
|
|
85
|
+
* already queued (a "backfill"), versus cards that are genuinely brand new.
|
|
86
|
+
* A backfilled card should slide into place; a brand new one should fade in.
|
|
87
|
+
*
|
|
88
|
+
* This lives outside the component as its own function so it can be tested
|
|
89
|
+
* directly — the animation library is mocked in tests, so testing this logic
|
|
90
|
+
* through the rendered component wouldn't actually prove it works.
|
|
91
|
+
*/
|
|
92
|
+
export const computeBackfillIds = (
|
|
93
|
+
layers: StackedNotificationItem[],
|
|
94
|
+
prevActiveIds: Set<string>,
|
|
95
|
+
prevLayerIds: Set<string>
|
|
96
|
+
): Set<string> => {
|
|
97
|
+
const ids = new Set<string>()
|
|
98
|
+
layers.forEach((item) => {
|
|
99
|
+
if (prevActiveIds.has(item.id) && !prevLayerIds.has(item.id)) {
|
|
100
|
+
ids.add(item.id)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
return ids
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
type StackedCardProps = {
|
|
83
107
|
item: StackedNotificationItem
|
|
84
108
|
depth: number
|
|
85
109
|
revealStep: number
|
|
86
110
|
onDismiss?: (id: string) => void
|
|
111
|
+
isBackfill: boolean
|
|
87
112
|
}
|
|
88
113
|
|
|
89
114
|
/**
|
|
@@ -97,15 +122,32 @@ const StackedCard = ({
|
|
|
97
122
|
item,
|
|
98
123
|
depth,
|
|
99
124
|
revealStep,
|
|
100
|
-
onDismiss
|
|
125
|
+
onDismiss,
|
|
126
|
+
isBackfill
|
|
101
127
|
}: StackedCardProps) => {
|
|
102
|
-
|
|
128
|
+
// Remember whether this card was a backfill when it first appeared, and
|
|
129
|
+
// ignore any later changes to the `isBackfill` prop. The parent only
|
|
130
|
+
// reports `isBackfill: true` for the one render where the card shows up —
|
|
131
|
+
// if we kept using the live value, the card's animation would stop
|
|
132
|
+
// partway through as soon as that value flips back to false.
|
|
133
|
+
const isBackfillRef = React.useRef(isBackfill)
|
|
134
|
+
const wasBackfill = isBackfillRef.current
|
|
135
|
+
|
|
136
|
+
const depthSV = useSharedValue(wasBackfill ? depth + 1 : depth)
|
|
137
|
+
const opacitySV = useSharedValue(wasBackfill ? 0 : 1)
|
|
103
138
|
|
|
104
139
|
React.useEffect(() => {
|
|
105
140
|
depthSV.value = withSpring(depth, SPRING_CONFIG)
|
|
106
141
|
}, [depth, depthSV])
|
|
107
142
|
|
|
108
|
-
|
|
143
|
+
// Only a backfilled card needs to fade in here. A brand new card already
|
|
144
|
+
// fades in via the outer view's `entering` animation instead.
|
|
145
|
+
React.useEffect(() => {
|
|
146
|
+
if (wasBackfill) opacitySV.value = withSpring(1, SPRING_CONFIG)
|
|
147
|
+
}, [])
|
|
148
|
+
|
|
149
|
+
const depthStyle = useAnimatedStyle(() => ({
|
|
150
|
+
opacity: opacitySV.value,
|
|
109
151
|
transform: [
|
|
110
152
|
{ translateY: depthSV.value * revealStep },
|
|
111
153
|
{ scale: 1 - depthSV.value * SCALE_STEP }
|
|
@@ -116,20 +158,22 @@ const StackedCard = ({
|
|
|
116
158
|
|
|
117
159
|
return (
|
|
118
160
|
<Reanimated.View
|
|
119
|
-
entering={CARD_ENTERING}
|
|
161
|
+
entering={wasBackfill ? undefined : CARD_ENTERING}
|
|
120
162
|
exiting={CARD_EXITING}
|
|
121
163
|
pointerEvents={isFront ? "auto" : "none"}
|
|
122
164
|
accessibilityElementsHidden={!isFront}
|
|
123
165
|
importantForAccessibility={isFront ? "auto" : "no-hide-descendants"}
|
|
124
|
-
style={[CARD_BASE_STYLE, { zIndex: -depth }
|
|
166
|
+
style={[CARD_BASE_STYLE, { zIndex: -depth }]}
|
|
125
167
|
>
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
168
|
+
<Reanimated.View style={[DEPTH_LAYER_STYLE, depthStyle]}>
|
|
169
|
+
<Notification
|
|
170
|
+
variant="toast"
|
|
171
|
+
type={item.type}
|
|
172
|
+
onClose={isFront ? () => onDismiss?.(item.id) : undefined}
|
|
173
|
+
>
|
|
174
|
+
{item.message}
|
|
175
|
+
</Notification>
|
|
176
|
+
</Reanimated.View>
|
|
133
177
|
</Reanimated.View>
|
|
134
178
|
)
|
|
135
179
|
}
|
|
@@ -207,6 +251,27 @@ export const StackedNotifications = React.forwardRef<
|
|
|
207
251
|
[activeItems]
|
|
208
252
|
)
|
|
209
253
|
|
|
254
|
+
// Keep track of which ids were queued, and which were actually shown,
|
|
255
|
+
// last time we rendered. That's how we tell "already queued, only now
|
|
256
|
+
// shown" apart from "brand new" below.
|
|
257
|
+
const prevActiveIdsRef = React.useRef<Set<string>>(new Set())
|
|
258
|
+
const prevLayerIdsRef = React.useRef<Set<string>>(new Set())
|
|
259
|
+
|
|
260
|
+
const backfillIds = React.useMemo(
|
|
261
|
+
() =>
|
|
262
|
+
computeBackfillIds(
|
|
263
|
+
layers,
|
|
264
|
+
prevActiveIdsRef.current,
|
|
265
|
+
prevLayerIdsRef.current
|
|
266
|
+
),
|
|
267
|
+
[layers]
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
React.useEffect(() => {
|
|
271
|
+
prevActiveIdsRef.current = new Set(activeItems.map((item) => item.id))
|
|
272
|
+
prevLayerIdsRef.current = new Set(layers.map((item) => item.id))
|
|
273
|
+
}, [activeItems, layers])
|
|
274
|
+
|
|
210
275
|
const handleDismiss = (id: string) => {
|
|
211
276
|
setDismissed((prev) => {
|
|
212
277
|
const next = new Set(prev)
|
|
@@ -235,6 +300,7 @@ export const StackedNotifications = React.forwardRef<
|
|
|
235
300
|
depth={depth}
|
|
236
301
|
revealStep={peekStep}
|
|
237
302
|
onDismiss={handleDismiss}
|
|
303
|
+
isBackfill={backfillIds.has(item.id)}
|
|
238
304
|
/>
|
|
239
305
|
))}
|
|
240
306
|
</StyledStack>
|