@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butternutbox/pawprint-native",
3
- "version": "0.16.1",
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.3.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
- () => ({ isOpen, modalVisible, openDrawer, closeDrawer, onExitComplete }),
97
- [isOpen, modalVisible, openDrawer, closeDrawer, onExitComplete]
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 ref={ref} {...rest}>
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 } = React.useContext(DrawerContext)
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, { useCallback } from "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 { useDrawerMeasureContext } from "./DrawerMeasureContext"
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. Grows to fill available space
38
- * between the header and footer.
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, onContentSizeChange, ...props }, ref) => {
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 measureContext = useDrawerMeasureContext()
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
- const bodyMaxHeight = windowHeight - 300
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 is given an explicit height (needed so DrawerBody's `flex: 1`
78
- * ScrollView has a bounded parent and doesn't collapse). That height re-fits
79
- * when the body content changes: DrawerBody reports its natural scroll-content
80
- * height, and the panel is sized to `chrome + min(content, bodyMax)` (capped at
81
- * 90% of the window). `chrome` (header + footer + padding + borders) is
82
- * measured once from the first, unbounded layout and is invariant thereafter,
83
- * so there is no measurement feedback loop.
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
- // Tallest the body content grows before it scrolls. Kept in sync with the
112
- // `maxHeight` DrawerBody sets on its own ScrollView, so the panel's re-fit
113
- // cap and the body's scroll cap agree.
114
- const bodyMaxHeight = windowHeight - 300
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
- // Explicit panel height (capped at maxHeight). Applied to the wrapper +
131
- // flex:1 on the panel so DrawerBody's ScrollView gets a bounded container
132
- // and can scroll correctly.
133
- const [panelHeight, setPanelHeight] = useState(0)
134
-
135
- // Re-fit inputs. `chrome` is the invariant non-body height (header + footer
136
- // + padding + borders); `bodyContent` is the body's natural content height;
137
- // `firstNatural` is the panel's natural height at the first unbounded layout
138
- // (before an explicit height is applied), used to derive chrome exactly.
139
- const chromeRef = useRef(0)
140
- const bodyContentRef = useRef(0)
141
- const firstNaturalRef = useRef(0)
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 > 0 && panelHeightRef.current > maxHeight) {
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
- // Desired panel height = chrome + body content (clamped to bodyMax, then
207
- // maxHeight). Only sets state when it actually changes, to avoid loops.
208
- const applyRefit = useCallback(() => {
209
- const chrome = chromeRef.current
210
- const content = bodyContentRef.current
211
- if (chrome <= 0 || content <= 0) return
212
- const desired = Math.min(
213
- chrome + Math.min(content, bodyMaxHeight),
214
- maxHeight
215
- )
216
- if (Math.abs(desired - panelHeightRef.current) > 1) {
217
- panelHeightRef.current = desired
218
- setPanelHeight(desired)
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
- }, [bodyMaxHeight, maxHeight])
221
-
222
- // Lock chrome once, from the first unbounded layout: there the panel wraps
223
- // its content, so natural height = chrome + min(content, bodyMax). chrome is
224
- // layout-invariant, so it never needs re-deriving (which is what made the
225
- // earlier frame-subtraction approach fragile).
226
- const lockChrome = useCallback(() => {
227
- if (chromeRef.current > 0) return
228
- const natural = firstNaturalRef.current
229
- const content = bodyContentRef.current
230
- if (natural <= 0 || content <= 0) return
231
- chromeRef.current = natural - Math.min(content, bodyMaxHeight)
232
- }, [bodyMaxHeight])
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) return
237
- bodyContentRef.current = height
238
- lockChrome()
239
- applyRefit()
251
+ if (height <= 0 || Math.abs(height - footerHeightRef.current) < 1)
252
+ return
253
+ footerHeightRef.current = height
254
+ applyBodyMax()
240
255
  },
241
- [lockChrome, applyRefit]
256
+ [applyBodyMax]
242
257
  )
243
258
 
244
259
  const measureContextValue = useMemo(
245
- () => ({ setBodyContentHeight }),
246
- [setBodyContentHeight]
260
+ () => ({ setHeaderHeight, setFooterHeight }),
261
+ [setHeaderHeight, setFooterHeight]
247
262
  )
248
263
 
249
264
  const onLayout = useCallback(
250
265
  ({ nativeEvent }: { nativeEvent: { layout: { height: number } } }) => {
251
- const h = nativeEvent.layout.height
252
- const capped = Math.min(h, maxHeight)
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
- // First measurement only: the panel is still wrapping its content
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, lockChrome, applyRefit]
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
- panelFlex={panelHeight > 0 ? 1 : undefined}
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
- {children}
395
+ <DrawerBodyMaxContext.Provider value={bodyMax}>
396
+ {children}
397
+ </DrawerBodyMaxContext.Provider>
392
398
  </StyledPanel>
393
399
  </Animated.View>
394
400
  </DrawerDragContext.Provider>