@butternutbox/pawprint-native 0.10.10 → 0.11.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.
@@ -0,0 +1,202 @@
1
+ import React from "react"
2
+ import { screen } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
5
+ import { renderWithTheme } from "../../../test-utils"
6
+ import { StackedNotifications } from "./StackedNotifications"
7
+ import type { StackedNotificationItem } from "./StackedNotifications"
8
+
9
+ const A: StackedNotificationItem = {
10
+ id: "a",
11
+ type: "info",
12
+ message: "First notification"
13
+ }
14
+ const B: StackedNotificationItem = {
15
+ id: "b",
16
+ type: "warning",
17
+ message: "Second notification"
18
+ }
19
+ const C: StackedNotificationItem = {
20
+ id: "c",
21
+ type: "info",
22
+ message: "Third notification"
23
+ }
24
+
25
+ describe("StackedNotifications", () => {
26
+ describe("ordering (LIFO, front is interactive)", () => {
27
+ it("shows the most recent item as the front, interactive card", () => {
28
+ renderWithTheme(<StackedNotifications items={[A, B]} />)
29
+
30
+ expect(screen.getByRole("alert")).toHaveTextContent("Second notification")
31
+ })
32
+
33
+ it("renders exactly one interactive notification when several are queued", () => {
34
+ renderWithTheme(<StackedNotifications items={[A, B, C]} />)
35
+
36
+ expect(screen.getAllByRole("alert")).toHaveLength(1)
37
+ expect(screen.getByText("Third notification")).toBeInTheDocument()
38
+ })
39
+
40
+ it("renders nothing when there are no items", () => {
41
+ renderWithTheme(<StackedNotifications items={[]} />)
42
+
43
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument()
44
+ })
45
+ })
46
+
47
+ describe("peek stack (up to 2 behind the front card)", () => {
48
+ it("renders the front card plus one peek when 2 items are queued", () => {
49
+ renderWithTheme(<StackedNotifications items={[A, B]} />)
50
+
51
+ expect(screen.getByRole("alert")).toBeInTheDocument()
52
+ expect(screen.getByText("Second notification")).toBeInTheDocument()
53
+ expect(screen.getByText("First notification")).toBeInTheDocument()
54
+ })
55
+
56
+ it("caps peeks at 2 regardless of how many are queued", () => {
57
+ const D: StackedNotificationItem = {
58
+ id: "d",
59
+ type: "warning",
60
+ message: "Fourth notification"
61
+ }
62
+ renderWithTheme(<StackedNotifications items={[A, B, C, D]} />)
63
+
64
+ // Front (D) + 2 peeks (C, B) are present; the oldest (A) is not rendered.
65
+ expect(screen.getByText("Fourth notification")).toBeInTheDocument()
66
+ expect(screen.getByText("Third notification")).toBeInTheDocument()
67
+ expect(screen.getByText("Second notification")).toBeInTheDocument()
68
+ expect(screen.queryByText("First notification")).not.toBeInTheDocument()
69
+ })
70
+
71
+ it("only the front card is exposed as an alert; peeks are hidden from the accessibility tree", () => {
72
+ renderWithTheme(<StackedNotifications items={[A, B, C]} />)
73
+
74
+ expect(screen.getAllByRole("alert")).toHaveLength(1)
75
+ expect(screen.getByRole("alert")).toHaveTextContent("Third notification")
76
+ })
77
+
78
+ it("only the front card has a close control", () => {
79
+ renderWithTheme(<StackedNotifications items={[A, B, C]} />)
80
+
81
+ expect(
82
+ screen.getAllByRole("button", { name: "Close notification" })
83
+ ).toHaveLength(1)
84
+ })
85
+
86
+ it("promotes the next peek to front, revealing a new peek behind it", async () => {
87
+ const D: StackedNotificationItem = {
88
+ id: "d",
89
+ type: "warning",
90
+ message: "Fourth notification"
91
+ }
92
+ const user = userEvent.setup()
93
+ renderWithTheme(<StackedNotifications items={[A, B, C, D]} />)
94
+
95
+ await user.click(
96
+ screen.getByRole("button", { name: "Close notification" })
97
+ )
98
+
99
+ expect(screen.getByRole("alert")).toHaveTextContent("Third notification")
100
+ expect(screen.getByText("Second notification")).toBeInTheDocument()
101
+ expect(screen.getByText("First notification")).toBeInTheDocument()
102
+ expect(screen.queryByText("Fourth notification")).not.toBeInTheDocument()
103
+ })
104
+ })
105
+
106
+ describe("variants", () => {
107
+ it("renders the info variant with its icon", () => {
108
+ renderWithTheme(<StackedNotifications items={[A]} />)
109
+
110
+ expect(screen.getByLabelText("info")).toBeInTheDocument()
111
+ })
112
+
113
+ it("renders the warning variant with its icon", () => {
114
+ renderWithTheme(<StackedNotifications items={[B]} />)
115
+
116
+ expect(screen.getByLabelText("warning")).toBeInTheDocument()
117
+ })
118
+ })
119
+
120
+ describe("dismissing", () => {
121
+ it("reveals the next item when the visible one is dismissed", async () => {
122
+ const user = userEvent.setup()
123
+ renderWithTheme(<StackedNotifications items={[A, B]} />)
124
+
125
+ await user.click(
126
+ screen.getByRole("button", { name: "Close notification" })
127
+ )
128
+
129
+ expect(screen.getByText("First notification")).toBeInTheDocument()
130
+ expect(screen.queryByText("Second notification")).not.toBeInTheDocument()
131
+ })
132
+
133
+ it("calls onDismiss with the dismissed item id", async () => {
134
+ const user = userEvent.setup()
135
+ const onDismiss = vi.fn()
136
+ renderWithTheme(
137
+ <StackedNotifications items={[A, B]} onDismiss={onDismiss} />
138
+ )
139
+
140
+ await user.click(
141
+ screen.getByRole("button", { name: "Close notification" })
142
+ )
143
+
144
+ expect(onDismiss).toHaveBeenCalledTimes(1)
145
+ expect(onDismiss).toHaveBeenCalledWith("b")
146
+ })
147
+ })
148
+
149
+ describe("persistence (no auto-dismiss)", () => {
150
+ beforeEach(() => {
151
+ vi.useFakeTimers()
152
+ })
153
+ afterEach(() => {
154
+ vi.useRealTimers()
155
+ })
156
+
157
+ it("does not dismiss on its own over time", () => {
158
+ renderWithTheme(<StackedNotifications items={[A]} />)
159
+
160
+ expect(screen.getByText("First notification")).toBeInTheDocument()
161
+ vi.advanceTimersByTime(60000)
162
+ expect(screen.getByText("First notification")).toBeInTheDocument()
163
+ })
164
+ })
165
+
166
+ describe("re-raise on reselect", () => {
167
+ it("keeps the notification until its item is removed", () => {
168
+ const { rerender } = renderWithTheme(
169
+ <StackedNotifications items={[A, B]} />
170
+ )
171
+
172
+ // Deselect B — A is revealed.
173
+ rerender(<StackedNotifications items={[A]} />)
174
+ expect(screen.getByText("First notification")).toBeInTheDocument()
175
+ expect(screen.queryByText("Second notification")).not.toBeInTheDocument()
176
+
177
+ // Reselect B — it re-raises to the top.
178
+ rerender(<StackedNotifications items={[A, B]} />)
179
+ expect(screen.getByText("Second notification")).toBeInTheDocument()
180
+ })
181
+
182
+ it("re-raises a previously dismissed item after deselect then reselect", async () => {
183
+ const user = userEvent.setup()
184
+ const { rerender } = renderWithTheme(
185
+ <StackedNotifications items={[A, B]} />
186
+ )
187
+
188
+ // Dismiss B — A shown, B hidden but still selected.
189
+ await user.click(
190
+ screen.getByRole("button", { name: "Close notification" })
191
+ )
192
+ expect(screen.getByText("First notification")).toBeInTheDocument()
193
+
194
+ // Deselect B (removed from items) then reselect it.
195
+ rerender(<StackedNotifications items={[A]} />)
196
+ rerender(<StackedNotifications items={[A, B]} />)
197
+
198
+ // The dismissed flag was cleared, so B shows again.
199
+ expect(screen.getByText("Second notification")).toBeInTheDocument()
200
+ })
201
+ })
202
+ })
@@ -0,0 +1,247 @@
1
+ import React from "react"
2
+ import { View, ViewProps } from "react-native"
3
+ import Reanimated, {
4
+ useAnimatedStyle,
5
+ useSharedValue,
6
+ withSpring,
7
+ FadeInDown,
8
+ FadeOutDown,
9
+ Easing
10
+ } from "react-native-reanimated"
11
+ import styled from "@emotion/native"
12
+ import { useTheme } from "@emotion/react"
13
+ import { Notification } from "../Notification"
14
+
15
+ type StackedNotificationType = "info" | "warning"
16
+
17
+ type StackedNotificationItem = {
18
+ id: string
19
+ type: StackedNotificationType
20
+ message: React.ReactNode
21
+ }
22
+
23
+ type StackedNotificationsOwnProps = {
24
+ items: StackedNotificationItem[]
25
+ onDismiss?: (id: string) => void
26
+ bottomOffset?: number
27
+ }
28
+
29
+ export type StackedNotificationsProps = StackedNotificationsOwnProps &
30
+ Omit<ViewProps, keyof StackedNotificationsOwnProps | "children">
31
+
32
+ const parseTokenValue = (value: string): number => parseFloat(value)
33
+
34
+ // Behind the front card, at most this many queued notifications peek out
35
+ // (à la iOS notification grouping) — regardless of how many are queued.
36
+ const PEEK_LAYERS = 2
37
+
38
+ // Unitless recede per depth level — the reveal distance itself comes from a
39
+ // spacing token (`peekStep`), this only controls the "shrink into the
40
+ // background" feel.
41
+ const SCALE_STEP = 0.06
42
+ const SPRING_CONFIG = { damping: 20, stiffness: 260, mass: 0.5 }
43
+
44
+ const CARD_ENTERING = FadeInDown.duration(200).easing(Easing.inOut(Easing.quad))
45
+ const CARD_EXITING = FadeOutDown.duration(200)
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
+ const CARD_BASE_STYLE = {
51
+ position: "absolute" as const,
52
+ bottom: 0,
53
+ left: 0,
54
+ right: 0,
55
+ transformOrigin: "50% 100%"
56
+ }
57
+
58
+ // Floats over content pinned to the bottom. `box-none` lets touches pass
59
+ // through to the content behind everywhere except on the notification itself,
60
+ // so the list is never blocked and never reflows.
61
+ const StyledRoot = styled(View)<{
62
+ rootBottom: number
63
+ rootPaddingH: number
64
+ }>(({ rootBottom, rootPaddingH }) => ({
65
+ position: "absolute",
66
+ left: 0,
67
+ right: 0,
68
+ bottom: rootBottom,
69
+ paddingHorizontal: rootPaddingH,
70
+ alignItems: "stretch"
71
+ }))
72
+
73
+ // Positioning context for the card layers. Deliberately left unsized — every
74
+ // layer is absolutely positioned and bottom-anchored, so the stack's own
75
+ // height stays 0 without clipping anything (RN views default to visible
76
+ // overflow).
77
+ const StyledStack = styled(View)({
78
+ position: "relative",
79
+ width: "100%"
80
+ })
81
+
82
+ type StackedCardProps = {
83
+ item: StackedNotificationItem
84
+ depth: number
85
+ revealStep: number
86
+ onDismiss?: (id: string) => void
87
+ }
88
+
89
+ /**
90
+ * One card in the stack. Depth 0 is the front (interactive); 1 and 2 peek
91
+ * out behind it. Stays mounted across promotions — dismissing the front card
92
+ * shifts every other card's `depth` down by one — so the position change
93
+ * springs smoothly instead of jump-cutting. Only truly joining or leaving
94
+ * the visible stack mounts/unmounts it.
95
+ */
96
+ const StackedCard = ({
97
+ item,
98
+ depth,
99
+ revealStep,
100
+ onDismiss
101
+ }: StackedCardProps) => {
102
+ const depthSV = useSharedValue(depth)
103
+
104
+ React.useEffect(() => {
105
+ depthSV.value = withSpring(depth, SPRING_CONFIG)
106
+ }, [depth, depthSV])
107
+
108
+ const animatedStyle = useAnimatedStyle(() => ({
109
+ transform: [
110
+ { translateY: depthSV.value * revealStep },
111
+ { scale: 1 - depthSV.value * SCALE_STEP }
112
+ ]
113
+ }))
114
+
115
+ const isFront = depth === 0
116
+
117
+ return (
118
+ <Reanimated.View
119
+ entering={CARD_ENTERING}
120
+ exiting={CARD_EXITING}
121
+ pointerEvents={isFront ? "auto" : "none"}
122
+ accessibilityElementsHidden={!isFront}
123
+ importantForAccessibility={isFront ? "auto" : "no-hide-descendants"}
124
+ style={[CARD_BASE_STYLE, { zIndex: -depth }, animatedStyle]}
125
+ >
126
+ <Notification
127
+ variant="toast"
128
+ type={item.type}
129
+ onClose={isFront ? () => onDismiss?.(item.id) : undefined}
130
+ >
131
+ {item.message}
132
+ </Notification>
133
+ </Reanimated.View>
134
+ )
135
+ }
136
+
137
+ /**
138
+ * A last-in-first-out stack of floating notifications pinned to the bottom of
139
+ * the screen. Only the most recent notification is interactive; up to two
140
+ * more peek out from behind it (à la iOS notification grouping) so the depth
141
+ * of the queue is visible, regardless of how many are actually queued.
142
+ * Dismissing the front one springs the next into place.
143
+ *
144
+ * There is no auto-dismiss: a notification persists until its item is removed
145
+ * from `items` (deselected). Removing then re-adding an item re-raises it to
146
+ * the top. Fully controlled — the consumer owns the set; this component owns
147
+ * ordering, dismissal, and enter/exit animation.
148
+ *
149
+ * @param {StackedNotificationItem[]} items - Active notifications, ordered by insertion — the last element is the most recent and sits at the front of the stack.
150
+ * @param {(id: string) => void} [onDismiss] - Called with the item id when its close control is pressed.
151
+ * @param {number} [bottomOffset] - Distance from the bottom edge in pixels. Defaults to `spacing.lg`.
152
+ *
153
+ * @example
154
+ * ```tsx
155
+ * import { StackedNotifications } from "@butternutbox/pawprint-native"
156
+ *
157
+ * <StackedNotifications
158
+ * items={selected.map((a) => ({
159
+ * id: a.id,
160
+ * type: a.severe ? "warning" : "info",
161
+ * message: a.note
162
+ * }))}
163
+ * onDismiss={(id) => console.log("dismissed", id)}
164
+ * />
165
+ * ```
166
+ */
167
+ export const StackedNotifications = React.forwardRef<
168
+ View,
169
+ StackedNotificationsProps
170
+ >(({ items, onDismiss, bottomOffset, ...rest }, ref) => {
171
+ const theme = useTheme()
172
+ const { spacing } = theme.tokens.semantics.dimensions
173
+
174
+ // Ids the user has closed with the dismiss control. Kept until the item
175
+ // leaves `items`, so a dismissed-but-still-selected notification stays hidden.
176
+ const [dismissed, setDismissed] = React.useState<Set<string>>(new Set())
177
+
178
+ const idsKey = items.map((item) => item.id).join("|")
179
+
180
+ // Prune dismissed ids that are no longer present, so deselect-then-reselect
181
+ // re-raises the notification. A no-op returns the same Set reference, letting
182
+ // React bail out of the re-render.
183
+ React.useEffect(() => {
184
+ setDismissed((prev) => {
185
+ if (prev.size === 0) return prev
186
+ const present = new Set(items.map((item) => item.id))
187
+ let changed = false
188
+ const next = new Set<string>()
189
+ prev.forEach((id) => {
190
+ if (present.has(id)) next.add(id)
191
+ else changed = true
192
+ })
193
+ return changed ? next : prev
194
+ })
195
+ }, [idsKey])
196
+
197
+ const activeItems = React.useMemo(
198
+ () => items.filter((item) => !dismissed.has(item.id)),
199
+ [items, dismissed]
200
+ )
201
+
202
+ // Front-most first: [front, peek₁, peek₂]. Capped at PEEK_LAYERS behind
203
+ // the front card regardless of how many are actually queued — the array
204
+ // index doubles as each card's depth.
205
+ const layers = React.useMemo(
206
+ () => activeItems.slice(-(PEEK_LAYERS + 1)).reverse(),
207
+ [activeItems]
208
+ )
209
+
210
+ const handleDismiss = (id: string) => {
211
+ setDismissed((prev) => {
212
+ const next = new Set(prev)
213
+ next.add(id)
214
+ return next
215
+ })
216
+ onDismiss?.(id)
217
+ }
218
+
219
+ const rootBottom = bottomOffset ?? parseTokenValue(spacing.lg)
220
+ const peekStep = parseTokenValue(spacing.xs)
221
+
222
+ return (
223
+ <StyledRoot
224
+ ref={ref}
225
+ pointerEvents="box-none"
226
+ rootBottom={rootBottom}
227
+ rootPaddingH={parseTokenValue(spacing.md)}
228
+ {...rest}
229
+ >
230
+ <StyledStack>
231
+ {layers.map((item, depth) => (
232
+ <StackedCard
233
+ key={item.id}
234
+ item={item}
235
+ depth={depth}
236
+ revealStep={peekStep}
237
+ onDismiss={handleDismiss}
238
+ />
239
+ ))}
240
+ </StyledStack>
241
+ </StyledRoot>
242
+ )
243
+ })
244
+
245
+ StackedNotifications.displayName = "StackedNotifications"
246
+
247
+ export type { StackedNotificationItem, StackedNotificationType }
@@ -0,0 +1,6 @@
1
+ export { StackedNotifications } from "./StackedNotifications"
2
+ export type {
3
+ StackedNotificationsProps,
4
+ StackedNotificationItem,
5
+ StackedNotificationType
6
+ } from "./StackedNotifications"
@@ -16,6 +16,7 @@ export * from "./SelectField"
16
16
  export * from "./NativeSelectPicker"
17
17
  export * from "./Slider"
18
18
  export * from "./Notification"
19
+ export * from "./StackedNotifications"
19
20
  export * from "./Tooltip"
20
21
  export * from "./MessageCard"
21
22
  export * from "./DatePicker"