@butternutbox/pawprint-native 1.5.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butternutbox/pawprint-native",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -3,7 +3,6 @@ export * from "./CarouselControls"
3
3
  export * from "./Typography"
4
4
  export * from "./Badge"
5
5
  export * from "./Button"
6
- export * from "./ChefDogLoader"
7
6
  export * from "./Divider"
8
7
  export * from "./Hint"
9
8
  export * from "./Icon"
@@ -6,12 +6,18 @@ import { Drawer } from "./Drawer"
6
6
 
7
7
  const BasicDrawer = ({
8
8
  open,
9
- onOpenChange
9
+ onOpenChange,
10
+ onExitComplete
10
11
  }: {
11
12
  open?: boolean
12
13
  onOpenChange?: (v: boolean) => void
14
+ onExitComplete?: () => void
13
15
  }) => (
14
- <Drawer.Root open={open} onOpenChange={onOpenChange}>
16
+ <Drawer.Root
17
+ open={open}
18
+ onOpenChange={onOpenChange}
19
+ onExitComplete={onExitComplete}
20
+ >
15
21
  <Drawer.Trigger>
16
22
  <button>Open</button>
17
23
  </Drawer.Trigger>
@@ -77,6 +83,47 @@ describe("Drawer", () => {
77
83
  })
78
84
  })
79
85
 
