@butternutbox/pawprint-native 0.16.1 → 0.18.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/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +22 -0
- package/dist/index.cjs +463 -212
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -2
- package/dist/index.d.ts +25 -2
- package/dist/index.js +464 -213
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/atoms/Button/Button.stories.tsx +44 -0
- package/src/components/atoms/Icon/Icon.tsx +1 -1
- package/src/components/molecules/Drawer/Drawer.tsx +84 -4
- package/src/components/molecules/Drawer/DrawerBody.tsx +16 -23
- package/src/components/molecules/Drawer/DrawerBodyMaxContext.ts +21 -0
- package/src/components/molecules/Drawer/DrawerContent.tsx +98 -92
- package/src/components/molecules/Drawer/DrawerContext.ts +9 -0
- package/src/components/molecules/Drawer/DrawerFooter.tsx +17 -3
- package/src/components/molecules/Drawer/DrawerHeader.tsx +25 -3
- package/src/components/molecules/Drawer/DrawerMeasureContext.ts +9 -6
- package/src/components/molecules/Drawer/DrawerOverlay.tsx +4 -1
- package/src/components/molecules/Drawer/DrawerOverlayContext.ts +21 -0
- package/src/components/molecules/Drawer/index.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@butternutbox/pawprint-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ButternutBox Pawprint Design System - React Native Components",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"test": "vitest run"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@butternutbox/pawprint-tokens": "^0.
|
|
25
|
+
"@butternutbox/pawprint-tokens": "^0.4.0",
|
|
26
26
|
"@emotion/native": "^11.11.0",
|
|
27
27
|
"@emotion/react": "^11.14.0",
|
|
28
28
|
"@rn-primitives/avatar": "^1.4.0",
|
|
@@ -3,6 +3,11 @@ import { View, StyleSheet } from "react-native"
|
|
|
3
3
|
import { Button } from "./Button"
|
|
4
4
|
import type { ButtonProps } from "./Button"
|
|
5
5
|
import { Typography } from "../Typography"
|
|
6
|
+
import { Icon } from "../Icon"
|
|
7
|
+
import { Check } from "@butternutbox/pawprint-icons/core"
|
|
8
|
+
import { IosShare, Whatsapp } from "@butternutbox/pawprint-icons/marketing"
|
|
9
|
+
|
|
10
|
+
import { KeyboardDoubleArrowDown } from "@butternutbox/pawprint-icons/core"
|
|
6
11
|
|
|
7
12
|
export default {
|
|
8
13
|
title: "Atoms/Button",
|
|
@@ -118,6 +123,45 @@ export const AllStates = () => (
|
|
|
118
123
|
</View>
|
|
119
124
|
)
|
|
120
125
|
|
|
126
|
+
export const WithStartIcon = () => (
|
|
127
|
+
<View style={styles.column}>
|
|
128
|
+
{(["sm", "md", "lg"] as const).map((size) => (
|
|
129
|
+
<View key={size} style={styles.section}>
|
|
130
|
+
<Typography size="sm" weight="semiBold" color="tertiary">
|
|
131
|
+
{size}
|
|
132
|
+
</Typography>
|
|
133
|
+
<View style={styles.row}>
|
|
134
|
+
<Button
|
|
135
|
+
size={size}
|
|
136
|
+
startIcon={<Icon icon={KeyboardDoubleArrowDown} colour="primary" />}
|
|
137
|
+
>
|
|
138
|
+
Add
|
|
139
|
+
</Button>
|
|
140
|
+
<Button
|
|
141
|
+
size={size}
|
|
142
|
+
startIcon={<Icon icon={IosShare} colour="success" />}
|
|
143
|
+
>
|
|
144
|
+
Search
|
|
145
|
+
</Button>
|
|
146
|
+
<Button
|
|
147
|
+
size={size}
|
|
148
|
+
startIcon={<Icon icon={Whatsapp} colour="warning" />}
|
|
149
|
+
>
|
|
150
|
+
Settings
|
|
151
|
+
</Button>
|
|
152
|
+
<Button
|
|
153
|
+
size={size}
|
|
154
|
+
startIcon={<Icon icon={Check} colour="error" />}
|
|
155
|
+
colour="secondary"
|
|
156
|
+
>
|
|
157
|
+
Confirm
|
|
158
|
+
</Button>
|
|
159
|
+
</View>
|
|
160
|
+
</View>
|
|
161
|
+
))}
|
|
162
|
+
</View>
|
|
163
|
+
)
|
|
164
|
+
|
|
121
165
|
export const FullWidth = () => (
|
|
122
166
|
<View style={styles.column}>
|
|
123
167
|
{(["filled", "outlined", "text"] as const).map((variant) => (
|
|
@@ -4,7 +4,7 @@ import styled from "@emotion/native"
|
|
|
4
4
|
import { useTheme } from "@emotion/react"
|
|
5
5
|
|
|
6
6
|
type IconCategory = "core" | "marketing" | "payments" | "flags"
|
|
7
|
-
type IconSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"
|
|
7
|
+
type IconSize = "xxs" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl"
|
|
8
8
|
type IconColour =
|
|
9
9
|
| "primary"
|
|
10
10
|
| "secondary"
|
|
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
|
2
2
|
import { Modal, StyleSheet, View, type ViewProps } from "react-native"
|
|
3
3
|
import { Slot } from "@rn-primitives/slot"
|
|
4
4
|
import { DrawerContext } from "./DrawerContext"
|
|
5
|
+
import { DrawerOverlayContext, useDrawerOverlay } from "./DrawerOverlayContext"
|
|
5
6
|
import { DrawerOverlay } from "./DrawerOverlay"
|
|
6
7
|
import { DrawerContent } from "./DrawerContent"
|
|
7
8
|
import { DrawerGrabber } from "./DrawerGrabber"
|
|
@@ -18,6 +19,16 @@ type DrawerRootOwnProps = {
|
|
|
18
19
|
open?: boolean
|
|
19
20
|
defaultOpen?: boolean
|
|
20
21
|
onOpenChange?: (open: boolean) => void
|
|
22
|
+
/**
|
|
23
|
+
* Whether the drawer shows a dimming backdrop and presents as a blocking
|
|
24
|
+
* modal (default `true`). Set to `false` for a backdrop-less, pass-through
|
|
25
|
+
* drawer: it renders inline instead of in a native `Modal`, omits the
|
|
26
|
+
* overlay, and lets touches outside the panel reach the content behind it
|
|
27
|
+
* (so e.g. a calendar stays interactive while the sheet is open). In this
|
|
28
|
+
* mode the root fills the screen with `pointerEvents="box-none"` so the
|
|
29
|
+
* panel's bottom-anchored positioning still resolves against the full screen.
|
|
30
|
+
*/
|
|
31
|
+
backdrop?: boolean
|
|
21
32
|
children: React.ReactNode
|
|
22
33
|
}
|
|
23
34
|
|
|
@@ -59,7 +70,10 @@ const DrawerRoot = React.forwardRef<View, DrawerProps>(
|
|
|
59
70
|
open: controlledOpen,
|
|
60
71
|
defaultOpen = false,
|
|
61
72
|
onOpenChange,
|
|
73
|
+
backdrop = true,
|
|
62
74
|
children,
|
|
75
|
+
style,
|
|
76
|
+
pointerEvents,
|
|
63
77
|
...rest
|
|
64
78
|
},
|
|
65
79
|
ref
|
|
@@ -93,13 +107,32 @@ const DrawerRoot = React.forwardRef<View, DrawerProps>(
|
|
|
93
107
|
}, [])
|
|
94
108
|
|
|
95
109
|
const ctx = useMemo(
|
|
96
|
-
() => ({
|
|
97
|
-
|
|
110
|
+
() => ({
|
|
111
|
+
isOpen,
|
|
112
|
+
modalVisible,
|
|
113
|
+
backdrop,
|
|
114
|
+
openDrawer,
|
|
115
|
+
closeDrawer,
|
|
116
|
+
onExitComplete
|
|
117
|
+
}),
|
|
118
|
+
[isOpen, modalVisible, backdrop, openDrawer, closeDrawer, onExitComplete]
|
|
98
119
|
)
|
|
99
120
|
|
|
100
121
|
return (
|
|
101
122
|
<DrawerContext.Provider value={ctx}>
|
|
102
|
-
<View
|
|
123
|
+
<View
|
|
124
|
+
ref={ref}
|
|
125
|
+
// A backdrop-less drawer renders inline, so its root must fill the
|
|
126
|
+
// screen (and stay touch-transparent) for the panel's bottom-anchored
|
|
127
|
+
// positioning to resolve against the full screen. A normal modal
|
|
128
|
+
// drawer keeps its inline, content-sized wrapper (the panel lives in a
|
|
129
|
+
// separate native Modal window).
|
|
130
|
+
style={backdrop ? style : [StyleSheet.absoluteFill, style]}
|
|
131
|
+
pointerEvents={
|
|
132
|
+
backdrop ? pointerEvents : (pointerEvents ?? "box-none")
|
|
133
|
+
}
|
|
134
|
+
{...rest}
|
|
135
|
+
>
|
|
103
136
|
{children}
|
|
104
137
|
</View>
|
|
105
138
|
</DrawerContext.Provider>
|
|
@@ -148,10 +181,24 @@ export type DrawerPortalProps = {
|
|
|
148
181
|
* @param topContent - *(optional)* Additional content rendered on top of the drawer
|
|
149
182
|
*/
|
|
150
183
|
const DrawerPortal = ({ children, topContent }: DrawerPortalProps) => {
|
|
151
|
-
const { modalVisible, closeDrawer } =
|
|
184
|
+
const { modalVisible, closeDrawer, backdrop } =
|
|
185
|
+
React.useContext(DrawerContext)
|
|
186
|
+
const OverlayComponent = useDrawerOverlay()
|
|
152
187
|
|
|
153
188
|
if (!modalVisible) return null
|
|
154
189
|
|
|
190
|
+
// Backdrop-less drawers render inline (no native Modal) inside the root's
|
|
191
|
+
// absolute-fill / box-none layer, so touches outside the panel pass through
|
|
192
|
+
// to the content behind and app-level overlays (toasts) stay above the panel.
|
|
193
|
+
if (!backdrop) {
|
|
194
|
+
return (
|
|
195
|
+
<>
|
|
196
|
+
{children}
|
|
197
|
+
{topContent}
|
|
198
|
+
</>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
155
202
|
return (
|
|
156
203
|
<Modal
|
|
157
204
|
visible
|
|
@@ -163,6 +210,9 @@ const DrawerPortal = ({ children, topContent }: DrawerPortalProps) => {
|
|
|
163
210
|
<View style={styles.modalContainer}>
|
|
164
211
|
{children}
|
|
165
212
|
{topContent}
|
|
213
|
+
{/* App-provided overlay (e.g. toasts) rendered inside the Modal so it
|
|
214
|
+
paints above the drawer instead of behind this native window. */}
|
|
215
|
+
{OverlayComponent ? <OverlayComponent /> : null}
|
|
166
216
|
</View>
|
|
167
217
|
</Modal>
|
|
168
218
|
)
|
|
@@ -170,12 +220,42 @@ const DrawerPortal = ({ children, topContent }: DrawerPortalProps) => {
|
|
|
170
220
|
|
|
171
221
|
DrawerPortal.displayName = "Drawer.Portal"
|
|
172
222
|
|
|
223
|
+
// ─── Overlay provider ─────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
export type DrawerOverlayProviderProps = {
|
|
226
|
+
/**
|
|
227
|
+
* Component rendered on top of every modal drawer, inside the drawer's native
|
|
228
|
+
* Modal window (e.g. an app's toast/notification stack). Pass `null` to render
|
|
229
|
+
* nothing extra.
|
|
230
|
+
*/
|
|
231
|
+
component: React.ComponentType | null
|
|
232
|
+
children: React.ReactNode
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Provides an app-level overlay component that every modal `Drawer` renders
|
|
237
|
+
* inside its native Modal, so app-root overlays (e.g. toasts) surface above the
|
|
238
|
+
* drawer rather than behind its OS window. Mount once, high in the app tree,
|
|
239
|
+
* around everything that opens drawers.
|
|
240
|
+
*/
|
|
241
|
+
const DrawerOverlayProvider = ({
|
|
242
|
+
component,
|
|
243
|
+
children
|
|
244
|
+
}: DrawerOverlayProviderProps): React.ReactElement => (
|
|
245
|
+
<DrawerOverlayContext.Provider value={component}>
|
|
246
|
+
{children}
|
|
247
|
+
</DrawerOverlayContext.Provider>
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
DrawerOverlayProvider.displayName = "Drawer.OverlayProvider"
|
|
251
|
+
|
|
173
252
|
// ─── Compound export ──────────────────────────────────────────────────────────
|
|
174
253
|
|
|
175
254
|
export const Drawer = Object.assign(DrawerRoot, {
|
|
176
255
|
Root: DrawerRoot,
|
|
177
256
|
Trigger: DrawerTrigger,
|
|
178
257
|
Portal: DrawerPortal,
|
|
258
|
+
OverlayProvider: DrawerOverlayProvider,
|
|
179
259
|
Overlay: DrawerOverlay,
|
|
180
260
|
Content: DrawerContent,
|
|
181
261
|
Grabber: DrawerGrabber,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from "react"
|
|
2
2
|
import { ScrollView, ScrollViewProps, useWindowDimensions } from "react-native"
|
|
3
3
|
import styled from "@emotion/native"
|
|
4
4
|
import { useTheme } from "@emotion/react"
|
|
5
5
|
import { useDrawerHeaderContext } from "./DrawerHeaderContext"
|
|
6
6
|
import { useDrawerFooterContext } from "./DrawerFooterContext"
|
|
7
|
-
import {
|
|
7
|
+
import { useDrawerBodyMax } from "./DrawerBodyMaxContext"
|
|
8
8
|
|
|
9
9
|
export type DrawerBodyProps = ScrollViewProps
|
|
10
10
|
|
|
@@ -13,55 +13,50 @@ const parseTokenValue = (value: string): number => parseFloat(value)
|
|
|
13
13
|
const StyledScrollView = styled(ScrollView)<{
|
|
14
14
|
bodyPaddingHorizontal: number
|
|
15
15
|
bodyPaddingRight: number
|
|
16
|
-
bodyGap: number
|
|
17
16
|
bodyMaxHeight: number
|
|
18
17
|
bodyPaddingBottom: number
|
|
19
18
|
}>(
|
|
20
19
|
({
|
|
21
20
|
bodyPaddingHorizontal,
|
|
22
21
|
bodyPaddingRight,
|
|
23
|
-
bodyGap,
|
|
24
22
|
bodyMaxHeight,
|
|
25
23
|
bodyPaddingBottom
|
|
26
24
|
}) => ({
|
|
27
|
-
flex: 1
|
|
25
|
+
// No `flex: 1`: the ScrollView self-sizes to its content (up to
|
|
26
|
+
// `maxHeight`), so the panel wraps tight around short content and scrolls
|
|
27
|
+
// only when content exceeds the chrome-aware cap.
|
|
28
|
+
flexGrow: 0,
|
|
29
|
+
flexShrink: 1,
|
|
28
30
|
paddingLeft: bodyPaddingHorizontal,
|
|
29
31
|
paddingRight: bodyPaddingRight,
|
|
30
|
-
gap: bodyGap,
|
|
31
32
|
maxHeight: bodyMaxHeight,
|
|
32
33
|
paddingBottom: bodyPaddingBottom
|
|
33
34
|
})
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
|
-
* Scrollable content area of the drawer.
|
|
38
|
-
*
|
|
38
|
+
* Scrollable content area of the drawer. Sizes to its content up to a
|
|
39
|
+
* chrome-aware `maxHeight`, scrolling beyond that.
|
|
39
40
|
*/
|
|
40
41
|
export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
|
|
41
|
-
({ children, contentContainerStyle,
|
|
42
|
+
({ children, contentContainerStyle, ...props }, ref) => {
|
|
42
43
|
const theme = useTheme()
|
|
43
44
|
const { spacing } = theme.tokens.components.drawer
|
|
44
45
|
const { buttons } = theme.tokens.components
|
|
45
46
|
const { content } = spacing
|
|
46
47
|
const headerContext = useDrawerHeaderContext()
|
|
47
48
|
const footerContext = useDrawerFooterContext()
|
|
48
|
-
const
|
|
49
|
+
const providedMaxHeight = useDrawerBodyMax()
|
|
49
50
|
const { height: windowHeight } = useWindowDimensions()
|
|
50
51
|
|
|
51
|
-
// Report the scroll content's natural height so DrawerContent can re-fit
|
|
52
|
-
// the panel when the body grows or shrinks.
|
|
53
|
-
const handleContentSizeChange = useCallback(
|
|
54
|
-
(width: number, height: number): void => {
|
|
55
|
-
measureContext?.setBodyContentHeight(height)
|
|
56
|
-
onContentSizeChange?.(width, height)
|
|
57
|
-
},
|
|
58
|
-
[measureContext, onContentSizeChange]
|
|
59
|
-
)
|
|
60
|
-
|
|
61
52
|
const gap = parseTokenValue(content.slot.gap)
|
|
62
53
|
const horizontalPadding = parseTokenValue(content.slot.horizontalPadding)
|
|
63
54
|
const topPadding = parseTokenValue(content.slot.verticalPadding)
|
|
64
|
-
|
|
55
|
+
|
|
56
|
+
// Prefer the chrome-aware cap from DrawerContent (90%·window minus the
|
|
57
|
+
// measured header/footer). Fall back to a window-based heuristic until the
|
|
58
|
+
// chrome has been measured, so the body still scrolls on the first layout.
|
|
59
|
+
const bodyMaxHeight = providedMaxHeight ?? windowHeight - 300
|
|
65
60
|
|
|
66
61
|
// When there's no header the close button floats in the top-right corner.
|
|
67
62
|
// Reserve space on the right so body content doesn't slide under it.
|
|
@@ -80,10 +75,8 @@ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
|
|
|
80
75
|
ref={ref}
|
|
81
76
|
bodyPaddingHorizontal={horizontalPadding}
|
|
82
77
|
bodyPaddingRight={paddingRight}
|
|
83
|
-
bodyGap={gap}
|
|
84
78
|
bodyMaxHeight={bodyMaxHeight}
|
|
85
79
|
bodyPaddingBottom={bodyPaddingBottom}
|
|
86
|
-
onContentSizeChange={handleContentSizeChange}
|
|
87
80
|
contentContainerStyle={[
|
|
88
81
|
{
|
|
89
82
|
gap,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createContext, useContext } from "react"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maximum height the body's ScrollView may occupy, computed by `DrawerContent`
|
|
5
|
+
* as the panel's height cap (90% of the window) minus the *measured* header and
|
|
6
|
+
* footer chrome.
|
|
7
|
+
*
|
|
8
|
+
* DrawerBody used to cap itself with a fixed `windowHeight - 300` heuristic that
|
|
9
|
+
* assumed a roughly constant chrome. When the footer (or header) is tall, that
|
|
10
|
+
* heuristic leaves the body too big, so `header + body + footer` exceeds the
|
|
11
|
+
* capped panel height and the footer — with its action button — is pushed past
|
|
12
|
+
* the panel's bottom edge and off-screen. Deriving the cap from the real chrome
|
|
13
|
+
* keeps the three sections inside the panel, so the footer is never clipped.
|
|
14
|
+
*
|
|
15
|
+
* `null` until the chrome has been measured; DrawerBody falls back to its own
|
|
16
|
+
* window-based heuristic in the meantime.
|
|
17
|
+
*/
|
|
18
|
+
export const DrawerBodyMaxContext = createContext<number | null>(null)
|
|
19
|
+
|
|
20
|
+
export const useDrawerBodyMax = (): number | null =>
|
|
21
|
+
useContext(DrawerBodyMaxContext)
|
|
@@ -13,6 +13,7 @@ import { useTheme } from "@emotion/react"
|
|
|
13
13
|
import { DrawerDragContext } from "./DrawerDragContext"
|
|
14
14
|
import { DrawerMeasureContext } from "./DrawerMeasureContext"
|
|
15
15
|
import { useDrawerContext } from "./DrawerContext"
|
|
16
|
+
import { DrawerBodyMaxContext } from "./DrawerBodyMaxContext"
|
|
16
17
|
import { DrawerFooterContext } from "./DrawerFooterContext"
|
|
17
18
|
import {
|
|
18
19
|
DrawerHeaderContext,
|
|
@@ -31,28 +32,28 @@ const StyledPanel = styled(View)<{
|
|
|
31
32
|
panelBorderRadius: number
|
|
32
33
|
panelBgColor: string
|
|
33
34
|
panelMaxWidth: number
|
|
35
|
+
panelMaxHeight: number
|
|
34
36
|
panelBorderWidth: number
|
|
35
37
|
panelBorderColor: string
|
|
36
38
|
panelShadowColor: string
|
|
37
39
|
panelShadowOffsetY: number
|
|
38
40
|
panelShadowBlur: number
|
|
39
|
-
panelFlex?: number
|
|
40
41
|
}>(
|
|
41
42
|
({
|
|
42
43
|
panelBorderRadius,
|
|
43
44
|
panelBgColor,
|
|
44
45
|
panelMaxWidth,
|
|
46
|
+
panelMaxHeight,
|
|
45
47
|
panelBorderWidth,
|
|
46
48
|
panelBorderColor,
|
|
47
49
|
panelShadowColor,
|
|
48
50
|
panelShadowOffsetY,
|
|
49
|
-
panelShadowBlur
|
|
50
|
-
panelFlex
|
|
51
|
+
panelShadowBlur
|
|
51
52
|
}) => ({
|
|
52
53
|
width: "100%",
|
|
53
54
|
maxWidth: panelMaxWidth,
|
|
55
|
+
maxHeight: panelMaxHeight,
|
|
54
56
|
alignSelf: "center",
|
|
55
|
-
...(panelFlex != null ? { flex: panelFlex } : undefined),
|
|
56
57
|
borderTopLeftRadius: panelBorderRadius,
|
|
57
58
|
borderTopRightRadius: panelBorderRadius,
|
|
58
59
|
borderTopWidth: panelBorderWidth,
|
|
@@ -74,13 +75,13 @@ const StyledPanel = styled(View)<{
|
|
|
74
75
|
* the screen. Uses React Native `Animated` for entrance/exit and `PanResponder`
|
|
75
76
|
* on the grabber for drag-to-dismiss.
|
|
76
77
|
*
|
|
77
|
-
* The panel
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
78
|
+
* The panel **wraps its content naturally** (no explicit height) and is capped
|
|
79
|
+
* at 90% of the window via `maxHeight`. The body's `ScrollView` self-sizes to
|
|
80
|
+
* its content up to a `maxHeight` of `90%·window − chrome`, where `chrome`
|
|
81
|
+
* (header + footer + border) is measured *directly* from the header and footer
|
|
82
|
+
* sections. So short content lets the panel wrap tight (no gap), and tall
|
|
83
|
+
* content scrolls inside the cap (no clipped footer) — without any
|
|
84
|
+
* explicit-height / content-measurement feedback loop.
|
|
84
85
|
*
|
|
85
86
|
* Must be rendered inside `Drawer.Portal`.
|
|
86
87
|
*
|
|
@@ -108,10 +109,11 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
|
|
|
108
109
|
|
|
109
110
|
const { height: windowHeight } = useWindowDimensions()
|
|
110
111
|
const maxHeight = windowHeight * 0.9
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
const
|
|
112
|
+
|
|
113
|
+
// Constant parts of the chrome that aren't a measured section: the panel's
|
|
114
|
+
// top border, and (when there's no footer) the body's own bottom padding.
|
|
115
|
+
const topBorder = parseTokenValue(dimensions.borderWidth.sm)
|
|
116
|
+
const noFooterBodyPadding = parseTokenValue(dimensions.spacing["2xl"])
|
|
115
117
|
|
|
116
118
|
// Keep a ref so the PanResponder (created once) always calls the latest
|
|
117
119
|
// closeDrawer without becoming a stale closure.
|
|
@@ -127,18 +129,22 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
|
|
|
127
129
|
const isOpenRef = useRef(isOpen)
|
|
128
130
|
const activeAnim = useRef<Animated.CompositeAnimation | null>(null)
|
|
129
131
|
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
132
|
+
// Max height the body's ScrollView may occupy: the 90%-of-window cap minus
|
|
133
|
+
// the measured header/footer chrome. Provided down to DrawerBody as the
|
|
134
|
+
// ScrollView's `maxHeight`, so tall content scrolls within the cap and short
|
|
135
|
+
// content lets the panel wrap tight. Null until the chrome is measured, in
|
|
136
|
+
// which case DrawerBody falls back to its own window-based heuristic.
|
|
137
|
+
const [bodyMax, setBodyMax] = useState<number | null>(null)
|
|
138
|
+
const bodyMaxRef = useRef<number | null>(null)
|
|
139
|
+
|
|
140
|
+
// The header/footer heights are measured directly by those sections and
|
|
141
|
+
// reported up, so the body's chrome-aware `maxHeight` stays correct.
|
|
142
|
+
// `hasHeader`/`hasFooter` (set during render) tell the calc whether to wait
|
|
143
|
+
// for those measurements.
|
|
144
|
+
const headerHeightRef = useRef(0)
|
|
145
|
+
const footerHeightRef = useRef(0)
|
|
146
|
+
const hasHeaderRef = useRef(false)
|
|
147
|
+
const hasFooterRef = useRef(false)
|
|
142
148
|
|
|
143
149
|
useEffect(() => {
|
|
144
150
|
const id = translateY.addListener(({ value }) => {
|
|
@@ -148,10 +154,10 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
|
|
|
148
154
|
}, [translateY])
|
|
149
155
|
|
|
150
156
|
// Clamp the cached panel height when the window shrinks (e.g. orientation
|
|
151
|
-
// change) so the entry animation never starts from below the screen.
|
|
157
|
+
// change) so the entry animation never starts from below the screen. This
|
|
158
|
+
// is animation-only bookkeeping — the panel itself sizes from its content.
|
|
152
159
|
useEffect(() => {
|
|
153
|
-
if (panelHeightRef.current >
|
|
154
|
-
setPanelHeight(maxHeight)
|
|
160
|
+
if (panelHeightRef.current > maxHeight) {
|
|
155
161
|
panelHeightRef.current = maxHeight
|
|
156
162
|
}
|
|
157
163
|
}, [maxHeight])
|
|
@@ -203,79 +209,73 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
|
|
|
203
209
|
}
|
|
204
210
|
}, [isOpen, runEntry, runExit])
|
|
205
211
|
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
212
|
+
// Non-body chrome, measured directly: header + footer heights + the panel's
|
|
213
|
+
// top border, plus (when there's no footer) the body's own bottom padding.
|
|
214
|
+
// Returns 0 until every *present* section has reported, so the re-fit waits
|
|
215
|
+
// rather than sizing the panel from a partial chrome.
|
|
216
|
+
const measureChrome = useCallback((): number => {
|
|
217
|
+
if (hasHeaderRef.current && headerHeightRef.current <= 0) return 0
|
|
218
|
+
if (hasFooterRef.current && footerHeightRef.current <= 0) return 0
|
|
219
|
+
const header = hasHeaderRef.current ? headerHeightRef.current : 0
|
|
220
|
+
const footer = hasFooterRef.current ? footerHeightRef.current : 0
|
|
221
|
+
const bodyBottomPad = hasFooterRef.current ? 0 : noFooterBodyPadding
|
|
222
|
+
return header + footer + topBorder + bodyBottomPad
|
|
223
|
+
}, [noFooterBodyPadding, topBorder])
|
|
224
|
+
|
|
225
|
+
// Recompute the body's scroll cap from the measured chrome: the body may
|
|
226
|
+
// occupy at most `maxHeight - chrome`, so `header + body + footer` always
|
|
227
|
+
// stays within the 90%-of-window cap and the footer can't be pushed
|
|
228
|
+
// off-screen. Only sets state when the value actually changes.
|
|
229
|
+
const applyBodyMax = useCallback(() => {
|
|
230
|
+
const chrome = measureChrome()
|
|
231
|
+
if (chrome <= 0) return
|
|
232
|
+
const availableBody = Math.max(0, maxHeight - chrome)
|
|
233
|
+
if (Math.abs(availableBody - (bodyMaxRef.current ?? -1)) > 1) {
|
|
234
|
+
bodyMaxRef.current = availableBody
|
|
235
|
+
setBodyMax(availableBody)
|
|
219
236
|
}
|
|
220
|
-
}, [
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const setBodyContentHeight = useCallback(
|
|
237
|
+
}, [measureChrome, maxHeight])
|
|
238
|
+
|
|
239
|
+
const setHeaderHeight = useCallback(
|
|
240
|
+
(height: number) => {
|
|
241
|
+
if (height <= 0 || Math.abs(height - headerHeightRef.current) < 1)
|
|
242
|
+
return
|
|
243
|
+
headerHeightRef.current = height
|
|
244
|
+
applyBodyMax()
|
|
245
|
+
},
|
|
246
|
+
[applyBodyMax]
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const setFooterHeight = useCallback(
|
|
235
250
|
(height: number) => {
|
|
236
|
-
if (height <= 0)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
251
|
+
if (height <= 0 || Math.abs(height - footerHeightRef.current) < 1)
|
|
252
|
+
return
|
|
253
|
+
footerHeightRef.current = height
|
|
254
|
+
applyBodyMax()
|
|
240
255
|
},
|
|
241
|
-
[
|
|
256
|
+
[applyBodyMax]
|
|
242
257
|
)
|
|
243
258
|
|
|
244
259
|
const measureContextValue = useMemo(
|
|
245
|
-
() => ({
|
|
246
|
-
[
|
|
260
|
+
() => ({ setHeaderHeight, setFooterHeight }),
|
|
261
|
+
[setHeaderHeight, setFooterHeight]
|
|
247
262
|
)
|
|
248
263
|
|
|
249
264
|
const onLayout = useCallback(
|
|
250
265
|
({ nativeEvent }: { nativeEvent: { layout: { height: number } } }) => {
|
|
251
|
-
|
|
252
|
-
|
|
266
|
+
// The panel wraps its content, so its measured height is the true panel
|
|
267
|
+
// height. Cache it for the slide-in/out + drag-dismiss animations (which
|
|
268
|
+
// translate the panel by its own height); it does *not* feed back into
|
|
269
|
+
// sizing, so there's no measurement loop. Runs the entry animation the
|
|
270
|
+
// first time we have a height while open.
|
|
271
|
+
const capped = Math.min(nativeEvent.layout.height, maxHeight)
|
|
253
272
|
if (capped <= 0) return
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
// (no explicit height yet), so `h` is the natural height. Capture it to
|
|
257
|
-
// derive chrome, fix the panel height, and run the entry animation.
|
|
258
|
-
// Afterwards the panel echoes its fixed height, so re-fitting is driven
|
|
259
|
-
// solely by the body's content size (setBodyContentHeight).
|
|
260
|
-
if (entryRanRef.current) return
|
|
261
|
-
|
|
262
|
-
if (chromeRef.current <= 0) {
|
|
263
|
-
firstNaturalRef.current = h
|
|
264
|
-
lockChrome()
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (capped !== panelHeightRef.current) {
|
|
268
|
-
panelHeightRef.current = capped
|
|
269
|
-
setPanelHeight(capped)
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (isOpenRef.current) {
|
|
273
|
+
panelHeightRef.current = capped
|
|
274
|
+
if (!entryRanRef.current && isOpenRef.current) {
|
|
273
275
|
runEntry()
|
|
274
276
|
}
|
|
275
|
-
|
|
276
|
-
applyRefit()
|
|
277
277
|
},
|
|
278
|
-
[runEntry, maxHeight
|
|
278
|
+
[runEntry, maxHeight]
|
|
279
279
|
)
|
|
280
280
|
|
|
281
281
|
const panResponder = useRef(
|
|
@@ -350,6 +350,11 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
|
|
|
350
350
|
(child.type as { displayName?: string }).displayName === "Drawer.Header"
|
|
351
351
|
) as React.ReactElement<{ variant?: DrawerHeaderVariant }> | undefined
|
|
352
352
|
|
|
353
|
+
// Record which sections are present so the re-fit knows whether to wait for
|
|
354
|
+
// their measured heights before sizing the panel.
|
|
355
|
+
hasHeaderRef.current = headerChild != null
|
|
356
|
+
hasFooterRef.current = hasFooter
|
|
357
|
+
|
|
353
358
|
const headerContextValue =
|
|
354
359
|
headerChild != null
|
|
355
360
|
? {
|
|
@@ -367,7 +372,6 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
|
|
|
367
372
|
<Animated.View
|
|
368
373
|
style={[
|
|
369
374
|
styles.animatedWrapper,
|
|
370
|
-
panelHeight > 0 ? { height: panelHeight } : undefined,
|
|
371
375
|
{ transform: [{ translateY }] }
|
|
372
376
|
]}
|
|
373
377
|
>
|
|
@@ -377,7 +381,7 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
|
|
|
377
381
|
panelBorderRadius={parseTokenValue(drawer.borderRadius.top)}
|
|
378
382
|
panelBgColor={colour.background.container.default}
|
|
379
383
|
panelMaxWidth={parseTokenValue(drawer.size.maxWidth)}
|
|
380
|
-
|
|
384
|
+
panelMaxHeight={maxHeight}
|
|
381
385
|
panelBorderWidth={parseTokenValue(dimensions.borderWidth.sm)}
|
|
382
386
|
panelBorderColor={colour.border.default}
|
|
383
387
|
panelShadowColor={modal.shadow.color}
|
|
@@ -388,7 +392,9 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
|
|
|
388
392
|
accessibilityViewIsModal
|
|
389
393
|
{...props}
|
|
390
394
|
>
|
|
391
|
-
{
|
|
395
|
+
<DrawerBodyMaxContext.Provider value={bodyMax}>
|
|
396
|
+
{children}
|
|
397
|
+
</DrawerBodyMaxContext.Provider>
|
|
392
398
|
</StyledPanel>
|
|
393
399
|
</Animated.View>
|
|
394
400
|
</DrawerDragContext.Provider>
|