@butternutbox/pawprint-native 0.10.8 → 0.10.9

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.9",
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,14 +1,9 @@
1
- import React, { useCallback } from "react"
2
- import {
3
- type LayoutChangeEvent,
4
- ScrollView,
5
- ScrollViewProps
6
- } from "react-native"
1
+ import React from "react"
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"
10
6
  import { useDrawerFooterContext } from "./DrawerFooterContext"
11
- import { useDrawerMeasureContext } from "./DrawerMeasureContext"
12
7
 
13
8
  export type DrawerBodyProps = ScrollViewProps
14
9
 
@@ -18,22 +13,21 @@ const StyledScrollView = styled(ScrollView)<{
18
13
  bodyPaddingHorizontal: number
19
14
  bodyPaddingRight: number
20
15
  bodyGap: number
16
+ bodyMaxHeight: number
21
17
  bodyPaddingBottom: number
22
18
  }>(
23
19
  ({
24
20
  bodyPaddingHorizontal,
25
21
  bodyPaddingRight,
26
22
  bodyGap,
23
+ bodyMaxHeight,
27
24
  bodyPaddingBottom
28
25
  }) => ({
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
26
  flex: 1,
34
27
  paddingLeft: bodyPaddingHorizontal,
35
28
  paddingRight: bodyPaddingRight,
36
29
  gap: bodyGap,
30
+ maxHeight: bodyMaxHeight,
37
31
  paddingBottom: bodyPaddingBottom
38
32
  })
39
33
  )