86
+ describe("onExitComplete", () => {
87
+ it("is not called while the drawer has never closed", () => {
88
+ const onExitComplete = vi.fn()
89
+ const { rerender } = renderWithTheme(
90
+ <BasicDrawer open={false} onExitComplete={onExitComplete} />
91
+ )
92
+ rerender(<BasicDrawer open onExitComplete={onExitComplete} />)
93
+ expect(onExitComplete).not.toHaveBeenCalled()
94
+ })
95
+
96
+ it("is called once the drawer has closed and its content is removed", () => {
97
+ const onExitComplete = vi.fn(() => {
98
+ expect(screen.queryByText("Drawer title")).not.toBeInTheDocument()
99
+ })
100
+ const { rerender } = renderWithTheme(
101
+ <BasicDrawer open onExitComplete={onExitComplete} />
102
+ )
103
+ rerender(<BasicDrawer open={false} onExitComplete={onExitComplete} />)
104
+ expect(onExitComplete).toHaveBeenCalledTimes(1)
105
+ })
106
+
107
+ it("is called once per close", () => {
108
+ const onExitComplete = vi.fn()
109
+ const { rerender } = renderWithTheme(
110
+ <BasicDrawer open onExitComplete={onExitComplete} />
111
+ )
112
+ rerender(<BasicDrawer open={false} onExitComplete={onExitComplete} />)
113
+ rerender(<BasicDrawer open onExitComplete={onExitComplete} />)
114
+ rerender(<BasicDrawer open={false} onExitComplete={onExitComplete} />)
115
+ expect(onExitComplete).toHaveBeenCalledTimes(2)
116
+ })
117
+
118
+ it("is called when an uncontrolled drawer is closed", () => {
119
+ const onExitComplete = vi.fn()
120
+ renderWithTheme(<BasicDrawer onExitComplete={onExitComplete} />)
121
+ fireEvent.click(screen.getByText("Open"))
122
+ fireEvent.click(screen.getByLabelText("Close"))
123
+ expect(onExitComplete).toHaveBeenCalledTimes(1)
124
+ })
125
+ })
126
+
80
127
  describe("controlled mode", () => {
81
128
  it("respects controlled open=true", () => {
82
129
  renderWithTheme(<BasicDrawer open />)
@@ -19,6 +19,14 @@ type DrawerRootOwnProps = {
19
19
  open?: boolean
20
20
  defaultOpen?: boolean
21
21
  onOpenChange?: (open: boolean) => void
22
+ /**
23
+ * Called once the drawer has fully closed: the exit animation has finished
24
+ * and the native `Modal` has been removed. Use it to chain work that can't
25
+ * overlap the drawer, such as opening another modal drawer (iOS won't present
26
+ * a second `Modal` while the first is still on screen) or unmounting the
27
+ * drawer's host.
28
+ */
29
+ onExitComplete?: () => void
22
30
  /**
23
31
  * Whether the drawer shows a dimming backdrop and presents as a blocking
24
32
  * modal (default `true`). Set to `false` for a backdrop-less, pass-through
@@ -70,6 +78,7 @@ const DrawerRoot = React.forwardRef<View, DrawerProps>(
70
78
  open: controlledOpen,
71
79
  defaultOpen = false,
72
80
  onOpenChange,
81
+ onExitComplete,
73
82
  backdrop = true,
74
83
  children,
75
84
  style,
@@ -102,10 +111,23 @@ const DrawerRoot = React.forwardRef<View, DrawerProps>(
102
111
  onOpenChange?.(false)
103
112
  }, [onOpenChange])
104
113
 
105
- const onExitComplete = useCallback(() => {
114
+ const handleExitComplete = useCallback(() => {
106
115
  setModalVisible(false)
107
116
  }, [])
108
117
 
118
+ const onExitCompleteRef = useRef(onExitComplete)
119
+ onExitCompleteRef.current = onExitComplete
120
+ const wasModalVisible = useRef(modalVisible)
121
+
122
+ // Fires after the commit that removed the Modal, not from the animation
123
+ // callback, so consumers never act while the Modal is still mounted.
124
+ useEffect(() => {
125
+ if (wasModalVisible.current && !modalVisible) {
126
+ onExitCompleteRef.current?.()
127
+ }
128
+ wasModalVisible.current = modalVisible
129
+ }, [modalVisible])
130
+
109
131
  const ctx = useMemo(
110
132
  () => ({
111
133
  isOpen,
@@ -113,9 +135,16 @@ const DrawerRoot = React.forwardRef<View, DrawerProps>(
113
135
  backdrop,
114
136
  openDrawer,
115
137
  closeDrawer,
116
- onExitComplete
138
+ onExitComplete: handleExitComplete
117
139
  }),
118
- [isOpen, modalVisible, backdrop, openDrawer, closeDrawer, onExitComplete]
140
+ [
141
+ isOpen,
142
+ modalVisible,
143
+ backdrop,
144
+ openDrawer,
145
+ closeDrawer,
146
+ handleExitComplete
147
+ ]
119
148
  )
120
149
 
121
150
  return (
@@ -1,21 +0,0 @@
1
- import React from "react"
2
- import { ChefDogLoader } from "./ChefDogLoader"
3
- import type { ChefDogLoaderProps } from "./ChefDogLoader"
4
-
5
- export default {
6
- title: "Atoms/ChefDogLoader",
7
- component: ChefDogLoader,
8
- argTypes: {
9
- size: {
10
- control: { type: "number" },
11
- description: "Width and height in points"
12
- }
13
- }
14
- }
15
-
16
- export const Playground = (args: ChefDogLoaderProps) => (
17
- <ChefDogLoader {...args} />
18
- )
19
- Playground.args = {
20
- size: 240
21
- }
@@ -1,33 +0,0 @@
1
- import React from "react"
2
- import { View } from "react-native"
3
- import { screen } from "@testing-library/react"
4
- import { describe, it, expect } from "vitest"
5
- import { renderWithTheme } from "../../../test-utils"
6
- import { ChefDogLoader } from "./ChefDogLoader"
7
-
8
- describe("ChefDogLoader", () => {
9
- describe("when component is rendering", () => {
10
- it("renders with progressbar role", () => {
11
- renderWithTheme(<ChefDogLoader />)
12
- expect(screen.getByRole("progressbar")).toBeInTheDocument()
13
- })
14
-
15
- it("has accessible loading label", () => {
16
- renderWithTheme(<ChefDogLoader />)
17
- expect(screen.getByLabelText("Loading")).toBeInTheDocument()
18
- })
19
-
20
- it("accepts a custom accessibility label", () => {
21
- renderWithTheme(<ChefDogLoader accessibilityLabel="Fetching your plan" />)
22
- expect(screen.getByLabelText("Fetching your plan")).toBeInTheDocument()
23
- })
24
- })
25
-
26
- describe("when using with ref", () => {
27
- it("forwards ref", () => {
28
- const ref = React.createRef<View>()
29
- renderWithTheme(<ChefDogLoader ref={ref} />)
30
- expect(ref.current).toBeTruthy()
31
- })
32
- })
33
- })
@@ -1,115 +0,0 @@
1
- import React from "react"
2
- import { StyleSheet, View, ViewProps } from "react-native"
3
- import Svg, { ClipPath, Defs, G, Polygon } from "react-native-svg"
4
- import Reanimated, {
5
- cancelAnimation,
6
- Easing,
7
- useAnimatedStyle,
8
- useReducedMotion,
9
- useSharedValue,
10
- withRepeat,
11
- withTiming
12
- } from "react-native-reanimated"
13
- import { LabradorChefsHat } from "@butternutbox/pawprint-illustrations/breeds"
14
-
15
- type ChefDogLoaderOwnProps = {
16
- size?: number
17
- }
18
-
19
- type ChefDogLoaderProps = ChefDogLoaderOwnProps &
20
- Omit<ViewProps, keyof ChefDogLoaderOwnProps>
21
-
22
- const DEFAULT_SIZE = 240
23
- const TAIL_REGION = "0,0 15.5,0 15.5,22.4 12,26.5 0,26.5"
24
- const BODY_REGION = "15.5,0 48,0 48,48 0,48 0,26.5 12,26.5 15.5,22.4"
25
- const TAIL_ORIGIN = { x: 13.4 / 48 - 0.5, y: 24.6 / 48 - 0.5 }
26
- const TAIL_DOWN = -6
27
- const TAIL_UP = 14
28
- const WAG_DURATION = 180
29
-
30
- /**
31
- * The labrador in a chef's hat wagging its tail, for indeterminate loading states.
32
- *
33
- * @example
34
- * ```tsx
35
- * import { ChefDogLoader } from "@butternutbox/pawprint-native"
36
- *
37
- * <ChefDogLoader />
38
- * <ChefDogLoader size={160} />
39
- * ```
40
- *
41
- * @param size - *(optional)* Width and height in points (default: 240)
42
- */
43
- const ChefDogLoader = React.forwardRef<View, ChefDogLoaderProps>(
44
- ({ size = DEFAULT_SIZE, ...rest }, ref) => {
45
- const reducedMotion = useReducedMotion()
46
- const rotation = useSharedValue(TAIL_DOWN)
47
- const clipId = React.useId().replace(/:/g, "")
48
-
49
- React.useEffect(() => {
50
- if (reducedMotion) return
51
- rotation.value = withRepeat(
52
- withTiming(TAIL_UP, {
53
- duration: WAG_DURATION,
54
- easing: Easing.inOut(Easing.quad)
55
- }),
56
- -1,
57
- true
58
- )
59
- return () => cancelAnimation(rotation)
60
- }, [reducedMotion, rotation])
61
-
62
- const originX = TAIL_ORIGIN.x * size
63
- const originY = TAIL_ORIGIN.y * size
64
-
65
- const tailStyle = useAnimatedStyle(() => ({
66
- transform: [
67
- { translateX: originX },
68
- { translateY: originY },
69
- { rotate: `${rotation.value}deg` },
70
- { translateX: -originX },
71
- { translateY: -originY }
72
- ]
73
- }))
74
-
75
- return (
76
- <View
77
- ref={ref}
78
- accessibilityRole="progressbar"
79
- accessibilityLabel="Loading"
80
- {...rest}
81
- >
82
- <Svg width={size} height={size} viewBox="0 0 48 48">
83
- <Defs>
84
- <ClipPath id={`${clipId}-body`}>
85
- <Polygon points={BODY_REGION} />
86
- </ClipPath>
87
- </Defs>
88
- <G clipPath={`url(#${clipId}-body)`}>
89
- <LabradorChefsHat />
90
- </G>
91
- </Svg>
92
- <Reanimated.View
93
- pointerEvents="none"
94
- style={[StyleSheet.absoluteFill, tailStyle]}
95
- >
96
- <Svg width={size} height={size} viewBox="0 0 48 48">
97
- <Defs>
98
- <ClipPath id={`${clipId}-tail`}>
99
- <Polygon points={TAIL_REGION} />
100
- </ClipPath>
101
- </Defs>
102
- <G clipPath={`url(#${clipId}-tail)`}>
103
- <LabradorChefsHat />
104
- </G>
105
- </Svg>
106
- </Reanimated.View>
107
- </View>
108
- )
109
- }
110
- )
111
-
112
- ChefDogLoader.displayName = "ChefDogLoader"
113
-
114
- export { ChefDogLoader }
115
- export type { ChefDogLoaderProps }
@@ -1,2 +0,0 @@
1
- export { ChefDogLoader } from "./ChefDogLoader"
2
- export type { ChefDogLoaderProps } from "./ChefDogLoader"