@butternutbox/pawprint-native 2.1.0 → 2.3.0

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": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -205,6 +205,9 @@ export const TextInput = React.forwardRef<HTMLInputElement, any>(
205
205
  }
206
206
  )
207
207
  TextInput.displayName = "TextInput"
208
+ ;(TextInput as unknown as { State: unknown }).State = {
209
+ currentlyFocusedInput: () => null
210
+ }
208
211
 
209
212
  // ─── ScrollView ──────────────────────────────────────────────────────────
210
213
  export const ScrollView = React.forwardRef<HTMLDivElement, any>(
@@ -216,11 +219,16 @@ export const ScrollView = React.forwardRef<HTMLDivElement, any>(
216
219
  showsHorizontalScrollIndicator: _showsHorizontalScrollIndicator,
217
220
  showsVerticalScrollIndicator: _showsVerticalScrollIndicator,
218
221
  nestedScrollEnabled: _nestedScrollEnabled,
222
+ keyboardShouldPersistTaps,
219
223
  ..._rest
220
224
  },
221
225
  ref
222
226
  ) => (
223
- <div ref={ref} style={contentContainerStyle}>
227
+ <div
228
+ ref={ref}
229
+ style={contentContainerStyle}
230
+ data-keyboard-should-persist-taps={keyboardShouldPersistTaps}
231
+ >
224
232
  {children}
225
233
  </div>
226
234
  )
@@ -310,6 +318,7 @@ export const Animated = {
310
318
  )
311
319
  }
312
320
  ),
321
+ ScrollView,
313
322
  Value: AnimatedValue,
