@butternutbox/pawprint-native 0.10.8 → 0.10.10

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.8",
3
+ "version": "0.10.10",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -205,6 +205,55 @@ export const LongContent = () => (
205
205
  </View>
206
206
  )
207
207
 
208
+ // Exercises the panel re-fit: toggling the extra paragraphs grows/shrinks the
209
+ // body after the drawer has opened. The panel should track the content —
210
+ // growing (up to its max, then scrolling) and shrinking back — never leaving a
211
+ // gap or clipping. Regression guard for the 0.10.8 re-measure feedback loop.
212
+ export const DynamicContent = () => {
213
+ const [expanded, setExpanded] = useState(false)
214
+ return (
215
+ <View style={styles.container}>
216
+ <Drawer.Root>
217
+ <Drawer.Trigger>
218
+ <Button variant="filled" colour="primary">
219
+ Open dynamic drawer
220
+ </Button>
221
+ </Drawer.Trigger>
222
+ <Drawer.Portal>
223
+ <Drawer.Overlay />
224
+ <Drawer.Content>
225
+ <Drawer.Header variant="titleAndText">
226
+ <Drawer.Title>Delivery details</Drawer.Title>
227
+ <Drawer.Close />
228
+ </Drawer.Header>
229
+ <Drawer.Body>
230
+ <Typography>{bodyText}</Typography>
231
+ <Button
232
+ variant="outlined"
233
+ colour="secondary"
234
+ onPress={() => setExpanded((prev) => !prev)}
235
+ >
236
+ {expanded ? "Show less" : "Show more"}
237
+ </Button>
238
+ {expanded &&
239
+ Array.from({ length: 12 }, (_, i) => (
240
+ <Typography
241
+ key={i}
242
+ >{`More detail ${i + 1}: ${bodyText}`}</Typography>
243
+ ))}
244
+ </Drawer.Body>
245
+ <Drawer.Footer>
246
+ <Button variant="filled" colour="primary">
247
+ Confirm
248
+ </Button>
249
+ </Drawer.Footer>
250
+ </Drawer.Content>
251
+ </Drawer.Portal>
252
+ </Drawer.Root>
253
+ </View>
254
+ )
255
+ }
256
+
208
257
  export const Controlled = () => {
209
258
  const [open, setOpen] = useState(false)
210
259
  return (
@@ -321,116 +370,6 @@ export const WithTopContent = () => {
321
370
  )
322
371
  }
323
372
 
324
- /**
325
- * Exercises the panel re-measuring its height when body content changes.
326
- * Use "Add"/"Remove" to grow and shrink the content: the drawer should re-fit
327
- * its height each time, and once the content exceeds ~90% of the screen it
328
- * should cap and the body should scroll instead of growing further.
329
- */
330
- export const DynamicContent = () => {
331
- const [count, setCount] = useState(1)
332
-
333
- return (
334
- <View style={styles.container}>
335
- <Drawer.Root defaultOpen>
336
- <Drawer.Trigger>
337
- <Button variant="filled" colour="primary">
338
- Open resizable drawer
339
- </Button>
340
- </Drawer.Trigger>
341
- <Drawer.Portal>
342
- <Drawer.Overlay />
343
- <Drawer.Content>
344
- <Drawer.Header variant="titleAndText">
345
- <Drawer.Title>Resizable content</Drawer.Title>
346
- <Drawer.Description>{`${count} paragraph${
347
- count === 1 ? "" : "s"
348
- }`}</Drawer.Description>
349
- <Drawer.Close />
350
- </Drawer.Header>
351
- <Drawer.Body>
352
- {Array.from({ length: count }, (_, i) => (
353
- <Typography
354
- key={i}
355
- >{`Paragraph ${i + 1}: ${bodyText}`}</Typography>
356
- ))}
357
- </Drawer.Body>
358
- <Drawer.Footer>
359
- <View style={styles.row}>
360
- <Button
361
- variant="filled"
362
- colour="primary"
363
- onPress={() => setCount((c) => c + 1)}
364
- >
365
- Add
366
- </Button>
367
- <Button
368
- variant="outlined"
369
- colour="secondary"
370
- onPress={() => setCount((c) => Math.max(0, c - 1))}
371
- >
372
- Remove
373
- </Button>
374
- </View>
375
- </Drawer.Footer>
376
- </Drawer.Content>
377
- </Drawer.Portal>
378
- </Drawer.Root>
379
- </View>
380
- )
381
- }
382
-
383
- /**
384
- * Simulates async content loading: the drawer opens compact with a loading
385
- * message, then re-fits taller once the "loaded" content arrives. Tap "Reload"
386
- * to replay.
387
- */
388
- export const AsyncContent = () => {
389
- const [loaded, setLoaded] = useState(false)
390
-
391
- const load = () => {
392
- setLoaded(false)
393
- setTimeout(() => setLoaded(true), 1200)
394
- }
395
-
396
- return (
397
- <View style={styles.container}>
398
- <Drawer.Root onOpenChange={(open) => open && load()}>
399
- <Drawer.Trigger>
400
- <Button variant="filled" colour="primary">
401
- Open async drawer
402
- </Button>
403
- </Drawer.Trigger>
404
- <Drawer.Portal>
405
- <Drawer.Overlay />
406
- <Drawer.Content>
407
- <Drawer.Header variant="titleAndText">
408
- <Drawer.Title>Delivery details</Drawer.Title>
409
- <Drawer.Close />
410
- </Drawer.Header>
411
- <Drawer.Body>
412
- {loaded ? (
413
- Array.from({ length: 5 }, (_, i) => (
414
- <Typography
415
- key={i}
416
- >{`Detail ${i + 1}: ${bodyText}`}</Typography>
417
- ))
418
- ) : (
419
- <Typography>Loading…</Typography>
420
- )}
421
- </Drawer.Body>
422
- <Drawer.Footer>
423
- <Button variant="filled" colour="primary" onPress={load}>
424
- Reload
425
- </Button>
426
- </Drawer.Footer>
427
- </Drawer.Content>
428
- </Drawer.Portal>
429
- </Drawer.Root>
430
- </View>
431
- )
432
- }
433
-
434
373
  const styles = StyleSheet.create({
435
374
  container: {
436
375
  padding: 16
@@ -1,9 +1,5 @@
1
1
  import React, { useCallback } from "react"
2
- import {
3
- type LayoutChangeEvent,
4
- ScrollView,
5
- ScrollViewProps
6
- } from "react-native"
2
+ import { ScrollView, ScrollViewProps, useWindowDimensions } from "react-native"
7
3
  import styled from "@emotion/native"
8
4
  import { useTheme } from "@emotion/react"
9
5
  import { useDrawerHeaderContext } from "./DrawerHeaderContext"
@@ -18,22 +14,21 @@ const StyledScrollView = styled(ScrollView)<{
18
14
  bodyPaddingHorizontal: number
19
15
  bodyPaddingRight: number
20
16
  bodyGap: number
17
+ bodyMaxHeight: number
21
18
  bodyPaddingBottom: number
22
19
  }>(
23
20
  ({
24
21
  bodyPaddingHorizontal,
25
22
  bodyPaddingRight,
26
23
  bodyGap,
24
+ bodyMaxHeight,
27
25
  bodyPaddingBottom
28
26
  }) => ({
29
- // flex: 1 fills the bounded panel so the ScrollView can scroll. The panel
30
- // (DrawerContent) is the single height authority — the body must NOT set
31
- // its own maxHeight, or it would cap independently of the panel and corrupt
32
- // the panel's chrome measurement.
33
27
  flex: 1,
34
28
  paddingLeft: bodyPaddingHorizontal,
35
29
  paddingRight: bodyPaddingRight,
36
30
  gap: bodyGap,
31
+ maxHeight: bodyMaxHeight,
37
32
  paddingBottom: bodyPaddingBottom
38
33
  })
39
34
  )
@@ -43,16 +38,7 @@ const StyledScrollView = styled(ScrollView)<{
43
38
  * between the header and footer.
44
39
  */
45
40
  export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
46
- (
47
- {
48
- children,
49
- contentContainerStyle,
50
- onLayout,
51
- onContentSizeChange,
52
- ...props
53
- },
54
- ref
55
- ) => {
41
+ ({ children, contentContainerStyle, onContentSizeChange, ...props }, ref) => {
56
42
  const theme = useTheme()
57
43
  const { spacing } = theme.tokens.components.drawer
58
44
  const { buttons } = theme.tokens.components
@@ -60,29 +46,23 @@ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
60
46
  const headerContext = useDrawerHeaderContext()
61
47
  const footerContext = useDrawerFooterContext()
62
48
  const measureContext = useDrawerMeasureContext()
49
+ const { height: windowHeight } = useWindowDimensions()
63
50
 
64
- const gap = parseTokenValue(content.slot.gap)
65
- const horizontalPadding = parseTokenValue(content.slot.horizontalPadding)
66
- const topPadding = parseTokenValue(content.slot.verticalPadding)
67
-
68
- // Report the ScrollView's frame and content heights so DrawerContent can
69
- // re-fit the panel when the body content changes.
70
- const handleLayout = useCallback(
71
- (event: LayoutChangeEvent) => {
72
- measureContext?.setBodyFrameHeight(event.nativeEvent.layout.height)
73
- onLayout?.(event)
74
- },
75
- [measureContext, onLayout]
76
- )
77
-
51
+ // Report the scroll content's natural height so DrawerContent can re-fit
52
+ // the panel when the body grows or shrinks.
78
53
  const handleContentSizeChange = useCallback(
79
- (width: number, height: number) => {
54
+ (width: number, height: number): void => {
80
55
  measureContext?.setBodyContentHeight(height)
81
56
  onContentSizeChange?.(width, height)
82
57
  },
83
58
  [measureContext, onContentSizeChange]
84
59
  )
85
60
 
61
+ const gap = parseTokenValue(content.slot.gap)
62
+ const horizontalPadding = parseTokenValue(content.slot.horizontalPadding)
63
+ const topPadding = parseTokenValue(content.slot.verticalPadding)
64
+ const bodyMaxHeight = windowHeight - 300
65
+
86
66
  // When there's no header the close button floats in the top-right corner.
87
67
  // Reserve space on the right so body content doesn't slide under it.
88
68
  const paddingRight =
@@ -101,8 +81,8 @@ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
101
81
  bodyPaddingHorizontal={horizontalPadding}
102
82
  bodyPaddingRight={paddingRight}
103
83
  bodyGap={gap}
84
+ bodyMaxHeight={bodyMaxHeight}
104
85
  bodyPaddingBottom={bodyPaddingBottom}
105
- onLayout={handleLayout}
106
86
  onContentSizeChange={handleContentSizeChange}
107
87
  contentContainerStyle={[
108
88
  {
@@ -74,6 +74,14 @@ const StyledPanel = styled(View)<{
74
74
  * the screen. Uses React Native `Animated` for entrance/exit and `PanResponder`
75
75
  * on the grabber for drag-to-dismiss.
76
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
+ *
77
85
  * Must be rendered inside `Drawer.Portal`.
78
86
  *
79
87
  * @example
@@ -100,9 +108,9 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
100
108
 
101
109
  const { height: windowHeight } = useWindowDimensions()
102
110
  const maxHeight = windowHeight * 0.9
103
- // Tallest the body content is allowed to grow before it scrolls. Enforced
104
- // here (via the panel height) rather than as a maxHeight on the body
105
- // ScrollView, so the panel stays the single height authority.
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.
106
114
  const bodyMaxHeight = windowHeight - 300
107
115
 
108
116
  // Keep a ref so the PanResponder (created once) always calls the latest
@@ -119,12 +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
 
122
- // After the first onLayout we know the real rendered height (capped at
123
- // MAX_HEIGHT). We immediately kick off the entry animation from that height
124
- // and then set explicit height + flex: 1 on the panel so DrawerBody's
125
- // ScrollView gets a bounded container and can scroll correctly.
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.
126
133
  const [panelHeight, setPanelHeight] = useState(0)
127
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
+
128
143
  useEffect(() => {
129
144
  const id = translateY.addListener(({ value }) => {
130
145
  translateYValue.current = value
@@ -188,85 +203,79 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
188
203
  }
189
204
  }, [isOpen, runEntry, runExit])
190
205
 
191
- const onLayout = useCallback(
192
- ({ nativeEvent }: { nativeEvent: { layout: { height: number } } }) => {
193
- // This only bootstraps the first measurement and the entry animation.
194
- // After entry has run, applyDesiredHeight (driven by the body's
195
- // content size) is the SOLE authority for panel height — this handler
196
- // must not keep overriding it, or the two fight and the panel gets
197
- // pinned to chrome + bodyFrame (leaving a gap) instead of
198
- // chrome + content.
199
- if (entryRanRef.current) return
200
-
201
- const h = nativeEvent.layout.height
202
- const capped = Math.min(h, maxHeight)
203
- if (capped <= 0) return
204
-
205
- if (capped !== panelHeightRef.current) {
206
- setPanelHeight(capped)
207
- panelHeightRef.current = capped
208
- }
209
-
210
- if (isOpenRef.current) {
211
- runEntry()
212
- }
213
- },
214
- [runEntry, maxHeight]
215
- )
216
-
217
- // Re-fit the panel height whenever the body content changes. Desired height
218
- // is the absolute `chrome + content` (clamped to bodyMax, then maxHeight).
219
- // `chrome` (header + footer + padding + borders) is invariant for a given
220
- // drawer, so it is measured ONCE and locked. Deriving it repeatedly from
221
- // `panelHeight - bodyFrame` is unsafe: those two are only consistent at a
222
- // settled layout, and mid-resize the frame lags, which inflates chrome and
223
- // pins the panel to chrome + frame (a gap) instead of chrome + content.
224
- const chromeRef = useRef(0)
225
- const bodyContentRef = useRef(0)
226
-
227
- const applyDesiredHeight = useCallback(() => {
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(() => {
228
209
  const chrome = chromeRef.current
229
210
  const content = bodyContentRef.current
230
- const current = panelHeightRef.current
231
- if (chrome <= 0 || content <= 0 || current <= 0) return
211
+ if (chrome <= 0 || content <= 0) return
232
212
  const desired = Math.min(
233
213
  chrome + Math.min(content, bodyMaxHeight),
234
214
  maxHeight
235
215
  )
236
- if (Math.abs(desired - current) > 1) {
216
+ if (Math.abs(desired - panelHeightRef.current) > 1) {
237
217
  panelHeightRef.current = desired
238
218
  setPanelHeight(desired)
239
219
  }
240
- }, [maxHeight, bodyMaxHeight])
241
-
242
- const setBodyFrameHeight = useCallback(
243
- (frameHeight: number) => {
244
- // Lock chrome once, from the first settled measurement (initial layout,
245
- // before any content toggling), where panelHeight and bodyFrame are
246
- // consistent. It stays fixed thereafter.
247
- if (chromeRef.current <= 0) {
248
- const current = panelHeightRef.current
249
- if (frameHeight > 0 && current > frameHeight) {
250
- chromeRef.current = current - frameHeight
251
- }
252
- }
253
- applyDesiredHeight()
254
- },
255
- [applyDesiredHeight]
256
- )
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])
257
233
 
258
234
  const setBodyContentHeight = useCallback(
259
- (contentHeight: number) => {
260
- if (contentHeight <= 0) return
261
- bodyContentRef.current = contentHeight
262
- applyDesiredHeight()
235
+ (height: number) => {
236
+ if (height <= 0) return
237
+ bodyContentRef.current = height
238
+ lockChrome()
239
+ applyRefit()
263
240
  },
264
- [applyDesiredHeight]
241
+ [lockChrome, applyRefit]
265
242
  )
266
243
 
267
244
  const measureContextValue = useMemo(
268
- () => ({ setBodyFrameHeight, setBodyContentHeight }),
269
- [setBodyFrameHeight, setBodyContentHeight]
245
+ () => ({ setBodyContentHeight }),
246
+ [setBodyContentHeight]
247
+ )
248
+
249
+ const onLayout = useCallback(
250
+ ({ nativeEvent }: { nativeEvent: { layout: { height: number } } }) => {
251
+ const h = nativeEvent.layout.height
252
+ const capped = Math.min(h, maxHeight)
253
+ 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
+ runEntry()
274
+ }
275
+
276
+ applyRefit()
277
+ },
278
+ [runEntry, maxHeight, lockChrome, applyRefit]
270
279
  )
271
280
 
272
281
  const panResponder = useRef(
@@ -1,22 +1,18 @@
1
- import React from "react"
1
+ import { createContext, useContext } from "react"
2
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
+ */
3
10
  export type DrawerMeasureContextValue = {
4
- /**
5
- * The drawer body's ScrollView frame height (its laid-out size). Together
6
- * with the panel height this yields the fixed "chrome" (header + footer +
7
- * padding), since panel height always equals chrome + body frame.
8
- */
9
- setBodyFrameHeight: (height: number) => void
10
- /**
11
- * The drawer body's content height (full scrollable content). Drives the
12
- * panel's desired height as `chrome + content`, so the drawer re-fits when
13
- * content changes.
14
- */
15
11
  setBodyContentHeight: (height: number) => void
16
12
  }
17
13
 
18
14
  export const DrawerMeasureContext =
19
- React.createContext<DrawerMeasureContextValue | null>(null)
15
+ createContext<DrawerMeasureContextValue | null>(null)
20
16
 
21
- export const useDrawerMeasureContext = () =>
22
- React.useContext(DrawerMeasureContext)
17
+ export const useDrawerMeasureContext = (): DrawerMeasureContextValue | null =>
18
+ useContext(DrawerMeasureContext)