@butternutbox/pawprint-native 0.10.9 → 0.10.11

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.10.9",
3
+ "version": "0.10.11",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -1,9 +1,10 @@
1
- import React from "react"
1
+ import React, { useCallback } 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
8
 
8
9
  export type DrawerBodyProps = ScrollViewProps
9
10
 
@@ -37,15 +38,26 @@ const StyledScrollView = styled(ScrollView)<{
37
38
  * between the header and footer.
38
39
  */
39
40
  export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
40
- ({ children, contentContainerStyle, ...props }, ref) => {
41
+ ({ children, contentContainerStyle, onContentSizeChange, ...props }, ref) => {
41
42
  const theme = useTheme()
42
43
  const { spacing } = theme.tokens.components.drawer
43
44
  const { buttons } = theme.tokens.components
44
45
  const { content } = spacing
45
46
  const headerContext = useDrawerHeaderContext()
46
47
  const footerContext = useDrawerFooterContext()
48
+ const measureContext = useDrawerMeasureContext()
47
49
  const { height: windowHeight } = useWindowDimensions()
48
50
 
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
+
49
61
  const gap = parseTokenValue(content.slot.gap)
50
62
  const horizontalPadding = parseTokenValue(content.slot.horizontalPadding)
51
63
  const topPadding = parseTokenValue(content.slot.verticalPadding)
@@ -71,6 +83,7 @@ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
71
83
  bodyGap={gap}
72
84
  bodyMaxHeight={bodyMaxHeight}
73
85
  bodyPaddingBottom={bodyPaddingBottom}
86
+ onContentSizeChange={handleContentSizeChange}
74
87
  contentContainerStyle={[
75
88
  {
76
89
  gap,
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useMemo, useRef } from "react"
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
2
  import {
3
3
  Animated,
4
4
  Easing,
@@ -11,6 +11,7 @@ import {
11
11
  import styled from "@emotion/native"
12
12
  import { useTheme } from "@emotion/react"
13
13
  import { DrawerDragContext } from "./DrawerDragContext"
14
+ import { DrawerMeasureContext } from "./DrawerMeasureContext"
14
15
  import { useDrawerContext } from "./DrawerContext"
15
16
  import { DrawerFooterContext } from "./DrawerFooterContext"
16
17
  import {
@@ -35,7 +36,7 @@ const StyledPanel = styled(View)<{
35
36
  panelShadowColor: string
36
37
  panelShadowOffsetY: number
37
38
  panelShadowBlur: number
38
- panelMaxHeight: number
39
+ panelFlex?: number
39
40
  }>(
40
41
  ({
41
42
  panelBorderRadius,
@@ -46,17 +47,12 @@ const StyledPanel = styled(View)<{
46
47
  panelShadowColor,
47
48
  panelShadowOffsetY,
48
49
  panelShadowBlur,
49
- panelMaxHeight
50
+ panelFlex
50
51
  }) => ({
51
52
  width: "100%",
52
53
  maxWidth: panelMaxWidth,
53
- // The panel wraps its content and caps at maxHeight. It is intentionally
54
- // NOT given a fixed height: letting it size to content means it re-fits
55
- // automatically whenever the body grows or shrinks (async load, expanding
56
- // sections). The body's own maxHeight (see DrawerBody) bounds scrolling and
57
- // keeps the ScrollView from collapsing, so no fixed panel height is needed.
58
- maxHeight: panelMaxHeight,
59
54
  alignSelf: "center",
55
+ ...(panelFlex != null ? { flex: panelFlex } : undefined),
60
56
  borderTopLeftRadius: panelBorderRadius,
61
57
  borderTopRightRadius: panelBorderRadius,
62
58
  borderTopWidth: panelBorderWidth,
@@ -78,6 +74,14 @@ const StyledPanel = styled(View)<{
78
74
  * the screen. Uses React Native `Animated` for entrance/exit and `PanResponder`
79
75
  * on the grabber for drag-to-dismiss.
80
76
  *
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.
84
+ *
81
85
  * Must be rendered inside `Drawer.Portal`.
82
86
  *
83
87
  * @example
@@ -104,6 +108,10 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
104
108
 
105
109
  const { height: windowHeight } = useWindowDimensions()
106
110
  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
107
115
 
108
116
  // Keep a ref so the PanResponder (created once) always calls the latest
109
117
  // closeDrawer without becoming a stale closure.
@@ -119,6 +127,19 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
119
127
  const isOpenRef = useRef(isOpen)
120
128
  const activeAnim = useRef<Animated.CompositeAnimation | null>(null)
121
129
 
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)
142
+
122
143
  useEffect(() => {
123
144
  const id = translateY.addListener(({ value }) => {
124
145
  translateYValue.current = value
@@ -126,6 +147,15 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
126
147
  return () => translateY.removeListener(id)
127
148
  }, [translateY])
128
149
 
150
+ // Clamp the cached panel height when the window shrinks (e.g. orientation
151
+ // change) so the entry animation never starts from below the screen.
152
+ useEffect(() => {
153
+ if (panelHeightRef.current > 0 && panelHeightRef.current > maxHeight) {
154
+ setPanelHeight(maxHeight)
155
+ panelHeightRef.current = maxHeight
156
+ }
157
+ }, [maxHeight])
158
+
129
159
  const runEntry = useCallback(() => {
130
160
  if (panelHeightRef.current === 0) return
131
161
  translateY.setValue(panelHeightRef.current)
@@ -173,25 +203,79 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
173
203
  }
174
204
  }, [isOpen, runEntry, runExit])
175
205
 
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)
219
+ }
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(
235
+ (height: number) => {
236
+ if (height <= 0) return
237
+ bodyContentRef.current = height
238
+ lockChrome()
239
+ applyRefit()
240
+ },
241
+ [lockChrome, applyRefit]
242
+ )
243
+
244
+ const measureContextValue = useMemo(
245
+ () => ({ setBodyContentHeight }),
246
+ [setBodyContentHeight]
247
+ )
248
+
176
249
  const onLayout = useCallback(
177
250
  ({ nativeEvent }: { nativeEvent: { layout: { height: number } } }) => {
178
251
  const h = nativeEvent.layout.height
179
252
  const capped = Math.min(h, maxHeight)
180
253
  if (capped <= 0) return
181
254
 
182
- // The panel wraps its content, so this fires again whenever the body
183
- // grows or shrinks. Keep the cached height current for the entry/exit
184
- // and drag-dismiss animations; the panel re-fits itself via layout, so
185
- // there is nothing else to do here (no state, no feedback loop).
186
- panelHeightRef.current = capped
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
187
261
 
188
- // Kick off the entry animation on the first measurement. translateY is
189
- // still off-screen (9999) at this point, so there is no visible jump.
190
- if (isOpenRef.current && !entryRanRef.current) {
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) {
191
273
  runEntry()
192
274
  }
275
+
276
+ applyRefit()
193
277
  },
194
- [runEntry, maxHeight]
278
+ [runEntry, maxHeight, lockChrome, applyRefit]
195
279
  )
196
280
 
197
281
  const panResponder = useRef(
@@ -278,31 +362,37 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
278
362
  return (
279
363
  <DrawerHeaderContext.Provider value={headerContextValue}>
280
364
  <DrawerFooterContext.Provider value={hasFooter}>
281
- <DrawerDragContext.Provider value={dragContextValue}>
282
- <Animated.View
283
- style={[styles.animatedWrapper, { transform: [{ translateY }] }]}
284
- >
285
- <StyledPanel
286
- ref={ref}
287
- onLayout={onLayout}
288
- panelBorderRadius={parseTokenValue(drawer.borderRadius.top)}
289
- panelBgColor={colour.background.container.default}
290
- panelMaxWidth={parseTokenValue(drawer.size.maxWidth)}
291
- panelMaxHeight={maxHeight}
292
- panelBorderWidth={parseTokenValue(dimensions.borderWidth.sm)}
293
- panelBorderColor={colour.border.default}
294
- panelShadowColor={modal.shadow.color}
295
- panelShadowOffsetY={parseTokenValue(modal.shadow.offsetY)}
296
- panelShadowBlur={parseTokenValue(modal.shadow.blur)}
297
- accessible
298
- accessibilityRole="none"
299
- accessibilityViewIsModal
300
- {...props}
365
+ <DrawerMeasureContext.Provider value={measureContextValue}>
366
+ <DrawerDragContext.Provider value={dragContextValue}>
367
+ <Animated.View
368
+ style={[
369
+ styles.animatedWrapper,
370
+ panelHeight > 0 ? { height: panelHeight } : undefined,
371
+ { transform: [{ translateY }] }
372
+ ]}
301
373
  >
302
- {children}
303
- </StyledPanel>
304
- </Animated.View>
305
- </DrawerDragContext.Provider>
374
+ <StyledPanel
375
+ ref={ref}
376
+ onLayout={onLayout}
377
+ panelBorderRadius={parseTokenValue(drawer.borderRadius.top)}
378
+ panelBgColor={colour.background.container.default}
379
+ panelMaxWidth={parseTokenValue(drawer.size.maxWidth)}
380
+ panelFlex={panelHeight > 0 ? 1 : undefined}
381
+ panelBorderWidth={parseTokenValue(dimensions.borderWidth.sm)}
382
+ panelBorderColor={colour.border.default}
383
+ panelShadowColor={modal.shadow.color}
384
+ panelShadowOffsetY={parseTokenValue(modal.shadow.offsetY)}
385
+ panelShadowBlur={parseTokenValue(modal.shadow.blur)}
386
+ accessible
387
+ accessibilityRole="none"
388
+ accessibilityViewIsModal
389
+ {...props}
390
+ >
391
+ {children}
392
+ </StyledPanel>
393
+ </Animated.View>
394
+ </DrawerDragContext.Provider>
395
+ </DrawerMeasureContext.Provider>
306
396
  </DrawerFooterContext.Provider>
307
397
  </DrawerHeaderContext.Provider>
308
398
  )
@@ -0,0 +1,18 @@
1
+ import { createContext, useContext } from "react"
2
+
3
+ /**
4
+ * Lets `DrawerBody` report its scroll content's natural height up to
5
+ * `DrawerContent`, which uses it to re-fit the panel when the content grows or
6
+ * shrinks after open. A ScrollView's content size is measured in an unbounded
7
+ * container, so it reflects the true content height independently of the
8
+ * ScrollView's own (bounded) frame — no measurement feedback loop.
9
+ */
10
+ export type DrawerMeasureContextValue = {
11
+ setBodyContentHeight: (height: number) => void
12
+ }
13
+
14
+ export const DrawerMeasureContext =
15
+ createContext<DrawerMeasureContextValue | null>(null)
16
+
17
+ export const useDrawerMeasureContext = (): DrawerMeasureContextValue | null =>
18
+ useContext(DrawerMeasureContext)
@@ -41,7 +41,7 @@ const StyledRoot = styled(View)<{
41
41
  fullWidth?: boolean
42
42
  }>(({ rootGap, fullWidth }) => ({
43
43
  gap: rootGap,
44
- ...(fullWidth && { width: "100%" })
44
+ ...(fullWidth ? { width: "100%" } : { alignItems: "center" })
45
45
  }))
46
46
 
47
47
  const StyledLabelGroup = styled(View)<{
@@ -134,6 +134,7 @@ export const NumberField = React.forwardRef<View, NumberFieldProps>(
134
134
  <Typography
135
135
  token={typoTokens.label}
136
136
  color={tokens.colour.text.label}
137
+ align="center"
137
138
  >
138
139
  {label}
139
140
  </Typography>
@@ -142,6 +143,7 @@ export const NumberField = React.forwardRef<View, NumberFieldProps>(
142
143
  <Typography
143
144
  token={typoTokens.smallLabel}
144
145
  color={tokens.colour.text.description}
146
+ align="center"
145
147
  >
146
148
  {smallLabel}
147
149
  </Typography>
@@ -172,6 +174,7 @@ export const NumberField = React.forwardRef<View, NumberFieldProps>(
172
174
  : tokens.typography.small.smallLabel
173
175
  }
174
176
  color={tokens.colour.text.description}
177
+ align="center"
175
178
  >
176
179
  {description}
177
180
  </Typography>
@@ -184,6 +187,7 @@ export const NumberField = React.forwardRef<View, NumberFieldProps>(
184
187
  : tokens.typography.small.smallLabel
185
188
  }
186
189
  color={theme.tokens.components.inputs.colour.description.error}
190
+ align="center"
187
191
  >
188
192
  {error}
189
193
  </Typography>