@butternutbox/pawprint-native 0.0.1 → 0.1.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.
Files changed (182) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/CHANGELOG.md +16 -0
  3. package/COMPONENT_GUIDELINES.md +111 -4
  4. package/dist/index.cjs +12370 -1455
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +1110 -11
  7. package/dist/index.d.ts +1110 -11
  8. package/dist/index.js +12324 -1455
  9. package/dist/index.js.map +1 -1
  10. package/package.json +28 -9
  11. package/src/__mocks__/asset-stub.ts +1 -0
  12. package/src/__mocks__/emotion-native.tsx +18 -0
  13. package/src/__mocks__/react-native-gesture-handler.tsx +41 -0
  14. package/src/__mocks__/react-native-reanimated.tsx +79 -0
  15. package/src/__mocks__/react-native-safe-area-context.tsx +6 -0
  16. package/src/__mocks__/react-native-svg.tsx +27 -0
  17. package/src/__mocks__/react-native-worklets.tsx +11 -0
  18. package/src/__mocks__/react-native.tsx +338 -0
  19. package/src/__mocks__/rn-primitives/avatar.tsx +24 -0
  20. package/src/__mocks__/rn-primitives/checkbox.tsx +19 -0
  21. package/src/__mocks__/rn-primitives/select.tsx +116 -0
  22. package/src/__mocks__/rn-primitives/slider.tsx +40 -0
  23. package/src/__mocks__/rn-primitives/slot.tsx +30 -0
  24. package/src/__mocks__/rn-primitives/switch.tsx +24 -0
  25. package/src/__mocks__/rn-primitives/toggle.tsx +16 -0
  26. package/src/components/atoms/Avatar/Avatar.stories.tsx +57 -49
  27. package/src/components/atoms/Avatar/Avatar.test.tsx +269 -0
  28. package/src/components/atoms/Avatar/Avatar.tsx +68 -22
  29. package/src/components/atoms/Avatar/index.ts +1 -6
  30. package/src/components/atoms/Badge/Badge.stories.tsx +5 -29
  31. package/src/components/atoms/Badge/Badge.test.tsx +90 -0
  32. package/src/components/atoms/Button/Button.test.tsx +123 -0
  33. package/src/components/atoms/Button/Button.tsx +1 -1
  34. package/src/components/atoms/CarouselControls/CarouselControls.stories.tsx +217 -0
  35. package/src/components/atoms/CarouselControls/CarouselControls.tsx +127 -0
  36. package/src/components/atoms/CarouselControls/index.ts +2 -0
  37. package/src/components/atoms/Hint/Hint.test.tsx +36 -0
  38. package/src/components/atoms/Icon/Icon.test.tsx +98 -0
  39. package/src/components/atoms/Icon/Icon.tsx +5 -1
  40. package/src/components/atoms/IconButton/IconButton.test.tsx +101 -0
  41. package/src/components/atoms/Illustration/Illustration.test.tsx +55 -0
  42. package/src/components/atoms/Input/Input.stories.tsx +129 -86
  43. package/src/components/atoms/Input/Input.test.tsx +306 -0
  44. package/src/components/atoms/Input/Input.tsx +9 -1
  45. package/src/components/atoms/Input/InputField.tsx +226 -74
  46. package/src/components/atoms/Link/Link.test.tsx +89 -0
  47. package/src/components/atoms/Logo/Logo.registry.ts +30 -5
  48. package/src/components/atoms/Logo/Logo.stories.tsx +108 -0
  49. package/src/components/atoms/Logo/Logo.test.tsx +56 -0
  50. package/src/components/atoms/Logo/assets/BCorp.tsx +113 -0
  51. package/src/components/atoms/Logo/assets/ButternutFavicon.tsx +33 -0
  52. package/src/components/atoms/Logo/assets/ButternutPrimary.tsx +294 -0
  53. package/src/components/atoms/Logo/assets/ButternutTabbedBottom.tsx +294 -0
  54. package/src/components/atoms/Logo/assets/ButternutTabbedTop.tsx +294 -0
  55. package/src/components/atoms/Logo/assets/ButternutWordmark.tsx +274 -0
  56. package/src/components/atoms/Logo/assets/PsiBufetFavicon.tsx +45 -0
  57. package/src/components/atoms/Logo/assets/PsiBufetPrimary.tsx +218 -0
  58. package/src/components/atoms/Logo/assets/PsiBufetTabbedBottom.tsx +218 -0
  59. package/src/components/atoms/Logo/assets/PsiBufetTabbedTop.tsx +218 -0
  60. package/src/components/atoms/Logo/assets/PsiBufetWordmark.tsx +195 -0
  61. package/src/components/atoms/Logo/assets/index.ts +11 -0
  62. package/src/components/atoms/NumberInput/NumberInput.stories.tsx +183 -0
  63. package/src/components/atoms/NumberInput/NumberInput.test.tsx +261 -0
  64. package/src/components/atoms/NumberInput/NumberInput.tsx +129 -0
  65. package/src/components/atoms/NumberInput/NumberInputField.tsx +77 -0
  66. package/src/components/atoms/NumberInput/index.ts +4 -0
  67. package/src/components/atoms/Spinner/Spinner.test.tsx +46 -0
  68. package/src/components/atoms/Spinner/Spinner.tsx +14 -5
  69. package/src/components/atoms/Switch/Switch.test.tsx +92 -0
  70. package/src/components/atoms/Switch/Switch.tsx +16 -13
  71. package/src/components/atoms/Tag/Tag.test.tsx +70 -0
  72. package/src/components/atoms/TextArea/TextArea.stories.tsx +303 -0
  73. package/src/components/atoms/TextArea/TextArea.test.tsx +416 -0
  74. package/src/components/atoms/TextArea/TextArea.tsx +171 -0
  75. package/src/components/atoms/TextArea/TextAreaField.tsx +304 -0
  76. package/src/components/atoms/TextArea/TextAreaLabel.tsx +103 -0
  77. package/src/components/atoms/TextArea/index.ts +6 -0
  78. package/src/components/atoms/Typography/Typography.test.tsx +94 -0
  79. package/src/components/atoms/index.ts +3 -0
  80. package/src/components/molecules/Accordion/Accordion.stories.tsx +177 -0
  81. package/src/components/molecules/Accordion/Accordion.test.tsx +185 -0
  82. package/src/components/molecules/Accordion/Accordion.tsx +284 -0
  83. package/src/components/molecules/Accordion/index.ts +6 -0
  84. package/src/components/molecules/Animated/Animated.stories.tsx +254 -0
  85. package/src/components/molecules/Animated/Animated.tsx +283 -0
  86. package/src/components/molecules/Animated/index.ts +10 -0
  87. package/src/components/molecules/ButtonDock/ButtonDock.test.tsx +83 -0
  88. package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +8 -14
  89. package/src/components/molecules/ButtonGroup/ButtonGroup.test.tsx +73 -0
  90. package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +25 -3
  91. package/src/components/molecules/Checkbox/Checkbox.stories.tsx +72 -0
  92. package/src/components/molecules/Checkbox/Checkbox.test.tsx +117 -0
  93. package/src/components/molecules/Checkbox/Checkbox.tsx +101 -95
  94. package/src/components/molecules/CopyField/CopyField.stories.tsx +313 -0
  95. package/src/components/molecules/CopyField/CopyField.test.tsx +431 -0
  96. package/src/components/molecules/CopyField/CopyField.tsx +156 -0
  97. package/src/components/molecules/CopyField/CopyFieldInput.tsx +127 -0
  98. package/src/components/molecules/CopyField/hooks/index.ts +1 -0
  99. package/src/components/molecules/CopyField/hooks/useCopyField.ts +25 -0
  100. package/src/components/molecules/CopyField/index.ts +4 -0
  101. package/src/components/molecules/DatePicker/DatePicker.stories.tsx +298 -0
  102. package/src/components/molecules/DatePicker/DatePicker.test.tsx +201 -0
  103. package/src/components/molecules/DatePicker/DatePicker.tsx +590 -0
  104. package/src/components/molecules/DatePicker/index.ts +2 -0
  105. package/src/components/molecules/Drawer/Drawer.stories.tsx +285 -0
  106. package/src/components/molecules/Drawer/Drawer.test.tsx +180 -0
  107. package/src/components/molecules/Drawer/Drawer.tsx +187 -0
  108. package/src/components/molecules/Drawer/DrawerBody.tsx +80 -0
  109. package/src/components/molecules/Drawer/DrawerClose.tsx +76 -0
  110. package/src/components/molecules/Drawer/DrawerContent.tsx +339 -0
  111. package/src/components/molecules/Drawer/DrawerContext.ts +19 -0
  112. package/src/components/molecules/Drawer/DrawerDescription.tsx +31 -0
  113. package/src/components/molecules/Drawer/DrawerDragContext.ts +11 -0
  114. package/src/components/molecules/Drawer/DrawerFooter.tsx +49 -0
  115. package/src/components/molecules/Drawer/DrawerFooterContext.ts +6 -0
  116. package/src/components/molecules/Drawer/DrawerGrabber.tsx +62 -0
  117. package/src/components/molecules/Drawer/DrawerHeader.tsx +244 -0
  118. package/src/components/molecules/Drawer/DrawerHeaderContext.ts +13 -0
  119. package/src/components/molecules/Drawer/DrawerOverlay.tsx +53 -0
  120. package/src/components/molecules/Drawer/DrawerTitle.tsx +32 -0
  121. package/src/components/molecules/Drawer/index.ts +12 -0
  122. package/src/components/molecules/FilterTab/FilterTab.stories.tsx +210 -0
  123. package/src/components/molecules/FilterTab/FilterTab.tsx +310 -0
  124. package/src/components/molecules/FilterTab/index.ts +2 -0
  125. package/src/components/molecules/MessageCard/MessageCard.stories.tsx +169 -0
  126. package/src/components/molecules/MessageCard/MessageCard.tsx +362 -0
  127. package/src/components/molecules/MessageCard/index.ts +10 -0
  128. package/src/components/molecules/Notification/Notification.stories.tsx +219 -0
  129. package/src/components/molecules/Notification/Notification.tsx +426 -0
  130. package/src/components/molecules/Notification/index.ts +2 -0
  131. package/src/components/molecules/NumberField/NumberField.stories.tsx +231 -0
  132. package/src/components/molecules/NumberField/NumberField.tsx +186 -0
  133. package/src/components/molecules/NumberField/NumberFieldInput.tsx +287 -0
  134. package/src/components/molecules/NumberField/index.ts +2 -0
  135. package/src/components/molecules/PasswordField/PasswordField.stories.tsx +362 -0
  136. package/src/components/molecules/PasswordField/PasswordField.test.tsx +369 -0
  137. package/src/components/molecules/PasswordField/PasswordField.tsx +194 -0
  138. package/src/components/molecules/PasswordField/PasswordFieldError.tsx +52 -0
  139. package/src/components/molecules/PasswordField/PasswordFieldInput.tsx +73 -0
  140. package/src/components/molecules/PasswordField/PasswordFieldRequirements.tsx +92 -0
  141. package/src/components/molecules/PasswordField/hooks/index.ts +2 -0
  142. package/src/components/molecules/PasswordField/hooks/usePasswordField.ts +113 -0
  143. package/src/components/molecules/PasswordField/index.ts +10 -0
  144. package/src/components/molecules/PictureSelector/PictureSelector.stories.tsx +243 -0
  145. package/src/components/molecules/PictureSelector/PictureSelector.tsx +313 -0
  146. package/src/components/molecules/PictureSelector/index.ts +5 -0
  147. package/src/components/molecules/Progress/Progress.stories.tsx +145 -0
  148. package/src/components/molecules/Progress/Progress.tsx +184 -0
  149. package/src/components/molecules/Progress/index.ts +2 -0
  150. package/src/components/molecules/Radio/Radio.test.tsx +104 -0
  151. package/src/components/molecules/Radio/Radio.tsx +1 -2
  152. package/src/components/molecules/SearchField/SearchField.stories.tsx +242 -0
  153. package/src/components/molecules/SearchField/SearchField.test.tsx +318 -0
  154. package/src/components/molecules/SearchField/SearchField.tsx +143 -0
  155. package/src/components/molecules/SearchField/SearchFieldInput.tsx +63 -0
  156. package/src/components/molecules/SearchField/hooks/index.ts +1 -0
  157. package/src/components/molecules/SearchField/hooks/useSearchField.ts +56 -0
  158. package/src/components/molecules/SearchField/index.ts +4 -0
  159. package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +31 -8
  160. package/src/components/molecules/SegmentedControl/SegmentedControl.test.tsx +141 -0
  161. package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +237 -23
  162. package/src/components/molecules/SelectField/SelectField.stories.tsx +320 -0
  163. package/src/components/molecules/SelectField/SelectField.test.tsx +254 -0
  164. package/src/components/molecules/SelectField/SelectField.tsx +236 -0
  165. package/src/components/molecules/SelectField/SelectFieldContent.tsx +85 -0
  166. package/src/components/molecules/SelectField/SelectFieldItem.tsx +133 -0
  167. package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +170 -0
  168. package/src/components/molecules/SelectField/SelectFieldValue.tsx +31 -0
  169. package/src/components/molecules/SelectField/hooks/index.ts +2 -0
  170. package/src/components/molecules/SelectField/hooks/useSelectField.ts +84 -0
  171. package/src/components/molecules/SelectField/index.ts +10 -0
  172. package/src/components/molecules/Slider/Slider.test.tsx +102 -0
  173. package/src/components/molecules/Slider/Slider.tsx +293 -180
  174. package/src/components/molecules/Tooltip/Tooltip.stories.tsx +168 -0
  175. package/src/components/molecules/Tooltip/Tooltip.tsx +326 -0
  176. package/src/components/molecules/Tooltip/index.ts +2 -0
  177. package/src/components/molecules/index.ts +15 -0
  178. package/src/test-utils.tsx +20 -0
  179. package/tsconfig.json +1 -1
  180. package/tsup.config.ts +16 -2
  181. package/vitest.config.ts +114 -0
  182. package/vitest.setup.ts +16 -0