@@ -43,45 +37,19 @@ const StyledScrollView = styled(ScrollView)<{
43
37
  * between the header and footer.
44
38
  */
45
39
  export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
46
- (
47
- {
48
- children,
49
- contentContainerStyle,
50
- onLayout,
51
- onContentSizeChange,
52
- ...props
53
- },
54
- ref
55
- ) => {
40
+ ({ children, contentContainerStyle, ...props }, ref) => {
56
41
  const theme = useTheme()
57
42
  const { spacing } = theme.tokens.components.drawer
58
43
  const { buttons } = theme.tokens.components
59
44
  const { content } = spacing
60
45
  const headerContext = useDrawerHeaderContext()
61
46
  const footerContext = useDrawerFooterContext()
62
- const measureContext = useDrawerMeasureContext()
47
+ const { height: windowHeight } = useWindowDimensions()
63
48
 
64
49
  const gap = parseTokenValue(content.slot.gap)
65
50
  const horizontalPadding = parseTokenValue(content.slot.horizontalPadding)
66
51
  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
-
78
- const handleContentSizeChange = useCallback(
79
- (width: number, height: number) => {
80
- measureContext?.setBodyContentHeight(height)
81
- onContentSizeChange?.(width, height)
82
- },
83
- [measureContext, onContentSizeChange]
84
- )
52
+ const bodyMaxHeight = windowHeight - 300
85
53
 
86
54
  // When there's no header the close button floats in the top-right corner.
87
55
  // Reserve space on the right so body content doesn't slide under it.
@@ -101,9 +69,8 @@ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
101
69
  bodyPaddingHorizontal={horizontalPadding}
102
70
  bodyPaddingRight={paddingRight}
103
71
  bodyGap={gap}
72
+ bodyMaxHeight={bodyMaxHeight}
104
73
  bodyPaddingBottom={bodyPaddingBottom}
105
- onLayout={handleLayout}
106
- onContentSizeChange={handleContentSizeChange}
107
74
  contentContainerStyle={[
108
75
  {
109
76
  gap,
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
1
+ import React, { useCallback, useEffect, useMemo, useRef } from "react"
2
2
  import {
3
3
  Animated,
4
4
  Easing,
@@ -11,7 +11,6 @@ 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"
15
14
  import { useDrawerContext } from "./DrawerContext"
16
15
  import { DrawerFooterContext } from "./DrawerFooterContext"
17
16
  import {
@@ -36,7 +35,7 @@ const StyledPanel = styled(View)<{
36
35
  panelShadowColor: string
37
36
  panelShadowOffsetY: number
38
37
  panelShadowBlur: number
39
- panelFlex?: number
38
+ panelMaxHeight: number
40
39
  }>(
41
40
  ({
42
41
  panelBorderRadius,
@@ -47,12 +46,17 @@ const StyledPanel = styled(View)<{
47
46
  panelShadowColor,
48
47
  panelShadowOffsetY,
49
48
  panelShadowBlur,
50
- panelFlex
49
+ panelMaxHeight
51
50
  }) => ({
52
51
  width: "100%",
53
52
  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,
54
59
  alignSelf: "center",
55
- ...(panelFlex != null ? { flex: panelFlex } : undefined),
56
60
  borderTopLeftRadius: panelBorderRadius,
57
61
  borderTopRightRadius: panelBorderRadius,
58
62
  borderTopWidth: panelBorderWidth,
@@ -100,10 +104,6 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
100
104
 
101
105
  const { height: windowHeight } = useWindowDimensions()
102
106
  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.
106
- const bodyMaxHeight = windowHeight - 300
107
107
 
108
108
  // Keep a ref so the PanResponder (created once) always calls the latest
109
109
  // closeDrawer without becoming a stale closure.
@@ -119,12 +119,6 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
119
119
  const isOpenRef = useRef(isOpen)
120
120
  const activeAnim = useRef<Animated.CompositeAnimation | null>(null)
121
121
 
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.
126
- const [panelHeight, setPanelHeight] = useState(0)
127
-
128
122
  useEffect(() => {
129
123
  const id = translateY.addListener(({ value }) => {
130
124
  translateYValue.current = value
@@ -132,15 +126,6 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
132
126
  return () => translateY.removeListener(id)
133
127
  }, [translateY])
134
128
 
135
- // Clamp the cached panel height when the window shrinks (e.g. orientation
136
- // change) so the entry animation never starts from below the screen.
137
- useEffect(() => {
138
- if (panelHeightRef.current > 0 && panelHeightRef.current > maxHeight) {
139
- setPanelHeight(maxHeight)
140
- panelHeightRef.current = maxHeight
141
- }
142
- }, [maxHeight])
143
-
144
129
  const runEntry = useCallback(() => {
145
130
  if (panelHeightRef.current === 0) return
146
131
  translateY.setValue(panelHeightRef.current)
@@ -190,85 +175,25 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
190
175
 
191
176
  const onLayout = useCallback(
192
177
  ({ 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
178
  const h = nativeEvent.layout.height
202
179
  const capped = Math.min(h, maxHeight)
203
180
  if (capped <= 0) return
204
181
 
205
- if (capped !== panelHeightRef.current) {
206
- setPanelHeight(capped)
207
- panelHeightRef.current = capped
208
- }
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
209
187
 
210
- if (isOpenRef.current) {
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) {
211
191
  runEntry()
212
192
  }
213
193
  },
214
194
  [runEntry, maxHeight]
215
195
  )
216
196
 
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(() => {
228
- const chrome = chromeRef.current
229
- const content = bodyContentRef.current
230
- const current = panelHeightRef.current
231
- if (chrome <= 0 || content <= 0 || current <= 0) return
232
- const desired = Math.min(
233
- chrome + Math.min(content, bodyMaxHeight),
234
- maxHeight
235
- )
236
- if (Math.abs(desired - current) > 1) {
237
- panelHeightRef.current = desired
238
- setPanelHeight(desired)
239
- }
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
- )
257
-
258
- const setBodyContentHeight = useCallback(
259
- (contentHeight: number) => {
260
- if (contentHeight <= 0) return
261
- bodyContentRef.current = contentHeight
262
- applyDesiredHeight()
263
- },
264
- [applyDesiredHeight]
265
- )
266
-
267
- const measureContextValue = useMemo(
268
- () => ({ setBodyFrameHeight, setBodyContentHeight }),
269
- [setBodyFrameHeight, setBodyContentHeight]
270
- )
271
-
272
197
  const panResponder = useRef(
273
198
  PanResponder.create({
274
199
  // Claim the responder as soon as a touch starts on the grabber so the
@@ -353,37 +278,31 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
353
278
  return (
354
279
  <DrawerHeaderContext.Provider value={headerContextValue}>
355
280
  <DrawerFooterContext.Provider value={hasFooter}>
356
- <DrawerMeasureContext.Provider value={measureContextValue}>
357
- <DrawerDragContext.Provider value={dragContextValue}>
358
- <Animated.View
359
- style={[
360
- styles.animatedWrapper,
361
- panelHeight > 0 ? { height: panelHeight } : undefined,
362
- { transform: [{ translateY }] }
363
- ]}
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}
364
301
  >
365
- <StyledPanel
366
- ref={ref}
367
- onLayout={onLayout}
368
- panelBorderRadius={parseTokenValue(drawer.borderRadius.top)}
369
- panelBgColor={colour.background.container.default}
370
- panelMaxWidth={parseTokenValue(drawer.size.maxWidth)}
371
- panelFlex={panelHeight > 0 ? 1 : undefined}
372
- panelBorderWidth={parseTokenValue(dimensions.borderWidth.sm)}
373
- panelBorderColor={colour.border.default}
374
- panelShadowColor={modal.shadow.color}
375
- panelShadowOffsetY={parseTokenValue(modal.shadow.offsetY)}
376
- panelShadowBlur={parseTokenValue(modal.shadow.blur)}
377
- accessible
378
- accessibilityRole="none"
379
- accessibilityViewIsModal
380
- {...props}
381
- >
382
- {children}
383
- </StyledPanel>
384
- </Animated.View>
385
- </DrawerDragContext.Provider>
386
- </DrawerMeasureContext.Provider>
302
+ {children}
303
+ </StyledPanel>
304
+ </Animated.View>
305
+ </DrawerDragContext.Provider>
387
306
  </DrawerFooterContext.Provider>
388
307
  </DrawerHeaderContext.Provider>
389
308
  )
@@ -1,22 +0,0 @@
1
- import React from "react"
2
-
3
- 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
- setBodyContentHeight: (height: number) => void
16
- }
17
-
18
- export const DrawerMeasureContext =
19
- React.createContext<DrawerMeasureContextValue | null>(null)
20
-
21
- export const useDrawerMeasureContext = () =>
22
- React.useContext(DrawerMeasureContext)