@butternutbox/pawprint-native 0.10.7 → 0.10.8

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.7",
3
+ "version": "0.10.8",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -129,34 +129,36 @@ export type DatePickerProps = DatePickerOwnProps &
129
129
  Omit<ViewProps, keyof DatePickerOwnProps>
130
130
 
131
131
  const StyledContainer = styled(View)<{
132
- containerGap: number
133
132
  containerMaxWidth: number
134
- }>(({ containerGap, containerMaxWidth }) => ({
133
+ }>(({ containerMaxWidth }) => ({
135
134
  flexDirection: "column",
136
- gap: containerGap,
135
+ // Fill the parent by default (capped by maxWidth) so consumers don't need a
136
+ // wrapper. Overridable via the `style` prop.
137
+ width: "100%",
137
138
  maxWidth: containerMaxWidth
138
139
  }))
139
140
 
140
- const StyledMonthHeader = styled(View)<{ headerGap: number }>(
141
- ({ headerGap }) => ({
141
+ const StyledMonthHeader = styled(View)<{ headerGap: number; datesGap: number }>(
142
+ ({ headerGap, datesGap }) => ({
142
143
  flexDirection: "row",
143
144
  alignItems: "center",
144
- gap: headerGap
145
+ gap: headerGap,
146
+ // Gap between the month header and the dates grid.
147
+ marginBottom: datesGap
145
148
  })
146
149
  )
147
150
 
148
- const StyledWeekRow = styled(View)({
149
- flexDirection: "row"
150
- })
151
+ const StyledWeekRow = styled(View)<{ weekGap: number }>(({ weekGap }) => ({
152
+ flexDirection: "row",
153
+ // Gap between the week-day header and the month header.
154
+ marginBottom: weekGap
155
+ }))
151
156
 
152
- const StyledWeekCell = styled(View)<{ cellHeight: number }>(
153
- ({ cellHeight }) => ({
154
- flex: 1,
155
- alignItems: "center",
156
- justifyContent: "center",
157
- height: cellHeight
158
- })
159
- )
157
+ const StyledWeekCell = styled(View)({
158
+ flex: 1,
159
+ alignItems: "center",
160
+ justifyContent: "center"
161
+ })
160
162
 
161
163
  const StyledDatesGrid = styled(View)<{ rowGap: number }>(({ rowGap }) => ({
162
164
  flexDirection: "column",
@@ -280,7 +282,8 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
280
282
  const [slideDir, setSlideDir] = useState<"forward" | "backward">("forward")
281
283
 
282
284
  const {
283
- containerGap,
285
+ weekGap,
286
+ datesGap,
284
287
  containerMaxWidth,
285
288
  headerGap,
286
289
  cellHeight,
@@ -291,7 +294,10 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
291
294
  rowGap
292
295
  } = useMemo(
293
296
  () => ({
294
- containerGap: parseTokenValue(datePicker.spacing.gap),
297
+ // Gap between the week-day header and the month header.
298
+ weekGap: parseTokenValue(datePicker.spacing.gap),
299
+ // Gap between the month header and the dates grid.
300
+ datesGap: parseTokenValue(theme.tokens.semantics.dimensions.spacing.md),
295
301
  containerMaxWidth: parseTokenValue(dt.size.width) * 7,
296
302
  headerGap: parseTokenValue(datePicker.month.spacing.gap),
297
303
  cellHeight: parseTokenValue(dt.size.height),
@@ -301,7 +307,7 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
301
307
  disabledOpacity: parseTokenValue(dt.opacity.disabled),
302
308
  rowGap: parseTokenValue(datePicker.dates.spacing.gap)
303
309
  }),
304
- [datePicker, dt]
310
+ [datePicker, dt, theme]
305
311
  )
306
312
 
307
313
  const resolvedGetDateState = useCallback(
@@ -423,14 +429,13 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
423
429
  return (
424
430
  <StyledContainer
425
431
  ref={ref}
426
- containerGap={containerGap}
427
432
  containerMaxWidth={containerMaxWidth}
428
433
  {...rest}
429
434
  >
430
435
  {showWeekHeader && (
431
- <StyledWeekRow accessibilityElementsHidden>
436
+ <StyledWeekRow weekGap={weekGap} accessibilityElementsHidden>
432
437
  {weekDayLabels.map((label) => (
433
- <StyledWeekCell key={label} cellHeight={cellHeight}>
438
+ <StyledWeekCell key={label}>
434
439
  <Typography
435
440
  token={datePicker.week.typography}
436
441
  align="center"
@@ -443,12 +448,12 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
443
448
  </StyledWeekRow>
444
449
  )}
445
450
 
446
- <StyledMonthHeader headerGap={headerGap}>
451
+ <StyledMonthHeader headerGap={headerGap} datesGap={datesGap}>
447
452
  <View style={{ opacity: showPrevControl ? 1 : 0 }}>
448
453
  <IconButton
449
454
  icon={KeyboardArrowLeft}
450
455
  variant="filled"
451
- size="sm"
456
+ size="md"
452
457
  colour="primary"
453
458
  aria-label="Previous month"
454
459
  onPress={showPrevControl ? handlePrevMonth : undefined}
@@ -470,7 +475,7 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
470
475
  <IconButton
471
476
  icon={KeyboardArrowRight}
472
477
  variant="filled"
473
- size="sm"
478
+ size="md"
474
479
  colour="primary"
475
480
  aria-label="Next month"
476
481
  onPress={showNextControl ? handleNextMonth : undefined}
@@ -321,6 +321,116 @@ export const WithTopContent = () => {
321
321
  )
322
322
  }
323
323
 
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
+
324
434
  const styles = StyleSheet.create({
325
435
  container: {
326
436
  padding: 16
@@ -1,9 +1,14 @@
1
- import React from "react"
2
- import { ScrollView, ScrollViewProps, useWindowDimensions } from "react-native"
1
+ import React, { useCallback } from "react"
2
+ import {
3
+ type LayoutChangeEvent,
4
+ ScrollView,
5
+ ScrollViewProps
6
+ } from "react-native"
3
7
  import styled from "@emotion/native"
4
8
  import { useTheme } from "@emotion/react"
5
9
  import { useDrawerHeaderContext } from "./DrawerHeaderContext"
6
10
  import { useDrawerFooterContext } from "./DrawerFooterContext"
11
+ import { useDrawerMeasureContext } from "./DrawerMeasureContext"
7
12
 
8
13
  export type DrawerBodyProps = ScrollViewProps
9
14
 
@@ -13,21 +18,22 @@ const StyledScrollView = styled(ScrollView)<{
13
18
  bodyPaddingHorizontal: number
14
19
  bodyPaddingRight: number
15
20
  bodyGap: number
16
- bodyMaxHeight: number
17
21
  bodyPaddingBottom: number
18
22
  }>(
19
23
  ({
20
24
  bodyPaddingHorizontal,
21
25
  bodyPaddingRight,
22
26
  bodyGap,
23
- bodyMaxHeight,
24
27
  bodyPaddingBottom
25
28
  }) => ({
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.
26
33
  flex: 1,
27
34
  paddingLeft: bodyPaddingHorizontal,
28
35
  paddingRight: bodyPaddingRight,
29
36
  gap: bodyGap,
30
- maxHeight: bodyMaxHeight,
31
37
  paddingBottom: bodyPaddingBottom
32
38
  })
33
39
  )
@@ -37,19 +43,45 @@ const StyledScrollView = styled(ScrollView)<{
37
43
  * between the header and footer.
38
44
  */
39
45
  export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
40
- ({ children, contentContainerStyle, ...props }, ref) => {
46
+ (
47
+ {
48
+ children,
49
+ contentContainerStyle,
50
+ onLayout,
51
+ onContentSizeChange,
52
+ ...props
53
+ },
54
+ ref
55
+ ) => {
41
56
  const theme = useTheme()
42
57
  const { spacing } = theme.tokens.components.drawer
43
58
  const { buttons } = theme.tokens.components
44
59
  const { content } = spacing
45
60
  const headerContext = useDrawerHeaderContext()
46
61
  const footerContext = useDrawerFooterContext()
47
- const { height: windowHeight } = useWindowDimensions()
62
+ const measureContext = useDrawerMeasureContext()
48
63
 
49
64
  const gap = parseTokenValue(content.slot.gap)
50
65
  const horizontalPadding = parseTokenValue(content.slot.horizontalPadding)
51
66
  const topPadding = parseTokenValue(content.slot.verticalPadding)
52
- const bodyMaxHeight = windowHeight - 300
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
+ )
53
85
 
54
86
  // When there's no header the close button floats in the top-right corner.
55
87
  // Reserve space on the right so body content doesn't slide under it.
@@ -69,8 +101,9 @@ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
69
101
  bodyPaddingHorizontal={horizontalPadding}
70
102
  bodyPaddingRight={paddingRight}
71
103
  bodyGap={gap}
72
- bodyMaxHeight={bodyMaxHeight}
73
104
  bodyPaddingBottom={bodyPaddingBottom}
105
+ onLayout={handleLayout}
106
+ onContentSizeChange={handleContentSizeChange}
74
107
  contentContainerStyle={[
75
108
  {
76
109
  gap,
@@ -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 {
@@ -99,6 +100,10 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
99
100
 
100
101
  const { height: windowHeight } = useWindowDimensions()
101
102
  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
102
107
 
103
108
  // Keep a ref so the PanResponder (created once) always calls the latest
104
109
  // closeDrawer without becoming a stale closure.
@@ -185,29 +190,85 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
185
190
 
186
191
  const onLayout = useCallback(
187
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
+
188
201
  const h = nativeEvent.layout.height
189
202
  const capped = Math.min(h, maxHeight)
203
+ if (capped <= 0) return
190
204
 
191
- // Only update state when the effective height actually changes, to
192
- // avoid re-render loops. Compare against the ref (kept in sync with
193
- // state) so panelHeight doesn't need to be a dep of this callback.
194
- if (capped !== panelHeightRef.current && capped > 0) {
205
+ if (capped !== panelHeightRef.current) {
195
206
  setPanelHeight(capped)
196
207
  panelHeightRef.current = capped
197
208
  }
198
209
 
199
- // Run entry on the first measurement. The animation starts from the
200
- // capped height even though the panel may still be at natural size;
201
- // the re-render that sets the explicit height happens while the panel
202
- // is still off-screen, so there is no visible jump.
203
- if (isOpenRef.current && !entryRanRef.current && capped > 0) {
204
- panelHeightRef.current = capped
210
+ if (isOpenRef.current) {
205
211
  runEntry()
206
212
  }
207
213
  },
208
214
  [runEntry, maxHeight]
209
215
  )
210
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(() => {
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
+
211
272
  const panResponder = useRef(
212
273
  PanResponder.create({
213
274
  // Claim the responder as soon as a touch starts on the grabber so the
@@ -292,35 +353,37 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
292
353
  return (
293
354
  <DrawerHeaderContext.Provider value={headerContextValue}>
294
355
  <DrawerFooterContext.Provider value={hasFooter}>
295
- <DrawerDragContext.Provider value={dragContextValue}>
296
- <Animated.View
297
- style={[
298
- styles.animatedWrapper,
299
- panelHeight > 0 ? { height: panelHeight } : undefined,
300
- { transform: [{ translateY }] }
301
- ]}
302
- >
303
- <StyledPanel
304
- ref={ref}
305
- onLayout={onLayout}
306
- panelBorderRadius={parseTokenValue(drawer.borderRadius.top)}
307
- panelBgColor={colour.background.container.default}
308
- panelMaxWidth={parseTokenValue(drawer.size.maxWidth)}
309
- panelFlex={panelHeight > 0 ? 1 : undefined}
310
- panelBorderWidth={parseTokenValue(dimensions.borderWidth.sm)}
311
- panelBorderColor={colour.border.default}
312
- panelShadowColor={modal.shadow.color}
313
- panelShadowOffsetY={parseTokenValue(modal.shadow.offsetY)}
314
- panelShadowBlur={parseTokenValue(modal.shadow.blur)}
315
- accessible
316
- accessibilityRole="none"
317
- accessibilityViewIsModal
318
- {...props}
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
+ ]}
319
364
  >
320
- {children}
321
- </StyledPanel>
322
- </Animated.View>
323
- </DrawerDragContext.Provider>
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>
324
387
  </DrawerFooterContext.Provider>
325
388
  </DrawerHeaderContext.Provider>
326
389
  )
@@ -0,0 +1,22 @@
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)
@@ -96,6 +96,9 @@ const StyledIndicator = styled(View)<{
96
96
  const StyledTextContent = styled(View)<{
97
97
  textContentGap: number
98
98
  }>(({ textContentGap }) => ({
99
+ // Take the row's remaining width so long label/subText wraps within the tile
100
+ // instead of pushing the trailing asset past the tile's edge.
101
+ flex: 1,
99
102
  flexDirection: "column",
100
103
  gap: textContentGap
101
104
  }))