@butternutbox/pawprint-native 0.10.6 → 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.
@@ -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)
@@ -25,6 +25,10 @@ export default {
25
25
  control: { type: "text" },
26
26
  description: "Help text displayed below the field"
27
27
  },
28
+ inlineHelpText: {
29
+ control: { type: "text" },
30
+ description: "Help text displayed inside the input field"
31
+ },
28
32
  error: {
29
33
  control: { type: "text" },
30
34
  description: "Error message when state is error"
@@ -221,6 +225,25 @@ export const FullWidth = () => {
221
225
  )
222
226
  }
223
227
 
228
+ export const WithInlineHelpText = () => {
229
+ const [value, setValue] = useState(12)
230
+
231
+ return (
232
+ <View style={styles.section}>
233
+ <NumberField
234
+ label="Daily amount"
235
+ size="lg"
236
+ inlineHelpText="~ 840 kcals"
237
+ description="Help text"
238
+ value={String(value)}
239
+ onChangeText={(t) => setValue(Number(t) || 0)}
240
+ onIncrement={() => setValue((v) => v + 1)}
241
+ onDecrement={() => setValue((v) => Math.max(0, v - 1))}
242
+ />
243
+ </View>
244
+ )
245
+ }
246
+
224
247
  export const Playground = {
225
248
  args: {
226
249
  size: "lg",
@@ -5,6 +5,7 @@ import { useTheme } from "@emotion/react"
5
5
  import { IconButton } from "../../atoms/IconButton"
6
6
  import { Add, Remove } from "@butternutbox/pawprint-icons/core"
7
7
  import { resolveFont } from "../../../fonts"
8
+ import { Typography } from "../../atoms/Typography"
8
9
 
9
10
  type NumberFieldSize = "sm" | "lg"
10
11
 
@@ -21,6 +22,7 @@ type NumberFieldInputOwnProps = {
21
22
  decrementDisabled?: boolean
22
23
  min?: number
23
24
  max?: number
25
+ inlineHelpText?: string
24
26
  }
25
27
 
26
28
  export type NumberFieldInputProps = NumberFieldInputOwnProps &
@@ -48,6 +50,7 @@ const StyledFieldWrapper = styled(View)<{
48
50
  }) => ({
49
51
  alignItems: "center",
50
52
  justifyContent: "center",
53
+ position: "relative",
51
54
  width: fullWidth ? "100%" : fieldMinWidth,
52
55
  minWidth: fieldMinWidth,
53
56
  height: fieldHeight,
@@ -79,19 +82,29 @@ const StyledTextInput = styled(TextInput)<{
79
82
  inputFontFamily: string
80
83
  inputFontWeight?: FontWeight
81
84
  inputFontSize: number
82
- }>(({ inputColor, inputFontFamily, inputFontWeight, inputFontSize }) => ({
83
- textAlign: "center" as const,
84
- outlineStyle: "none" as unknown as undefined,
85
- padding: 0,
86
- minWidth: 0,
87
- minHeight: 0,
88
- width: "100%",
89
- height: "100%",
90
- color: inputColor,
91
- fontFamily: inputFontFamily,
92
- ...(inputFontWeight ? { fontWeight: inputFontWeight } : {}),
93
- fontSize: inputFontSize
94
- }))
85
+ hasInlineHelp?: boolean
86
+ }>(
87
+ ({
88
+ inputColor,
89
+ inputFontFamily,
90
+ inputFontWeight,
91
+ inputFontSize,
92
+ hasInlineHelp
93
+ }) => ({
94
+ textAlign: "center" as const,
95
+ outlineStyle: "none" as unknown as undefined,
96
+ padding: 0,
97
+ minWidth: 0,
98
+ minHeight: 0,
99
+ width: "100%",
100
+ height: "100%",
101
+ color: inputColor,
102
+ fontFamily: inputFontFamily,
103
+ ...(inputFontWeight ? { fontWeight: inputFontWeight } : {}),
104
+ fontSize: inputFontSize,
105
+ ...(hasInlineHelp ? { position: "relative", top: -8 } : {})
106
+ })
107
+ )
95
108
 
96
109
  const StyledRow = styled(View)<{
97
110
  rowGap: number
@@ -106,6 +119,8 @@ const StyledRow = styled(View)<{
106
119
  * Renders decrement button, centered numeric input, increment button,
107
120
  * and optional side text (e.g. "kg").
108
121
  *
122
+ * @param {string} [inlineHelpText] - Help text displayed inside the input field at the bottom
123
+ *
109
124
  * Border states:
110
125
  * - default: `colour.field.border.default`
111
126
  * - focused: `colour.field.border.selected`
@@ -130,6 +145,7 @@ export const NumberFieldInput = React.forwardRef<
130
145
  decrementDisabled = false,
131
146
  min,
132
147
  max,
148
+ inlineHelpText,
133
149
  style,
134
150
  onFocus,
135
151
  onBlur,
@@ -252,6 +268,7 @@ export const NumberFieldInput = React.forwardRef<
252
268
  : undefined
253
269
  }
254
270
  inputFontSize={parseTokenValue(typographyToken.fontSize)}
271
+ hasInlineHelp={!!inlineHelpText}
255
272
  keyboardType="numeric"
256
273
  editable={!disabled}
257
274
  onFocus={handleFocus}
@@ -262,6 +279,25 @@ export const NumberFieldInput = React.forwardRef<
262
279
  style={style}
263
280
  {...rest}
264
281
  />
282
+ {inlineHelpText && (
283
+ <View
284
+ style={{
285
+ position: "absolute",
286
+ bottom: 4,
287
+ width: fieldMinWidth,
288
+ display: "flex",
289
+ justifyContent: "center",
290
+ alignItems: "center"
291
+ }}
292
+ >
293
+ <Typography
294
+ token={inputTokens.description.text.default}
295
+ color={inputTokens.colour.description.default}
296
+ >
297
+ {inlineHelpText}
298
+ </Typography>
299
+ </View>
300
+ )}
265
301
  </StyledFieldWrapper>
266
302
  {showIncrementButton && (
267
303
  <IconButton
@@ -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
  }))