@butternutbox/pawprint-native 0.23.2 → 0.24.1

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.23.2",
3
+ "version": "0.24.1",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -83,18 +83,6 @@ const StyledButton = styled(Pressable)<{
83
83
  })
84
84
  )
85
85
 
86
- const StyledTextWrapper = styled(View)<{
87
- textOpacity: number
88
- fullWidth?: boolean
89
- }>(({ textOpacity, fullWidth }) => ({
90
- opacity: textOpacity,
91
- // `flexShrink` (not `flex: 1`) so the text wrapper can shrink to let long /
92
- // translated copy wrap under the button's `minHeight`, WITHOUT growing to
93
- // fill the row on short copy — which would push a `startIcon`/`endIcon` out
94
- // to the button edge instead of sitting next to the centred text.
95
- ...(fullWidth ? { flexShrink: 1 } : {})
96
- }))
97
-
98
86
  const StyledSpinnerWrapper = styled(View)({
99
87
  position: "absolute",
100
88
  alignItems: "center",
@@ -233,21 +221,26 @@ const Button = React.forwardRef<View, ButtonProps>(
233
221
  {...rest}
234
222
  >
235
223
  {startIcon && <IconWrapper>{startIcon}</IconWrapper>}
236
- <StyledTextWrapper textOpacity={loading ? 0 : 1} fullWidth={fullWidth}>
237
- <Typography
238
- token={{
239
- fontFamily: typography.fontFamily,
240
- fontWeight: typography.fontWeight,
241
- fontSize: typography.fontSize,
242
- lineHeight: typography.lineHeight,
243
- letterSpacing: typography.letterSpacing
244
- }}
245
- color={variantStyles.textColor as never}
246
- align="center"
247
- >
248
- {children}
249
- </Typography>
250
- </StyledTextWrapper>
224
+ {/* `Typography` is a DIRECT child of the button row (no wrapping View)
225
+ so its `<Text>` participates in flex shrink and wraps long /
226
+ translated labels to multiple lines under the button's `minHeight`.
227
+ A `<Text>` nested inside an intermediate flex `<View>` does not
228
+ re-wrap once the wrapper resolves its width. Loading hides the label
229
+ via a transparent colour (keeping its footprint) while the spinner
230
+ overlays it. */}
231
+ <Typography
232
+ token={{
233
+ fontFamily: typography.fontFamily,
234
+ fontWeight: typography.fontWeight,
235
+ fontSize: typography.fontSize,
236
+ lineHeight: typography.lineHeight,
237
+ letterSpacing: typography.letterSpacing
238
+ }}
239
+ color={(loading ? "transparent" : variantStyles.textColor) as never}
240
+ align="center"
241
+ >
242
+ {children}
243
+ </Typography>
251
244
  {endIcon && <IconWrapper>{endIcon}</IconWrapper>}
252
245
  {loading && (
253
246
  <StyledSpinnerWrapper>
@@ -66,6 +66,12 @@ const StyledTag = styled(View)<{
66
66
  }
67
67
  })
68
68
 
69
+ // Lets the label shrink so a long tag wraps within its container instead of
70
+ // growing past it (e.g. long translated copy overflowing a card's padding).
71
+ const StyledLabel = styled(View)({
72
+ flexShrink: 1
73
+ })
74
+
69
75
  /**
70
76
  * Tag component for labelling and categorising content.
71
77
  *
@@ -118,12 +124,14 @@ export const Tag = React.forwardRef<View, TagProps>(
118
124
  colour={iconColourMap[variant]}
119
125
  />
120
126
  )}
121
- <Typography
122
- token={typography[size].default}
123
- color={textColorMap[variant]}
124
- >
125
- {children}
126
- </Typography>
127
+ <StyledLabel>
128
+ <Typography
129
+ token={typography[size].default}
130
+ color={textColorMap[variant]}
131
+ >
132
+ {children}
133
+ </Typography>
134
+ </StyledLabel>
127
135
  </StyledTag>
128
136
  )
129
137
  }
@@ -217,6 +217,12 @@ export const Typography = React.forwardRef<Text, TypographyProps>(
217
217
  } = props
218
218
 
219
219
  const style: TextStyle = {
220
+ // Token-mode text is component-owned and lives inside flex rows
221
+ // (buttons, tabs, tags). `flexShrink` lets it wrap to multiple lines
222
+ // when the row is narrower than the text, instead of laying out on one
223
+ // line and overflowing/clipping — a `<Text>` nested in a flex `<View>`
224
+ // won't re-wrap on its own after the parent's width is resolved.
225
+ flexShrink: 1,
220
226
  ...nativeTokenToStyle(token),
221
227
  ...(color ? { color } : {}),
222
228
  ...getCommonStyles(align, textTransform, textDecoration)
@@ -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={{ width: `${percent}%` }}
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 { StackedNotifications } from "./StackedNotifications"
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
- transformOrigin: "50% 100%"
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
- const depthSV = useSharedValue(depth)
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
- const animatedStyle = useAnimatedStyle(() => ({
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 }, animatedStyle]}
166
+ style={[CARD_BASE_STYLE, { zIndex: -depth }]}
125
167
  >
126
- <Notification
127
- variant="toast"
128
- type={item.type}
129
- onClose={isFront ? () => onDismiss?.(item.id) : undefined}
130
- >
131
- {item.message}
132
- </Notification>
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>
@@ -114,7 +114,11 @@ const StyledTab = styled(Pressable)<{
114
114
  alignItems: "center",
115
115
  justifyContent: "center",
116
116
  gap: tabGap,
117
- height: tabHeight,
117
+ // `minHeight` (not a fixed `height`) so a tab whose label wraps to more
118
+ // than one line — e.g. long translated copy in `fixed` layout — grows to
119
+ // fit instead of clipping/overflowing. The list's `alignItems: "stretch"`
120
+ // then matches every tab to the tallest, keeping labels vertically aligned.
121
+ minHeight: tabHeight,
118
122
  minWidth: tabMinWidth,
119
123
  paddingTop: tabPaddingTop,
120
124
  paddingBottom: tabPaddingBottom,
@@ -223,7 +227,7 @@ const TabNavigationTab = React.forwardRef<View, TabNavigationTabProps>(
223
227
  {icon && (
224
228
  <Icon icon={icon} customColour={textColour as string} aria-hidden />
225
229
  )}
226
- <Typography token={token} color={textColour}>
230
+ <Typography token={token} color={textColour} align="center">
227
231
  {children}
228
232
  </Typography>
229
233
  </StyledTab>