314
323
  timing: (_value: any, _config: any) => ({
315
324
  start: mockAnim,
@@ -176,6 +176,7 @@ const IconButton = React.forwardRef<View, IconButtonProps>(
176
176
  <StyledIconButton
177
177
  ref={ref}
178
178
  disabled={isDisabled}
179
+ accessibilityRole="button"
179
180
  accessibilityLabel={ariaLabel}
180
181
  accessibilityState={{ disabled: isDisabled, busy: loading }}
181
182
  onPressIn={() => setPressed(true)}
@@ -1,6 +1,6 @@
1
- import React, { useCallback, useMemo, useState } from "react"
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
2
  import { format, isBefore, isSameDay, isToday, type Locale } from "date-fns"
3
- import { View, Pressable, ViewProps } from "react-native"
3
+ import { AccessibilityInfo, View, Pressable, ViewProps } from "react-native"
4
4
  import styled from "@emotion/native"
5
5
  import { useTheme } from "@emotion/react"
6
6
  import {
@@ -52,6 +52,10 @@ const MONTH_SLIDE_EXITING = {
52
52
  backward: FadeOutRight.duration(200)
53
53
  } as const
54
54
 
55
+ // Moving screen-reader focus straight after a tap or a sheet closing is
56
+ // ignored by VoiceOver, so wait for it to settle first.
57
+ const FOCUS_DELAY_MS = 150
58
+
55
59
  const DEFAULT_WEEK_LABELS: [
56
60
  string,
57
61
  string,
@@ -121,7 +125,13 @@ type DatePickerOwnProps = {
121
125
  getMonthLabel?: (year: number, month: number, locale?: Locale) => string
122
126
  weekDayLabels?: [string, string, string, string, string, string, string]
123
127
  getDateState?: (date: Date) => DateItemState
128
+ getDateAccessibilityLabel?: (
129
+ date: Date,
130
+ state: DateItemState,
131
+ monthLabel: string
132
+ ) => string
124
133
  isDateDisabled?: (date: Date) => boolean
134
+ focusDate?: Date | null
125
135
  minDate?: Date
126
136
  maxDate?: Date
127
137
  }
@@ -229,6 +239,10 @@ const StyledIndicator = styled(View)<{
229
239
  * @param {[string,string,string,string,string,string,string]} [weekDayLabels] - Day-column labels Mon→Sun.
230
240
  * @param {(date: Date) => DateItemState} [getDateState] - Returns the visual state for a date.
231
241
  * Defaults to: past="previous", today="current", future="default".
242
+ * @param {(date: Date, state: DateItemState, monthLabel: string) => string} [getDateAccessibilityLabel] - Returns the screen-reader label for a date,
243
+ * e.g. to announce delivery states. Defaults to "{day} {monthLabel}".
244
+ * @param {Date | null} [focusDate] - Moves screen-reader focus to this date's cell when it is set or
245
+ * changes, e.g. to return focus to a picked date after a confirmation sheet closes.
232
246
  * @param {(date: Date) => boolean} [isDateDisabled] - Returns true to disable a date.
233
247
  * @param {Date} [minDate] - Dates before this (exclusive of time) will be disabled.
234
248
  * @param {Date} [maxDate] - Dates after this (exclusive of time) will be disabled.
@@ -270,7 +284,9 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
270
284
  getMonthLabel,
271
285
  weekDayLabels = DEFAULT_WEEK_LABELS,
272
286
  getDateState,
287
+ getDateAccessibilityLabel,
273
288
  isDateDisabled,
289
+ focusDate,
274
290
  minDate,
275
291
  maxDate,
276
292
  ...rest
@@ -312,6 +328,23 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
312
328
  [datePicker, dt, theme]
313
329
  )
314
330
 
331
+ const cellRefs = useRef(new Map<string, View>())
332
+ const focusTime = focusDate?.getTime()
333
+
334
+ useEffect(() => {
335
+ if (focusTime === undefined) return
336
+
337
+ const target = new Date(focusTime)
338
+ if (target.getFullYear() !== year || target.getMonth() !== month) return
339
+
340
+ const timeout = setTimeout(() => {
341
+ const cell = cellRefs.current.get(toDateKey(target))
342
+ if (cell) AccessibilityInfo.sendAccessibilityEvent(cell, "focus")
343
+ }, FOCUS_DELAY_MS)
344
+
345
+ return () => clearTimeout(timeout)
346
+ }, [focusTime, year, month])
347
+
315
348
  const resolvedGetDateState = useCallback(
316
349
  (date: Date) => getDateState?.(date) ?? defaultGetDateState(date),
317
350
  [getDateState]
@@ -470,6 +503,7 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
470
503
  token={datePicker.month.typography}
471
504
  align="center"
472
505
  color={datePicker.month.colour.text.default}
506
+ accessibilityRole="header"
473
507
  >
474
508
  {resolvedMonthLabel}
475
509
  </Typography>
@@ -493,11 +527,7 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
493
527
  entering={MONTH_SLIDE_ENTERING[slideDir]}
494
528
  exiting={MONTH_SLIDE_EXITING[slideDir]}
495
529
  >
496
- <StyledDatesGrid
497
- rowGap={rowGap}
498
- accessible
499
- accessibilityLabel={resolvedMonthLabel}
500
- >
530
+ <StyledDatesGrid rowGap={rowGap}>
501
531
  {calendarRows.map((row, rowIdx) => (
502
532
  <StyledDateRow key={rowIdx}>
503
533
  {row.map((date, colIdx) =>
@@ -539,6 +569,11 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
539
569
 
540
570
  return (
541
571
  <Pressable
572
+ ref={(node) => {
573
+ const key = toDateKey(date)
574
+ if (node) cellRefs.current.set(key, node)
575
+ else cellRefs.current.delete(key)
576
+ }}
542
577
  onPress={
543
578
  !isDisabled
544
579
  ? () => onDateSelect?.(date)
@@ -547,7 +582,13 @@ export const DatePicker = React.forwardRef<View, DatePickerProps>(
547
582
  disabled={isDisabled}
548
583
  accessible
549
584
  accessibilityRole="button"
550
- accessibilityLabel={`${date.getDate()} ${resolvedMonthLabel}`}
585
+ accessibilityLabel={
586
+ getDateAccessibilityLabel?.(
587
+ date,
588
+ state,
589
+ resolvedMonthLabel
590
+ ) ?? `${date.getDate()} ${resolvedMonthLabel}`
591
+ }
551
592
  accessibilityState={{
552
593
  selected: isSelected,
553
594
  disabled: isDisabled
@@ -90,6 +90,69 @@ describe("Drawer keyboard lift", () => {
90
90
 
91
91
  expect(screen.getByText("Row one")).toBeInTheDocument()
92
92
  })
93
+
94
+ it("keeps its content once the keyboard has settled", () => {
95
+ renderWithTheme(
96
+ <Drawer.Root defaultOpen>
97
+ <Drawer.Portal>
98
+ <Drawer.Content>
99
+ <Drawer.Body>
100
+ <Typography>Row one</Typography>
101
+ </Drawer.Body>
102
+ </Drawer.Content>
103
+ </Drawer.Portal>
104
+ </Drawer.Root>
105
+ )
106
+
107
+ act(() => {
108
+ Keyboard.emit("keyboardDidShow", {
109
+ endCoordinates: { height: 336 },
110
+ duration: 250
111
+ })
112
+ })
113
+
114
+ expect(screen.getByText("Row one")).toBeInTheDocument()
115
+ })
116
+ })
117
+
118
+ describe("Drawer.Body taps", () => {
119
+ const persistTapsOf = (text: string) =>
120
+ screen
121
+ .getByText(text)
122
+ .closest("[data-keyboard-should-persist-taps]")
123
+ ?.getAttribute("data-keyboard-should-persist-taps")
124
+
125
+ it("lets taps reach its controls without dismissing the keyboard", () => {
126
+ renderWithTheme(
127
+ <Drawer.Root defaultOpen>
128
+ <Drawer.Portal>
129
+ <Drawer.Content>
130
+ <Drawer.Body>
131
+ <Typography>Row one</Typography>
132
+ </Drawer.Body>
133
+ </Drawer.Content>
134
+ </Drawer.Portal>
135
+ </Drawer.Root>
136
+ )
137
+
138
+ expect(persistTapsOf("Row one")).toBe("handled")
139
+ })
140
+
141
+ it("respects a consumer's keyboardShouldPersistTaps", () => {
142
+ renderWithTheme(
143
+ <Drawer.Root defaultOpen>
144
+ <Drawer.Portal>
145
+ <Drawer.Content>
146
+ <Drawer.Body keyboardShouldPersistTaps="never">
147
+ <Typography>Row one</Typography>
148
+ </Drawer.Body>
149
+ </Drawer.Content>
150
+ </Drawer.Portal>
151
+ </Drawer.Root>
152
+ )
153
+
154
+ expect(persistTapsOf("Row one")).toBe("never")
155
+ })
93
156
  })
94
157
 
95
158
  describe("Drawer", () => {
@@ -1,46 +1,62 @@
1
- import React from "react"
2
- import { ScrollView, ScrollViewProps, useWindowDimensions } from "react-native"
1
+ import React, { useCallback, useEffect, useMemo, useRef } from "react"
2
+ import {
3
+ Animated,
4
+ Keyboard,
5
+ ScrollView,
6
+ ScrollViewProps,
7
+ StyleSheet,
8
+ TextInput,
9
+ useWindowDimensions,
10
+ View,
11
+ type LayoutChangeEvent,
12
+ type NativeScrollEvent,
13
+ type NativeSyntheticEvent
14
+ } from "react-native"
3
15
  import styled from "@emotion/native"
4
16
  import { useTheme } from "@emotion/react"
5
17
  import { useSafeAreaInsets } from "react-native-safe-area-context"
6
18
  import { useDrawerHeaderContext } from "./DrawerHeaderContext"
7
19
  import { useDrawerFooterContext } from "./DrawerFooterContext"
8
20
  import { useDrawerBodyMax } from "./DrawerBodyMaxContext"
21
+ import { useDrawerKeyboardInset } from "./DrawerKeyboardInsetContext"
9
22
 
10
23
  export type DrawerBodyProps = ScrollViewProps
11
24
 
12
25
  const parseTokenValue = (value: string): number => parseFloat(value)
13
26
 
14
- const StyledScrollView = styled(ScrollView)<{
27
+ const StyledScrollView = styled(Animated.ScrollView)<{
15
28
  bodyPaddingHorizontal: number
16
29
  bodyPaddingRight: number
17
- bodyMaxHeight: number
18
30
  bodyPaddingBottom: number
19
- }>(
20
- ({
21
- bodyPaddingHorizontal,
22
- bodyPaddingRight,
23
- bodyMaxHeight,
24
- bodyPaddingBottom
25
- }) => ({
26
- // No `flex: 1`: the ScrollView self-sizes to its content (up to
27
- // `maxHeight`), so the panel wraps tight around short content and scrolls
28
- // only when content exceeds the chrome-aware cap.
29
- flexGrow: 0,
30
- flexShrink: 1,
31
- paddingLeft: bodyPaddingHorizontal,
32
- paddingRight: bodyPaddingRight,
33
- maxHeight: bodyMaxHeight,
34
- paddingBottom: bodyPaddingBottom
35
- })
36
- )
31
+ }>(({ bodyPaddingHorizontal, bodyPaddingRight, bodyPaddingBottom }) => ({
32
+ // No `flex: 1`: the ScrollView self-sizes to its content (up to
33
+ // `maxHeight`), so the panel wraps tight around short content and scrolls
34
+ // only when content exceeds the chrome-aware cap.
35
+ flexGrow: 0,
36
+ flexShrink: 1,
37
+ paddingLeft: bodyPaddingHorizontal,
38
+ paddingRight: bodyPaddingRight,
39
+ paddingBottom: bodyPaddingBottom
40
+ }))
37
41
 
38
42
  /**
39
43
  * Scrollable content area of the drawer. Sizes to its content up to a
40
44
  * chrome-aware `maxHeight`, scrolling beyond that.
41
45
  */
42
46
  export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
43
- ({ children, contentContainerStyle, ...props }, ref) => {
47
+ (
48
+ {
49
+ children,
50
+ contentContainerStyle,
51
+ keyboardShouldPersistTaps = "handled",
52
+ scrollEventThrottle = 16,
53
+ onScroll,
54
+ onLayout,
55
+ style,
56
+ ...props
57
+ },
58
+ ref
59
+ ) => {
44
60
  const theme = useTheme()
45
61
  const { spacing } = theme.tokens.components.drawer
46
62
  const { buttons } = theme.tokens.components
@@ -48,6 +64,7 @@ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
48
64
  const headerContext = useDrawerHeaderContext()
49
65
  const footerContext = useDrawerFooterContext()
50
66
  const providedMaxHeight = useDrawerBodyMax()
67
+ const keyboardInset = useDrawerKeyboardInset()
51
68
  const { height: windowHeight } = useWindowDimensions()
52
69
  const insets = useSafeAreaInsets()
53
70
 
@@ -60,6 +77,90 @@ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
60
77
  // chrome has been measured, so the body still scrolls on the first layout.
61
78
  const bodyMaxHeight = providedMaxHeight ?? windowHeight - 300
62
79
 
80
+ const { maxHeight: styleMaxHeight, ...restStyle } =
81
+ StyleSheet.flatten(style) ?? {}
82
+ const cap =
83
+ typeof styleMaxHeight === "number"
84
+ ? Math.min(styleMaxHeight, bodyMaxHeight)
85
+ : bodyMaxHeight
86
+
87
+ const maxHeight = useMemo(() => {
88
+ if (!keyboardInset || bodyMaxHeight <= 0) return cap
89
+ const slack = bodyMaxHeight - cap
90
+ return keyboardInset.interpolate({
91
+ inputRange: slack > 0 ? [0, slack, bodyMaxHeight] : [0, bodyMaxHeight],
92
+ outputRange: slack > 0 ? [cap, cap, 0] : [cap, 0],
93
+ extrapolate: "clamp"
94
+ })
95
+ }, [keyboardInset, bodyMaxHeight, cap])
96
+
97
+ const scrollRef = useRef<ScrollView | null>(null)
98
+ const contentRef = useRef<View>(null)
99
+ const scrollOffsetRef = useRef(0)
100
+ const viewportHeightRef = useRef(0)
101
+
102
+ const combinedRef = useCallback(
103
+ (node: ScrollView | null) => {
104
+ scrollRef.current = node
105
+ if (typeof ref === "function") {
106
+ ref(node)
107
+ } else if (ref) {
108
+ ;(ref as React.MutableRefObject<ScrollView | null>).current = node
109
+ }
110
+ },
111
+ [ref]
112
+ )
113
+
114
+ const handleScroll = useCallback(
115
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
116
+ scrollOffsetRef.current = event.nativeEvent.contentOffset.y
117
+ onScroll?.(event)
118
+ },
119
+ [onScroll]
120
+ )
121
+
122
+ const handleLayout = useCallback(
123
+ (event: LayoutChangeEvent) => {
124
+ viewportHeightRef.current = event.nativeEvent.layout.height
125
+ onLayout?.(event)
126
+ },
127
+ [onLayout]
128
+ )
129
+
130
+ useEffect(() => {
131
+ const subscription = Keyboard.addListener("keyboardDidShow", () => {
132
+ const scrollView = scrollRef.current
133
+ const focusedInput = TextInput.State.currentlyFocusedInput()
134
+ const contentView = contentRef.current
135
+ if (!scrollView || !focusedInput || !contentView) return
136
+
137
+ focusedInput.measureLayout(
138
+ contentView,
139
+ (_left, inputTop, _width, inputHeight) => {
140
+ const viewportHeight = viewportHeightRef.current
141
+ if (viewportHeight <= 0) return
142
+
143
+ const visibleTop = scrollOffsetRef.current
144
+ const visibleBottom = visibleTop + viewportHeight
145
+ const wantedTop = inputTop - gap
146
+ const wantedBottom = inputTop + inputHeight + gap
147
+
148
+ if (wantedTop < visibleTop) {
149
+ scrollView.scrollTo({ y: Math.max(0, wantedTop), animated: true })
150
+ } else if (wantedBottom > visibleBottom) {
151
+ scrollView.scrollTo({
152
+ y: Math.min(wantedTop, wantedBottom - viewportHeight),
153
+ animated: true
154
+ })
155
+ }
156
+ },
157
+ () => {}
158
+ )
159
+ })
160
+
161
+ return () => subscription.remove()
162
+ }, [gap])
163
+
63
164
  // When there's no header the close button floats in the top-right corner.
64
165
  // Reserve space on the right so body content doesn't slide under it.
65
166
  const paddingRight =
@@ -82,11 +183,12 @@ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
82
183
 
83
184
  return (
84
185
  <StyledScrollView
85
- ref={ref}
186
+ ref={combinedRef}
187
+ innerViewRef={contentRef as React.RefObject<View>}
86
188
  bodyPaddingHorizontal={horizontalPadding}
87
189
  bodyPaddingRight={paddingRight}
88
- bodyMaxHeight={bodyMaxHeight}
89
190
  bodyPaddingBottom={bodyPaddingBottom}
191
+ style={[restStyle, { maxHeight }]}
90
192
  contentContainerStyle={[
91
193
  {
92
194
  gap,
@@ -95,6 +197,10 @@ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
95
197
  },
96
198
  contentContainerStyle
97
199
  ]}
200
+ keyboardShouldPersistTaps={keyboardShouldPersistTaps}
201
+ scrollEventThrottle={scrollEventThrottle}
202
+ onScroll={handleScroll}
203
+ onLayout={handleLayout}
98
204
  {...props}
99
205
  >
100
206
  {children}
@@ -17,6 +17,7 @@ import { DrawerMeasureContext } from "./DrawerMeasureContext"
17
17
  import { useDrawerContext } from "./DrawerContext"
18
18
  import { DrawerBodyMaxContext } from "./DrawerBodyMaxContext"
19
19
  import { DrawerFooterContext } from "./DrawerFooterContext"
20
+ import { DrawerKeyboardInsetContext } from "./DrawerKeyboardInsetContext"
20
21
  import {
21
22
  DrawerHeaderContext,
22
23
  type DrawerHeaderVariant
@@ -31,6 +32,8 @@ const DISMISS_THRESHOLD = 80
31
32
 
32
33
  const parseTokenValue = (value: string): number => parseFloat(value)
33
34
 
35
+ const claimTouch = () => true
36
+
34
37
  const StyledPanel = styled(View)<{
35
38
  panelBorderRadius: number
36
39
  panelBgColor: string
@@ -111,9 +114,8 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
111
114
  const { isOpen, closeDrawer, onExitComplete } = useDrawerContext()
112
115
 
113
116
  const { height: windowHeight } = useWindowDimensions()
114
- const [keyboardHeight, setKeyboardHeight] = useState(0)
115
117
  const keyboardSpacer = useRef(new Animated.Value(0)).current
116
- const maxHeight = (windowHeight - keyboardHeight) * 0.9
118
+ const maxHeight = windowHeight * 0.9
117
119
 
118
120
  // Constant parts of the chrome that aren't a measured section: the panel's
119
121
  // top border, and (when there's no footer) the body's own bottom padding.
@@ -178,11 +180,9 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
178
180
  }
179
181
 
180
182
  const shown = Keyboard.addListener(showEvent, (event) => {
181
- setKeyboardHeight(event.endCoordinates.height)
182
183
  lift(event.endCoordinates.height, event.duration)
183
184
  })
184
185
  const hidden = Keyboard.addListener(hideEvent, (event) => {
185
- setKeyboardHeight(0)
186
186
  lift(0, event.duration)
187
187
  })
188
188
 
@@ -427,19 +427,21 @@ export const DrawerContent = React.forwardRef<View, DrawerContentProps>(
427
427
  panelBorderRadius={parseTokenValue(drawer.borderRadius.top)}
428
428
  panelBgColor={colour.background.container.default}
429
429
  panelMaxWidth={parseTokenValue(drawer.size.maxWidth)}
430
- panelMaxHeight={maxHeight + keyboardHeight}
430
+ panelMaxHeight={maxHeight}
431
431
  panelBorderWidth={parseTokenValue(dimensions.borderWidth.sm)}
432
432
  panelBorderColor={colour.border.default}
433
433
  panelShadowColor={modal.shadow.color}
434
434
  panelShadowOffsetY={parseTokenValue(modal.shadow.offsetY)}
435
435
  panelShadowBlur={parseTokenValue(modal.shadow.blur)}
436
- accessible
437
- accessibilityRole="none"
438
436
  accessibilityViewIsModal
437
+ onAccessibilityEscape={closeDrawer}
438
+ onStartShouldSetResponder={claimTouch}
439
439
  {...props}
440
440
  >
441
441
  <DrawerBodyMaxContext.Provider value={bodyMax}>
442
- {children}
442
+ <DrawerKeyboardInsetContext.Provider value={keyboardSpacer}>
443
+ {children}
444
+ </DrawerKeyboardInsetContext.Provider>
443
445
  </DrawerBodyMaxContext.Provider>
444
446
  <Animated.View style={{ height: keyboardSpacer }} />
445
447
  </StyledPanel>
@@ -0,0 +1,9 @@
1
+ import { createContext, useContext } from "react"
2
+ import type { Animated } from "react-native"
3
+
4
+ export const DrawerKeyboardInsetContext = createContext<Animated.Value | null>(
5
+ null
6
+ )
7
+
8
+ export const useDrawerKeyboardInset = (): Animated.Value | null =>
9
+ useContext(DrawerKeyboardInsetContext)
@@ -180,9 +180,20 @@ export const SelectField = React.forwardRef<View, SelectFieldProps>(
180
180
  )
181
181
  }, [windowHeight, keyboardHeight, maxDropdownHeight])
182
182
 
183
+ const isOpen = isFocused && !disabled
184
+
183
185
  React.useEffect(() => {
184
- if (isFocused) measurePlacement()
185
- }, [isFocused, measurePlacement])
186
+ if (!isOpen) return
187
+
188
+ let frame = 0
189
+ const track = () => {
190
+ measurePlacement()
191
+ frame = requestAnimationFrame(track)
192
+ }
193
+ track()
194
+
195
+ return () => cancelAnimationFrame(frame)
196
+ }, [isOpen, measurePlacement])
186
197
 
187
198
  React.useEffect(() => {
188
199
  if (!isControlled || isFocused) return
@@ -251,7 +262,6 @@ export const SelectField = React.forwardRef<View, SelectFieldProps>(
251
262
  }
252
263
 
253
264
  const fieldState: InputState = error ? "error" : state
254
- const isOpen = isFocused && !disabled
255
265
  const clearAction =
256
266
  !disabled && inputText.length > 0 ? (
257
267
  <Pressable