@@ -0,0 +1,80 @@
1
+ import React from "react"
2
+ import { ScrollView, ScrollViewProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import { useSafeAreaInsets } from "react-native-safe-area-context"
6
+ import { useDrawerHeaderContext } from "./DrawerHeaderContext"
7
+ import { useDrawerFooterContext } from "./DrawerFooterContext"
8
+
9
+ export type DrawerBodyProps = ScrollViewProps
10
+
11
+ const parseTokenValue = (value: string): number => parseFloat(value)
12
+
13
+ const StyledScrollView = styled(ScrollView)<{
14
+ bodyPaddingHorizontal: number
15
+ bodyPaddingRight: number
16
+ bodyGap: number
17
+ bodyPaddingVertical: number
18
+ }>(
19
+ ({
20
+ bodyPaddingHorizontal,
21
+ bodyPaddingRight,
22
+ bodyGap,
23
+ bodyPaddingVertical
24
+ }) => ({
25
+ flexGrow: 1,
26
+ flexShrink: 1,
27
+ paddingLeft: bodyPaddingHorizontal,
28
+ paddingRight: bodyPaddingRight,
29
+ gap: bodyGap,
30
+ paddingTop: bodyPaddingVertical
31
+ })
32
+ )
33
+
34
+ /**
35
+ * Scrollable content area of the drawer. Grows to fill available space
36
+ * between the header and footer.
37
+ */
38
+ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
39
+ ({ children, contentContainerStyle, ...props }, ref) => {
40
+ const theme = useTheme()
41
+ const { spacing } = theme.tokens.components.drawer
42
+ const { buttons } = theme.tokens.components
43
+ const { content } = spacing
44
+ const headerContext = useDrawerHeaderContext()
45
+ const hasFooter = useDrawerFooterContext()
46
+ const insets = useSafeAreaInsets()
47
+
48
+ const gap = parseTokenValue(content.slot.gap)
49
+ const verticalPadding = parseTokenValue(content.slot.verticalPadding)
50
+ const bottomPadding = verticalPadding + (hasFooter ? 0 : insets.bottom)
51
+ const horizontalPadding = parseTokenValue(content.slot.horizontalPadding)
52
+
53
+ // When there's no header the close button floats in the top-right corner.
54
+ // Reserve space on the right so body content doesn't slide under it.
55
+ const paddingRight =
56
+ headerContext === null
57
+ ? parseTokenValue(spacing.close.right.md) +
58
+ parseTokenValue(buttons.size.sm.height)
59
+ : horizontalPadding
60
+
61
+ return (
62
+ <StyledScrollView
63
+ ref={ref}
64
+ bodyPaddingHorizontal={horizontalPadding}
65
+ bodyPaddingRight={paddingRight}
66
+ bodyGap={gap}
67
+ bodyPaddingVertical={verticalPadding}
68
+ contentContainerStyle={[
69
+ { gap, paddingBottom: bottomPadding },
70
+ contentContainerStyle
71
+ ]}
72
+ {...props}
73
+ >
74
+ {children}
75
+ </StyledScrollView>
76
+ )
77
+ }
78
+ )
79
+
80
+ DrawerBody.displayName = "Drawer.Body"
@@ -0,0 +1,76 @@
1
+ import React from "react"
2
+ import { View, ViewProps, StyleSheet } from "react-native"
3
+ import { Close as CloseIcon } from "@butternutbox/pawprint-icons/core"
4
+ import { useTheme } from "@emotion/react"
5
+ import { IconButton } from "../../atoms/IconButton"
6
+ import { useDrawerContext } from "./DrawerContext"
7
+ import { useDrawerHeaderContext } from "./DrawerHeaderContext"
8
+
9
+ export type DrawerCloseProps = {
10
+ "aria-label"?: string
11
+ } & Omit<ViewProps, "children">
12
+
13
+ const parseTokenValue = (value: string): number => parseFloat(value)
14
+
15
+ /**
16
+ * Close button for the drawer. When placed inside `Drawer.Header` the header
17
+ * manages its position. When used outside a header it renders as a floating
18
+ * button in the top-right corner of the panel.
19
+ *
20
+ * Colour is `"primary"` for the `image` variant and `"secondary"` otherwise.
21
+ */
22
+ export const DrawerClose = React.forwardRef<View, DrawerCloseProps>(
23
+ ({ "aria-label": ariaLabel = "Close", ...props }, ref) => {
24
+ const theme = useTheme()
25
+ const { closeDrawer } = useDrawerContext()
26
+ const headerContext = useDrawerHeaderContext()
27
+ const { spacing } = theme.tokens.components.drawer
28
+
29
+ const defaultColour =
30
+ headerContext?.variant === "image" ? "primary" : "secondary"
31
+
32
+ const button = (
33
+ <IconButton
34
+ icon={CloseIcon}
35
+ variant="filled"
36
+ colour={defaultColour}
37
+ size="sm"
38
+ aria-label={ariaLabel}
39
+ onPress={closeDrawer}
40
+ />
41
+ )
42
+
43
+ if (!headerContext) {
44
+ return (
45
+ <View
46
+ ref={ref}
47
+ style={[
48
+ styles.floating,
49
+ {
50
+ top: parseTokenValue(spacing.close.top.md),
51
+ right: parseTokenValue(spacing.close.right.md)
52
+ }
53
+ ]}
54
+ {...props}
55
+ >
56
+ {button}
57
+ </View>
58
+ )
59
+ }
60
+
61
+ return (
62
+ <View ref={ref} {...props}>
63
+ {button}
64
+ </View>
65
+ )
66
+ }
67
+ )
68
+
69
+ DrawerClose.displayName = "Drawer.Close"
70
+
71
+ const styles = StyleSheet.create({
72
+ floating: {
73
+ position: "absolute",
74
+ zIndex: 1
75
+ }
76
+ })
@@ -0,0 +1,339 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ PanResponder,
6
+ StyleSheet,
7
+ View,
8
+ useWindowDimensions,
9
+ type ViewProps
10
+ } from "react-native"
11
+ import styled from "@emotion/native"
12
+ import { useTheme } from "@emotion/react"
13
+ import { DrawerDragContext } from "./DrawerDragContext"
14
+ import { useDrawerContext } from "./DrawerContext"
15
+ import { DrawerFooterContext } from "./DrawerFooterContext"
16
+ import {
17
+ DrawerHeaderContext,
18
+ type DrawerHeaderVariant
19
+ } from "./DrawerHeaderContext"
20
+
21
+ export type DrawerContentProps = Omit<ViewProps, "style">
22
+
23
+ const OPEN_DURATION = 320
24
+ const CLOSE_DURATION = 220
25
+ const DISMISS_THRESHOLD = 80
26
+
27
+ const parseTokenValue = (value: string): number => parseFloat(value)
28
+
29
+ const StyledPanel = styled(View)<{
30
+ panelBorderRadius: number
31
+ panelBgColor: string
32
+ panelMaxWidth: number
33
+ panelBorderWidth: number
34
+ panelBorderColor: string
35
+ panelShadowColor: string
36
+ panelShadowOffsetY: number
37
+ panelShadowBlur: number
38
+ panelFlex?: number
39
+ }>(
40
+ ({
41
+ panelBorderRadius,
42
+ panelBgColor,
43
+ panelMaxWidth,
44
+ panelBorderWidth,
45
+ panelBorderColor,
46
+ panelShadowColor,
47
+ panelShadowOffsetY,
48
+ panelShadowBlur,
49
+ panelFlex
50
+ }) => ({
51
+ width: "100%",
52
+ maxWidth: panelMaxWidth,
53
+ alignSelf: "center",
54
+ ...(panelFlex != null ? { flex: panelFlex } : undefined),
55
+ borderTopLeftRadius: panelBorderRadius,
56
+ borderTopRightRadius: panelBorderRadius,
57
+ borderTopWidth: panelBorderWidth,
58
+ borderLeftWidth: panelBorderWidth,
59
+ borderRightWidth: panelBorderWidth,
60
+ borderColor: panelBorderColor,
61
+ backgroundColor: panelBgColor,
62
+ shadowColor: panelShadowColor,
63
+ shadowOffset: { width: 0, height: panelShadowOffsetY },
64
+ shadowOpacity: 1,
65
+ shadowRadius: panelShadowBlur,
66
+ elevation: 8,
67
+ overflow: "hidden"
68
+ })
69
+ )
70
+
71
+ /**
72
+ * Main panel container for the native drawer. Slides up from the bottom of
73
+ * the screen. Uses React Native `Animated` for entrance/exit and `PanResponder`
74
+ * on the grabber for drag-to-dismiss.
75
+ *
76
+ * Must be rendered inside `Drawer.Portal`.
77
+ *
78
+ * @example
79
+ * ```tsx
80
+ * <Drawer.Portal>
81
+ * <Drawer.Overlay />
82
+ * <Drawer.Content>
83
+ * <Drawer.Header variant="titleAndText">
84
+ * <Drawer.Title>Title</Drawer.Title>
85
+ * <Drawer.Close />
86
+ * </Drawer.Header>
87
+ * <Drawer.Body>Content</Drawer.Body>
88
+ * </Drawer.Content>
89
+ * </Drawer.Portal>
90
+ * ```
91
+ */
92
+ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
93
+ ({ children, ...props }, ref) => {
94
+ const theme = useTheme()
95
+ const { drawer, modal } = theme.tokens.components
96
+ const { colour, dimensions } = theme.tokens.semantics
97
+
98
+ const { isOpen, closeDrawer, onExitComplete } = useDrawerContext()
99
+
100
+ const { height: windowHeight } = useWindowDimensions()
101
+ const maxHeight = windowHeight * 0.9
102
+
103
+ // Keep a ref so the PanResponder (created once) always calls the latest
104
+ // closeDrawer without becoming a stale closure.
105
+ const closeDrawerRef = useRef(closeDrawer)
106
+ useEffect(() => {
107
+ closeDrawerRef.current = closeDrawer
108
+ }, [closeDrawer])
109
+
110
+ const translateY = useRef(new Animated.Value(9999)).current
111
+ const translateYValue = useRef(9999)
112
+ const panelHeightRef = useRef(0)
113
+ const entryRanRef = useRef(false)
114
+ const isOpenRef = useRef(isOpen)
115
+ const activeAnim = useRef<Animated.CompositeAnimation | null>(null)
116
+
117
+ // After the first onLayout we know the real rendered height (capped at
118
+ // MAX_HEIGHT). We immediately kick off the entry animation from that height
119
+ // and then set explicit height + flex: 1 on the panel so DrawerBody's
120
+ // ScrollView gets a bounded container and can scroll correctly.
121
+ const [panelHeight, setPanelHeight] = useState(0)
122
+
123
+ useEffect(() => {
124
+ const id = translateY.addListener(({ value }) => {
125
+ translateYValue.current = value
126
+ })
127
+ return () => translateY.removeListener(id)
128
+ }, [translateY])
129
+
130
+ // Clamp the cached panel height when the window shrinks (e.g. orientation
131
+ // change) so the entry animation never starts from below the screen.
132
+ useEffect(() => {
133
+ if (panelHeightRef.current > 0 && panelHeightRef.current > maxHeight) {
134
+ setPanelHeight(maxHeight)
135
+ panelHeightRef.current = maxHeight
136
+ }
137
+ }, [maxHeight])
138
+
139
+ const runEntry = useCallback(() => {
140
+ if (panelHeightRef.current === 0) return
141
+ translateY.setValue(panelHeightRef.current)
142
+ activeAnim.current?.stop()
143
+ const anim = Animated.timing(translateY, {
144
+ toValue: 0,
145
+ duration: OPEN_DURATION,
146
+ easing: Easing.bezier(0.32, 0.72, 0, 1),
147
+ useNativeDriver: true
148
+ })
149
+ activeAnim.current = anim
150
+ anim.start(() => {
151
+ activeAnim.current = null
152
+ })
153
+ entryRanRef.current = true
154
+ }, [translateY])
155
+
156
+ const runExit = useCallback(() => {
157
+ const height = panelHeightRef.current
158
+ if (height === 0) {
159
+ onExitComplete()
160
+ return
161
+ }
162
+ activeAnim.current?.stop()
163
+ const anim = Animated.timing(translateY, {
164
+ toValue: height * 1.1,
165
+ duration: CLOSE_DURATION,
166
+ easing: Easing.bezier(0.32, 0.72, 0, 1),
167
+ useNativeDriver: true
168
+ })
169
+ activeAnim.current = anim
170
+ anim.start(() => {
171
+ activeAnim.current = null
172
+ onExitComplete()
173
+ })
174
+ }, [translateY, onExitComplete])
175
+
176
+ useEffect(() => {
177
+ isOpenRef.current = isOpen
178
+ if (isOpen) {
179
+ entryRanRef.current = false
180
+ runEntry()
181
+ } else {
182
+ runExit()
183
+ }
184
+ }, [isOpen, runEntry, runExit])
185
+
186
+ const onLayout = useCallback(
187
+ ({ nativeEvent }: { nativeEvent: { layout: { height: number } } }) => {
188
+ const h = nativeEvent.layout.height
189
+ const capped = Math.min(h, maxHeight)
190
+
191
+ // Only update state when the effective height actually changes, to
192
+ // avoid re-render loops. Compare against the ref (kept in sync with
193
+ // state) so panelHeight doesn't need to be a dep of this callback.
194
+ if (capped !== panelHeightRef.current && capped > 0) {
195
+ setPanelHeight(capped)
196
+ panelHeightRef.current = capped
197
+ }
198
+
199
+ // Run entry on the first measurement. The animation starts from the
200
+ // capped height even though the panel may still be at natural size;
201
+ // the re-render that sets the explicit height happens while the panel
202
+ // is still off-screen, so there is no visible jump.
203
+ if (isOpenRef.current && !entryRanRef.current && capped > 0) {
204
+ panelHeightRef.current = capped
205
+ runEntry()
206
+ }
207
+ },
208
+ [runEntry, maxHeight]
209
+ )
210
+
211
+ const panResponder = useRef(
212
+ PanResponder.create({
213
+ // Claim the responder as soon as a touch starts on the grabber so the
214
+ // gesture is never stolen by a parent ScrollView.
215
+ onStartShouldSetPanResponder: () => true,
216
+ onMoveShouldSetPanResponder: (_evt, gestureState) =>
217
+ Math.abs(gestureState.dy) > 5 && gestureState.dy > 0,
218
+ onPanResponderGrant: () => {
219
+ activeAnim.current?.stop()
220
+ activeAnim.current = null
221
+ },
222
+ onPanResponderMove: (_evt, gestureState) => {
223
+ const next = Math.max(0, gestureState.dy)
224
+ translateY.setValue(next)
225
+ },
226
+ onPanResponderRelease: (_evt, gestureState) => {
227
+ const currentY = translateYValue.current
228
+ if (currentY > DISMISS_THRESHOLD || gestureState.vy > 0.5) {
229
+ const dismissAnim = Animated.timing(translateY, {
230
+ toValue: panelHeightRef.current * 1.1,
231
+ duration: CLOSE_DURATION,
232
+ easing: Easing.bezier(0.32, 0.72, 0, 1),
233
+ useNativeDriver: true
234
+ })
235
+ activeAnim.current = dismissAnim
236
+ dismissAnim.start(() => {
237
+ activeAnim.current = null
238
+ closeDrawerRef.current()
239
+ })
240
+ } else {
241
+ const snapAnim = Animated.spring(translateY, {
242
+ toValue: 0,
243
+ bounciness: 8,
244
+ useNativeDriver: true
245
+ })
246
+ activeAnim.current = snapAnim
247
+ snapAnim.start(() => {
248
+ activeAnim.current = null
249
+ })
250
+ }
251
+ },
252
+ onPanResponderTerminate: () => {
253
+ const snapAnim = Animated.spring(translateY, {
254
+ toValue: 0,
255
+ bounciness: 8,
256
+ useNativeDriver: true
257
+ })
258
+ activeAnim.current = snapAnim
259
+ snapAnim.start(() => {
260
+ activeAnim.current = null
261
+ })
262
+ }
263
+ })
264
+ ).current
265
+
266
+ const dragContextValue = useMemo(
267
+ () => ({ panHandlers: panResponder.panHandlers }),
268
+ [panResponder.panHandlers]
269
+ )
270
+
271
+ const hasFooter = React.Children.toArray(children).some(
272
+ (child) =>
273
+ React.isValidElement(child) &&
274
+ (child.type as { displayName?: string }).displayName === "Drawer.Footer"
275
+ )
276
+
277
+ const headerChild = React.Children.toArray(children).find(
278
+ (child) =>
279
+ React.isValidElement(child) &&
280
+ (child.type as { displayName?: string }).displayName === "Drawer.Header"
281
+ ) as React.ReactElement<{ variant?: DrawerHeaderVariant }> | undefined
282
+
283
+ const headerContextValue =
284
+ headerChild != null
285
+ ? {
286
+ variant:
287
+ headerChild.props.variant ??
288
+ ("titleAndText" as DrawerHeaderVariant)
289
+ }
290
+ : null
291
+
292
+ return (
293
+ <DrawerHeaderContext.Provider value={headerContextValue}>
294
+ <DrawerFooterContext.Provider value={hasFooter}>
295
+ <DrawerDragContext.Provider value={dragContextValue}>
296
+ <Animated.View
297
+ style={[
298
+ styles.animatedWrapper,
299
+ panelHeight > 0 ? { height: panelHeight } : undefined,
300
+ { transform: [{ translateY }] }
301
+ ]}
302
+ >
303
+ <StyledPanel
304
+ ref={ref}
305
+ onLayout={onLayout}
306
+ panelBorderRadius={parseTokenValue(drawer.borderRadius.top)}
307
+ panelBgColor={colour.background.container.default}
308
+ panelMaxWidth={parseTokenValue(drawer.size.maxWidth)}
309
+ panelFlex={panelHeight > 0 ? 1 : undefined}
310
+ panelBorderWidth={parseTokenValue(dimensions.borderWidth.sm)}
311
+ panelBorderColor={colour.border.default}
312
+ panelShadowColor={modal.shadow.color}
313
+ panelShadowOffsetY={parseTokenValue(modal.shadow.offsetY)}
314
+ panelShadowBlur={parseTokenValue(modal.shadow.blur)}
315
+ accessible
316
+ accessibilityRole="none"
317
+ accessibilityViewIsModal
318
+ {...props}
319
+ >
320
+ {children}
321
+ </StyledPanel>
322
+ </Animated.View>
323
+ </DrawerDragContext.Provider>
324
+ </DrawerFooterContext.Provider>
325
+ </DrawerHeaderContext.Provider>
326
+ )
327
+ }
328
+ )
329
+
330
+ DrawerContent.displayName = "Drawer.Content"
331
+
332
+ const styles = StyleSheet.create({
333
+ animatedWrapper: {
334
+ position: "absolute",
335
+ bottom: 0,
336
+ left: 0,
337
+ right: 0
338
+ }
339
+ })
@@ -0,0 +1,19 @@
1
+ import React from "react"
2
+
3
+ export type DrawerContextValue = {
4
+ isOpen: boolean
5
+ modalVisible: boolean
6
+ openDrawer: () => void
7
+ closeDrawer: () => void
8
+ onExitComplete: () => void
9
+ }
10
+
11
+ export const DrawerContext = React.createContext<DrawerContextValue>({
12
+ isOpen: false,
13
+ modalVisible: false,
14
+ openDrawer: () => {},
15
+ closeDrawer: () => {},
16
+ onExitComplete: () => {}
17
+ })
18
+
19
+ export const useDrawerContext = () => React.useContext(DrawerContext)
@@ -0,0 +1,31 @@
1
+ import React from "react"
2
+ import { View, ViewProps } from "react-native"
3
+ import { useTheme } from "@emotion/react"
4
+ import { Typography } from "../../atoms/Typography"
5
+
6
+ export type DrawerDescriptionProps = {
7
+ children: React.ReactNode
8
+ } & Omit<ViewProps, "children">
9
+
10
+ /**
11
+ * Description text for the drawer header.
12
+ */
13
+ export const DrawerDescription = React.forwardRef<View, DrawerDescriptionProps>(
14
+ ({ children, ...props }, ref) => {
15
+ const theme = useTheme()
16
+ const { drawerHeader } = theme.tokens.components.drawer
17
+
18
+ return (
19
+ <View ref={ref} {...props}>
20
+ <Typography
21
+ token={drawerHeader.typography.description}
22
+ color={drawerHeader.colour.text.description}
23
+ >
24
+ {children}
25
+ </Typography>
26
+ </View>
27
+ )
28
+ }
29
+ )
30
+
31
+ DrawerDescription.displayName = "Drawer.Description"
@@ -0,0 +1,11 @@
1
+ import React from "react"
2
+ import type { PanResponderInstance } from "react-native"
3
+
4
+ export type DrawerDragContextValue = {
5
+ panHandlers: PanResponderInstance["panHandlers"]
6
+ }
7
+
8
+ export const DrawerDragContext =
9
+ React.createContext<DrawerDragContextValue | null>(null)
10
+
11
+ export const useDrawerDragContext = () => React.useContext(DrawerDragContext)
@@ -0,0 +1,49 @@
1
+ import React from "react"
2
+ import { View, ViewProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import { useSafeAreaInsets } from "react-native-safe-area-context"
6
+
7
+ export type DrawerFooterProps = ViewProps
8
+
9
+ const parseTokenValue = (value: string): number => parseFloat(value)
10
+
11
+ const StyledFooter = styled(View)<{
12
+ footerPaddingTop: number
13
+ footerPaddingHorizontal: number
14
+ footerPaddingBottom: number
15
+ }>(({ footerPaddingTop, footerPaddingHorizontal, footerPaddingBottom }) => ({
16
+ flexShrink: 0,
17
+ paddingTop: footerPaddingTop,
18
+ paddingHorizontal: footerPaddingHorizontal,
19
+ paddingBottom: footerPaddingBottom
20
+ }))
21
+
22
+ /**
23
+ * Fixed footer section of the drawer. Typically contains action buttons.
24
+ */
25
+ export const DrawerFooter = React.forwardRef<View, DrawerFooterProps>(
26
+ ({ children, ...props }, ref) => {
27
+ const theme = useTheme()
28
+ const { spacing } = theme.tokens.components.drawer
29
+ const insets = useSafeAreaInsets()
30
+
31
+ return (
32
+ <StyledFooter
33
+ ref={ref}
34
+ footerPaddingTop={parseTokenValue(spacing.footer.topPaddding)}
35
+ footerPaddingHorizontal={parseTokenValue(
36
+ spacing.footer.horizontalPadding
37
+ )}
38
+ footerPaddingBottom={
39
+ parseTokenValue(spacing.bottomPadding) + insets.bottom
40
+ }
41
+ {...props}
42
+ >
43
+ {children}
44
+ </StyledFooter>
45
+ )
46
+ }
47
+ )
48
+
49
+ DrawerFooter.displayName = "Drawer.Footer"
@@ -0,0 +1,6 @@
1
+ import React from "react"
2
+
3
+ export const DrawerFooterContext = React.createContext(false)
4
+
5
+ export const useDrawerFooterContext = () =>
6
+ React.useContext(DrawerFooterContext)
@@ -0,0 +1,62 @@
1
+ import React from "react"
2
+ import { View, ViewProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import { useDrawerDragContext } from "./DrawerDragContext"
6
+
7
+ export type DrawerGrabberProps = ViewProps
8
+
9
+ const parseTokenValue = (value: string): number => parseFloat(value)
10
+
11
+ const StyledWrapper = styled(View)<{ grabberPaddingTop: number }>(
12
+ ({ grabberPaddingTop }) => ({
13
+ alignItems: "center",
14
+ justifyContent: "center",
15
+ width: "100%",
16
+ paddingTop: grabberPaddingTop,
17
+ paddingBottom: grabberPaddingTop
18
+ })
19
+ )
20
+
21
+ const StyledBar = styled(View)<{
22
+ barWidth: number
23
+ barHeight: number
24
+ barBorderRadius: number
25
+ barColor: string
26
+ }>(({ barWidth, barHeight, barBorderRadius, barColor }) => ({
27
+ width: barWidth,
28
+ height: barHeight,
29
+ borderRadius: barBorderRadius,
30
+ backgroundColor: barColor
31
+ }))
32
+
33
+ /**
34
+ * Standalone pull handle for the drawer. Attach to the top of `Drawer.Content`
35
+ * when `Drawer.Header` is not used. Activates drag-to-dismiss.
36
+ */
37
+ export const DrawerGrabber = React.forwardRef<View, DrawerGrabberProps>(
38
+ (props, ref) => {
39
+ const theme = useTheme()
40
+ const dragContext = useDrawerDragContext()
41
+ const { drawer, grabber } = theme.tokens.components
42
+ const { borderRadius } = theme.tokens.semantics.dimensions
43
+
44
+ return (
45
+ <StyledWrapper
46
+ ref={ref}
47
+ grabberPaddingTop={parseTokenValue(drawer.spacing.grabber.paddingTop)}
48
+ {...(dragContext?.panHandlers ?? {})}
49
+ {...props}
50
+ >
51
+ <StyledBar
52
+ barWidth={parseTokenValue(grabber.size.bar.width)}
53
+ barHeight={parseTokenValue(grabber.size.bar.height)}
54
+ barBorderRadius={parseTokenValue(borderRadius.round)}
55
+ barColor={grabber.colour.background.default}
56
+ />
57
+ </StyledWrapper>
58
+ )
59
+ }
60
+ )
61
+
62
+ DrawerGrabber.displayName = "Drawer.Grabber"