@mrmeg/expo-ui 0.26.0 → 0.27.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,174 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.27.1]
9
+
10
+ ### Fixed
11
+
12
+ - **`BottomSheet.Content` paints no fill of its own; the native surface set
13
+ through `backgroundStyle` is the sheet's only background.** `@expo/ui`'s iOS
14
+ sheet lays the hosted RN column out 16 pt below the sheet's top edge while the
15
+ native grabber is shown, so the column's own card fill covered everything
16
+ below that line and the strip behind the grabber showed the native
17
+ `presentationBackground` alone. Opaque theme colors matched and hid the seam,
18
+ but a translucent card, or a translucent `backgroundStyle`, read as two layers
19
+ below the line and one above it: a differently shaded band behind the grabber
20
+ in every app with a glass-style sheet (iPhone 17 simulator, iOS 27, `@expo/ui`
21
+ 58.0.2, 60% card: strip 30,30,32 over a 24,24,27 body; one continuous
22
+ 30,30,32 surface from the rim down after the change). Web (vaul panel) and
23
+ Android (`containerColor`) already paint the surface natively, so the column
24
+ fill was redundant there too. `backgroundStyle={{ backgroundColor:
25
+ "transparent" }}` no longer needs a `style` clearing the column; content sits
26
+ directly on the transparent surface, so add a fill inside the content if a
27
+ card behind it was intended.
28
+ - **iOS `Dialog` and `AlertDialog` present through React Native's `Modal`, so
29
+ `@expo/ui`-hosted controls inside them mount and take focus.** Both dialogs
30
+ rendered through react-native-screens' `FullWindowOverlay` on iOS, which adds
31
+ its container straight to the `UIWindow` with no `UIViewController` above it.
32
+ `expo-modules-core`'s `ExpoSwiftUI.HostingView` attaches its
33
+ `UIHostingController` only when `reactViewController()` finds a parent
34
+ controller and otherwise removes the SwiftUI view, so every hosted control
35
+ inside a dialog — the package `TextInput`, and `Slider` / `SegmentedControl`
36
+ — was a zero-height box: no editable accessibility element, no focus, no
37
+ keyboard (fieldnest `start-trip-modal-keyboard-avoidance`, iOS 27 simulator,
38
+ 0.27.0; reproduced in the template's `Dialog` `form` variant, where the
39
+ accessibility tree listed only the two field labels and a tap raised no
40
+ keyboard, while the same content mounted and focused with the overlay
41
+ replaced by a Fragment — but then did not move for the keyboard, because the
42
+ portal host sits outside `UIProvider`'s root avoidance). `DialogContent` and
43
+ `AlertDialogContent` now present through a transparent, unanimated
44
+ `overFullScreen` `Modal` on iOS, rendered inline where the dialog sits in
45
+ the tree instead of through the portal host: RN presents a `Modal` from the
46
+ view controller nearest its host view, so a portal-hosted `Modal` presents
47
+ from the root controller and silently fails while a native stack modal or
48
+ sheet is up (device-verified — the trigger reported expanded, nothing
49
+ appeared), while the inline one presents from the screen, native stack modal
50
+ or sheet that contains the dialog and stacks above it (device-verified over
51
+ a `presentation: "modal"` route). `portalHost` is honored on Android and web
52
+ only, and a `Dialog` placed inside `FullWindowOverlay`-hosted content
53
+ (`Drawer`, `Popover`, `Select`, `DropdownMenu`, `Tooltip`) has no view
54
+ controller to present from on iOS — render it at screen level and open it
55
+ from the item's `onPress`. The platform close request (`onRequestClose`)
56
+ reaches the root's `onOpenChange(false)`. The Modal is
57
+ outside the root keyboard avoidance, so the dialog owns it there: the
58
+ centered container is wrapped in the package `KeyboardAvoidingView`
59
+ (`behavior="padding"`), the card recenters above the keyboard, and
60
+ `useKeyboardAvoidance()` is `true` inside dialog content — do not wrap dialog
61
+ content in another `KeyboardAvoidingView`. Device-verified on iOS 27: the
62
+ first tap on each field focuses it, the keyboard appears, typing lands, and
63
+ both fields and the footer stay visible. Android and web keep the inline
64
+ portal-host tree unchanged; there the portal host is still outside the root
65
+ avoidance, so an Android dialog does not avoid the keyboard yet (follow-up).
66
+ `Drawer`, `Popover`, `Select`, `DropdownMenu` and `Tooltip` still render
67
+ through `FullWindowOverlay`, so hosted controls inside those do not mount on
68
+ iOS either (follow-up).
69
+ - **Android `BottomSheet` names the host requirement its keyboard avoidance
70
+ depends on.** 0.27.0 said Material owns Android avoidance. That is true for
71
+ the window and, on `expo-modules-core` >= 57.0.4, for the hosted column too:
72
+ Material3's `ModalBottomSheet` shrinks the sheet with `imePadding()`,
73
+ `@expo/ui`'s `RNHostView` re-reports its Compose size to the shadow tree, and
74
+ the `flexGrow: 1, height: 0` column follows, so `Footer` and the tail of
75
+ `Body` stay above the keyboard and `Body` scrolls. `expo-modules-core`
76
+ <= 57.0.3 flushed that report only from a pre-draw listener on the activity
77
+ window, which does not draw while the sheet's own dialog window animates the
78
+ IME, so the column kept its detent height until the activity redrew
79
+ (expo/expo#47778, fixed by expo/expo#47810 in 57.0.4 and 58.0.0; no 56.x
80
+ release has it). That is the clipped `Footer` tractor-tools-direct #32 saw on
81
+ Expo 57 with core 57.0.3, and background/resume "fixing" it. The package adds
82
+ no inset or height of its own — nothing in JS can observe a size the shadow
83
+ tree never received — and instead warns once in development on Android when
84
+ the native core compiled into the app is older (`globalThis.expo`'s
85
+ `expoModulesCoreVersion`), and the docs state the floor: `npx expo install
86
+ --fix` (or `bun update expo-modules-core`), then rebuild. iOS and web are
87
+ untouched.
88
+ - **Android state surfaces no longer re-parent their children on a
89
+ `disabled` / `pending` / `checked` flip.** `Button` (surface and content),
90
+ `TextInput`, `Slider`, `InputOTP` and the `Switch` labels express state as
91
+ `opacity` on a plain `View`. On Fabric a View whose only stacking-context
92
+ prop is that opacity has its children hoisted into the parent while the
93
+ opacity is 1 and pulled back under it when the opacity changes, and a flip
94
+ that raced a navigation pop crashed with `addViewAt: cannot insert view …
95
+ View already has a parent` (doglog #57, Pixel 6a). Those Views now set
96
+ `collapsable={false}` on Android so the native tree shape is fixed across
97
+ state. iOS and web are unchanged. `stateSurfaceProps()` from
98
+ `@mrmeg/expo-ui/lib` is the same guard for app-owned surfaces; RN's
99
+ `Pressable` already pins itself, so `Pressable`-based controls need nothing.
100
+
101
+ - **`BottomSheet` names the ancestor `ScrollView` that swallows the first tap
102
+ on a sheet control while a sheet field is focused (Android).** Sheet content
103
+ is drawn in the Material dialog window but stays in the screen's React tree,
104
+ and React Native's responder negotiation walks that tree: a `ScrollView`
105
+ around the `BottomSheet` on the default `keyboardShouldPersistTaps="never"`
106
+ claims a tap on `Footer` (or any sheet control) in the capture phase once a
107
+ field is focused — RN's `Keyboard` reports the sheet's IME from the activity
108
+ root — and blurs the field on release, so the keyboard closes and `onPress`
109
+ never runs (Pixel_10 / API 36, `@expo/ui` 58.0.2: 0 of 3 Footer taps fired
110
+ inside a default `ScrollView`, 3 of 3 inside
111
+ `keyboardShouldPersistTaps="always"` or a plain `View`; `Body`'s own `always`
112
+ covers only ScrollViews inside the sheet). Nothing inside the tree can preempt
113
+ a capture-phase claim,
114
+ so `BottomSheet.Content` now warns once in development on Android when a
115
+ touch starts on its column without having reached the column's own capture
116
+ handler while a package `TextInput` holds focus — the exact signature of that
117
+ claim — and the docs state the remedy: `keyboardShouldPersistTaps="always"`
118
+ (or `"handled"`) on scroll views that contain a sheet, or `DismissKeyboard`,
119
+ which already sets it. The tap-away boundary itself is unchanged; iOS and web
120
+ are untouched.
121
+
122
+ ## [0.27.0]
123
+
124
+ ### Changed
125
+
126
+ - **`StyledText` is non-selectable by default on Android.** RN implements
127
+ `selectable` with `TextView.setTextIsSelectable(true)`, which makes the label
128
+ focusable-in-touch-mode, so a tap on any default `StyledText` next to a
129
+ focused `TextInput` moved view focus to the label and hid the keyboard
130
+ (tractor-tools-direct #19, mindmap #38 worked around it per label). The
131
+ Android default is now `false`; pass `selectable` to opt copyable content in.
132
+ iOS and web keep `true`, an explicit `selectable` prop still wins everywhere,
133
+ and `TextSelectabilityContext` still overrides the platform default. Consumers
134
+ can drop `selectable={false}` added only to keep the Android keyboard up;
135
+ keeping it on control chrome is harmless and still recommended for iOS/web.
136
+ - **`BottomSheet.Content avoidKeyboard` is documented as platform-owned, with
137
+ the iOS behavior verified on device.** The prop stays accepted-and-ignored,
138
+ like `swipeEnabled` and `dismissKeyboardOnDrag`. On iOS (iPhone 17 Pro, iOS
139
+ 27, `@expo/ui` 58.0.2) UIKit's sheet presentation keeps the hosted RN column
140
+ above the keyboard by itself: a 45% sheet is lifted whole (its column shrinks
141
+ 393 → 363 pt), a 92% sheet is shrunk in place (700 → 468 pt), and in both
142
+ cases the column's bottom edge lands at the keyboard's top, so `Footer` and
143
+ the tail of `Body` sit 50 pt (`spacing.md` + home-indicator inset) above the
144
+ keyboard and a long `Body` scrolls. The package therefore adds no inset, and
145
+ none could be correct from JS: `measureInWindow` inside the `layoutRoot`
146
+ sheet host reports host-relative coordinates (the 45% column read bottom 409
147
+ while sitting at screen y 546), and a window-height estimate would lift a
148
+ lifted sheet twice. Android: Material3's `ModalBottomSheet` owns avoidance
149
+ (no JS keyboard signal exists inside the Compose dialog window: RN reads IME
150
+ insets from the main root view, and keyboard-controller watches only the
151
+ main window and RN `Modal` dialogs). Web: none. Never nest a
152
+ `KeyboardAvoidingView` inside a sheet.
153
+
154
+ ### Fixed
155
+
156
+ - **`TextInput` no longer forwards the Android mount blur.** Compose reports
157
+ `isFocused=false` when the field is first composed and the Android field
158
+ forwarded it as `onBlur`, so forms validating on blur showed "required"
159
+ errors before the user typed anything (tractor-tools-direct #28 gated it in
160
+ app code). `onBlur` now fires only after the field has reported focus, and a
161
+ repeated blur with no focus in between is dropped too. Android only; iOS and
162
+ web are unchanged. Consumers can drop touched-field guards added for this.
163
+ - **`BottomSheet.Body` no longer swallows the first tap on its controls while a
164
+ sheet field is focused on iOS.** `Body`'s `ScrollView` now sets
165
+ `keyboardShouldPersistTaps="always"` (before `{...props}`, so an explicit
166
+ consumer value still wins). Without it RN's default `never` policy claimed the
167
+ tap in the capture phase whenever a registered field was focused and the
168
+ keyboard was visible, then blurred the field on release, so chips, buttons,
169
+ tabs and field-to-field handoff inside `Body` needed a second tap. The
170
+ `BottomSheet.Content` column boundary already owns tap-away dismissal, so
171
+ dead-space taps inside `Body` still dismiss on release and a short drag still
172
+ does not. Android >= 30 was already unaffected: RN reads keyboard insets from
173
+ the main root view, and the Material sheet is its own window, so RN's policy
174
+ never armed there.
175
+
8
176
  ## [0.26.0]
9
177
 
10
178
  ### Changed
package/LLM_USAGE.md CHANGED
@@ -85,13 +85,48 @@ call `dismissKeyboard()` explicitly.
85
85
  SwiftUI `.sheet()`, Android Material3 `ModalBottomSheet`, web `vaul`). The
86
86
  platform owns gestures and keyboard avoidance: `swipeEnabled`, `avoidKeyboard`,
87
87
  and `dismissKeyboardOnDrag` are accepted for call-site ergonomics but have no
88
- effect. `Slider` and `SegmentedControl` are also `@expo/ui`-backed.
88
+ effect. On iOS the sheet presentation lifts or shrinks the hosted content so
89
+ `Footer` and the tail of `Body` end at the keyboard's top (device-verified);
90
+ on Android Material3 shrinks the sheet and `@expo/ui`'s `RNHostView` re-reports
91
+ its size to the shadow tree, which needs the app built against
92
+ `expo-modules-core` >= 57.0.4 (expo/expo#47778) — below that the column keeps
93
+ its detent height and `Footer` sits under the keyboard; the sheet warns once in
94
+ dev on Android, and the fix is `npx expo install --fix` plus a rebuild, never
95
+ padding in app code. Do not nest a `KeyboardAvoidingView` inside a
96
+ sheet. `BottomSheet.Content` mounts the tap-away keyboard-dismiss boundary on
97
+ its column, and `BottomSheet.Body` sets `keyboardShouldPersistTaps="always"` on
98
+ its ScrollView so that boundary owns dismissal; do not pass `never`. Sheet
99
+ content stays in the screen's React tree although it is drawn in another
100
+ window, so a ScrollView *around* a `BottomSheet` on the default
101
+ `keyboardShouldPersistTaps="never"` claims the first tap on any sheet control
102
+ (`Footer` included) while a sheet field is focused and blurs the field instead:
103
+ give scroll views that contain a sheet `keyboardShouldPersistTaps="always"` (or
104
+ `"handled"`), or use `DismissKeyboard`; the sheet warns once in dev on Android
105
+ when it sees such a tap. `Slider` and `SegmentedControl` are also
106
+ `@expo/ui`-backed.
89
107
 
90
108
  `BottomSheet.Content` themes the native sheet surface with the card color. Pass
91
109
  `backgroundStyle={{ backgroundColor: "transparent" }}`, plus a `style` clearing
92
110
  the content column's card fill, when custom chrome such as a glass backdrop must
93
111
  show through.
94
112
 
113
+ `Dialog` and `AlertDialog` present their content through React Native's `Modal`
114
+ on iOS, rendered inline where the dialog sits (transparent, `overFullScreen`,
115
+ unanimated; the package fade/scale run inside), so it stacks above whatever
116
+ screen, native stack modal or sheet contains it; `portalHost` is honored on
117
+ Android and web only. `@expo/ui`-hosted controls (`TextInput`, `Slider`,
118
+ `SegmentedControl`) need a view controller above them and render as empty,
119
+ unfocusable boxes inside react-native-screens' `FullWindowOverlay`, which
120
+ `Drawer`, `Popover`, `Select`, `DropdownMenu` and `Tooltip` still use on iOS —
121
+ keep hosted controls out of those, and do not place a `Dialog` inside their
122
+ content (no view controller to present from; render it at screen level and open
123
+ it from the item's `onPress`). The dialog owns keyboard avoidance inside its Modal (package
124
+ `KeyboardAvoidingView`, `behavior="padding"`; the card recenters above the
125
+ keyboard and `useKeyboardAvoidance()` is `true` in dialog content); do not wrap
126
+ dialog content in another `KeyboardAvoidingView`. Android and web render dialog
127
+ content inline into the portal host, outside the root avoidance, so an Android
128
+ dialog does not avoid the keyboard yet.
129
+
95
130
  i18n is optional. Do not add app-level i18n setup just to use this package;
96
131
  plain children and `text` props work without `i18next` or `react-i18next`. `tx`
97
132
  props render their fallback text when provided, otherwise the key. Package-owned
@@ -258,14 +293,15 @@ Check this before creating a new app-local primitive. All components come from
258
293
  `caption`, `label`, `eyebrow`), `size` (`xs`, `sm`, `base`, `body`, `lg`, `xl`,
259
294
  `xxl`, `display`), `fontWeight` (`light`–`bold`), `variant` (`sansSerif`,
260
295
  `serif`, `mono`), `align`, `text`, `tx`, `txOptions`, `selectable` (default
261
- `true`). Aliases: `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`,
296
+ `true` on iOS and web, `false` on Android since 0.27.0). Aliases: `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`,
262
297
  `BodyText`, `CaptionText`, `LabelText`, `EyebrowText`, `MonoText`, `SerifText`,
263
298
  `SansSerifText`, `SerifBoldText`, `SansSerifBoldText`.
264
299
 
265
- Set `selectable={false}` on text labels inside app-owned `Pressable`s. Selectable
266
- Android text can take input focus even if the press handler never dismisses the
267
- keyboard. Package controls already scope label selectability; ordinary text
268
- should remain selectable.
300
+ Set `selectable={false}` on text labels inside app-owned `Pressable`s on every
301
+ platform (control chrome shows no selection cursor). On Android `StyledText` is
302
+ non-selectable by default because a selectable `Text` takes input focus and hides
303
+ the keyboard; pass `selectable` to opt copyable content in. Package controls
304
+ already scope label selectability; ordinary iOS/web text stays selectable.
269
305
 
270
306
  ## Component Selection Rules
271
307
 
@@ -275,6 +311,8 @@ should remain selectable.
275
311
  - `Carousel` for a horizontal snap row of a known, small set of slides. It renders every child (no virtualization), so slides survive into the exported HTML shell and the first client frame; use `FlatList` for large or unbounded data. Dots are pressable and jump to their slide. A fractional `itemWidth` (default `0.85`) measures the viewport until the first layout, so pass absolute pixels (`> 1`) when the parent is narrower than the window and the first frame matters.
276
312
  - `Avatar` with both `source` and `name` whenever both exist: `name` supplies the initials shown when the image is absent, still loading, or failed, plus the default accessibility label. Inside `AvatarGroup`, set `size`/`shape` on the group — children inherit them and gain the ring; the group's count is a hidden summary node, so each member stays individually announceable and the group needs no `accessible` wrapper.
277
313
  - Pair a standalone `Label` with its control using two DISTINCT ids: `nativeID` is the label's own id, `htmlFor` is the input's id (`<Label nativeID="email-label" htmlFor="email-input">` + `<TextInput nativeID="email-input" />`). One id on both renders duplicate ids on web and associates nothing. Prefer `TextInput`'s own `label` prop when no separate label element is needed.
314
+ - A plain `View` whose `opacity`/`pointerEvents` follows state must be `collapsable={false}` on Android (Fabric re-parents its children on the flip; a flip racing a navigation pop crashes). Package surfaces are pinned; spread `stateSurfaceProps()` from `@mrmeg/expo-ui/lib` on app-owned ones. `Pressable` already pins itself.
315
+ - `TextInput` `onBlur` fires only after a real focus on Android (the native field's first-composition blur is dropped in the package); validate on blur directly and do not add a touched-fields guard in app code.
278
316
  - `Drawer.Header` takes `icon`, `title`, and `action` slots for a compact app-brand row; put `Drawer.ToggleCollapse` in `action` for a trailing rail control. `Drawer.Content` owns safe-area top/bottom padding — do not duplicate it in children.
279
317
 
280
318
  ## Minimal Examples
package/README.md CHANGED
@@ -314,12 +314,17 @@ import { BodyText, CaptionText, HeadingText, StyledText } from "@mrmeg/expo-ui/c
314
314
  - `fontWeight`: `light`, `regular`, `medium`, `semibold`, `bold`
315
315
  - `variant`: `sansSerif`, `serif`, `mono`
316
316
  - `align`, `text`, `tx`, `txOptions`
317
- - `selectable`: defaults to `true`; package controls disable it for labels and
318
- interactive chrome where accidental drag selection would feel broken.
319
-
320
- For app-owned `Pressable` labels, explicitly set `selectable={false}` on the
321
- nested `StyledText`. On Android selectable label text can take focus and the IME
322
- connection from an active input. Preserve selection for ordinary readable text.
317
+ - `selectable`: defaults to `true` on iOS and web and to `false` on Android
318
+ (0.27.0), where a selectable `Text` is focusable and takes focus and the IME
319
+ from an active input. Opt copyable content in per element with `selectable`
320
+ (codes, addresses, chat bubbles). Package controls disable it on every
321
+ platform for labels and interactive chrome where accidental drag selection
322
+ would feel broken.
323
+
324
+ For app-owned `Pressable` labels, still set `selectable={false}` on the nested
325
+ `StyledText` so iOS and web show no selection cursor on control chrome. Ordinary
326
+ readable text keeps selection on iOS and web; on Android it selects only when
327
+ opted in.
323
328
 
324
329
  Aliases: `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`,
325
330
  `BodyText`, `CaptionText`, `LabelText`, `EyebrowText`, `MonoText` (code, IDs,
@@ -553,6 +558,16 @@ web, where the window cannot be read during export or hydration.
553
558
  Native targets add computed hit slop up to 44px. Nested `StyledText` inherits
554
559
  the Button size, so use `size="sm"` for popover, tooltip, and toolbar
555
560
  triggers.
561
+ - A plain `View` whose `opacity` or `pointerEvents` follows state (`disabled`,
562
+ `loading`, `checked`, `editable`) must be `collapsable={false}` on Android.
563
+ Fabric hoists the children of a filled or bordered View into its parent while
564
+ the opacity is 1 and pulls them back when it changes, and a flip racing a
565
+ navigation pop crashes with `addViewAt: … View already has a parent`. The
566
+ package pins its own surfaces (`Button`, `TextInput`, `Slider`, `InputOTP`,
567
+ `Switch` labels); spread `stateSurfaceProps()` from `@mrmeg/expo-ui/lib` on
568
+ app-owned surfaces (`<View {...stateSurfaceProps()} style={{ opacity:
569
+ disabled ? 0.5 : 1 }} />`). `Pressable` already pins itself. iOS and web get
570
+ no prop.
556
571
  - Pair a standalone `Label` with its control using two DISTINCT ids: `nativeID`
557
572
  is the label's own id, `htmlFor` is the input's id (`<Label
558
573
  nativeID="email-label" htmlFor="email-input">` + `<TextInput
@@ -564,25 +579,84 @@ web, where the window cannot be read during export or hydration.
564
579
  `numberPassword` for numeric keyboards) in both the masked and the revealed
565
580
  eye-toggle state, with autocorrect off by default; that Android field is
566
581
  package-owned on top of `@expo/ui/jetpack-compose`'s `BasicTextField`, while
567
- iOS uses `@expo/ui`'s universal field (SwiftUI `SecureField`).
582
+ iOS uses `@expo/ui`'s universal field (SwiftUI `SecureField`). On Android the
583
+ native field reports a blur when it is first composed; the package forwards
584
+ `onBlur` only after the field has reported focus, so blur validation never
585
+ flags an untouched field and app code needs no touched-fields guard.
568
586
  - `BottomSheet` renders the platform's native sheet through `@expo/ui`: iOS
569
587
  SwiftUI `.sheet()`, Android Material3 `ModalBottomSheet`, web `vaul`. The
570
588
  platform owns gestures and keyboard avoidance, so `swipeEnabled`,
571
589
  `avoidKeyboard`, and `dismissKeyboardOnDrag` are accepted for call-site
572
- ergonomics but have no effect. The sheet hosts its content in a separate
590
+ ergonomics but have no effect. On iOS the sheet presentation itself keeps the
591
+ hosted content above the keyboard — a short sheet is lifted whole, a tall one
592
+ is shrunk — so `Footer` and the tail of `Body` end at the keyboard's top with
593
+ only their home-indicator padding as clearance (device-verified on iOS 27 with
594
+ `@expo/ui` 58); on Android Material3's `ModalBottomSheet` shrinks the sheet
595
+ for the keyboard and `@expo/ui`'s `RNHostView` re-reports its size to the
596
+ React Native shadow tree, so the hosted column follows and `Footer` stays
597
+ above the keyboard — this needs the app built against `expo-modules-core`
598
+ >= 57.0.4 (expo/expo#47778; older cores dropped that size update until the
599
+ activity redrew, leaving `Footer` under the keyboard; no 56.x release has the
600
+ fix). Run `npx expo install --fix` (or `bun update expo-modules-core`) and
601
+ rebuild; the sheet warns once in development on Android when the native core
602
+ is older. Web has none. Do not wrap sheet content in another
603
+ `KeyboardAvoidingView`: it would lift twice.
604
+ The sheet hosts its content in a separate
573
605
  native window outside the app's `DismissKeyboard`, so `BottomSheet.Content`
574
606
  mounts its own tap-away keyboard-dismiss boundary on the content column: it
575
607
  never claims the touch (buttons, tabs and other fields fire on the first tap
576
608
  with the keyboard up) and dismisses an unclaimed dead-space tap on release,
577
- matching `DismissKeyboard`. Android has only two snap states (partial /
578
- expanded) and maps extra snap points to the nearest; because Material ignores
579
- percentage snap points, the Android body fills the rendered sheet height
580
- rather than a window-percentage cap. `BottomSheet.Content` also takes
581
- `backgroundStyle`, merged over the themed card default on the native sheet
582
- surface (web panel, Android `containerColor`, iOS `presentationBackground`) —
583
- pass `{ backgroundColor: "transparent" }`, plus a `style` clearing the
584
- content column's card fill, to let custom chrome such as a glass backdrop
585
- show through.
609
+ matching `DismissKeyboard`. `BottomSheet.Body` sets
610
+ `keyboardShouldPersistTaps="always"` on its ScrollView so that column boundary
611
+ owns dismissal; do not pass `never`, which lets RN claim the first tap on a
612
+ body control and blur the field instead. That covers ScrollViews inside the
613
+ sheet only: the sheet's content stays in the screen's React tree even though
614
+ it is drawn in another window, and RN's responder negotiation walks that tree,
615
+ so a ScrollView *around* the `BottomSheet` left on the default
616
+ `keyboardShouldPersistTaps="never"` claims the first tap on any sheet control
617
+ (`Footer` included) while a sheet field is focused and blurs the field instead
618
+ of firing the control. Set `keyboardShouldPersistTaps="always"` (or
619
+ `"handled"`) on scroll views that contain a sheet, or use `DismissKeyboard`,
620
+ which already does; the sheet warns once in development on Android when it
621
+ observes such a swallowed tap. Android has only two snap states
622
+ (partial / expanded) and maps extra snap points to the nearest; because
623
+ Material ignores percentage snap points, the Android body fills the rendered
624
+ sheet height rather than a window-percentage cap. `BottomSheet.Content` also
625
+ takes `backgroundStyle`, merged over the themed card default on the native
626
+ sheet surface (web panel, Android `containerColor`, iOS
627
+ `presentationBackground`). That surface is the sheet's only background: the
628
+ content column paints no fill of its own, so a translucent card reads as one
629
+ layer from the grabber down (a second, column-level fill made the 16 pt
630
+ strip behind the iOS grabber a different shade), and
631
+ `{ backgroundColor: "transparent" }` alone lets custom chrome such as a glass
632
+ backdrop show through.
633
+ - `Dialog` and `AlertDialog` present their content through React Native's
634
+ `Modal` on iOS, rendered inline where the dialog sits in the tree
635
+ (transparent, `overFullScreen`, no animation of its own; the package's fade
636
+ and scale still run inside). The Modal presents a real view controller, which
637
+ `@expo/ui`-hosted controls need: `expo-modules-core`'s SwiftUI hosting view
638
+ drops its content when no `UIViewController` sits above it, so inside
639
+ react-native-screens' `FullWindowOverlay` — which adds its container straight
640
+ to the window — a package `TextInput`, `Slider` or `SegmentedControl`
641
+ rendered as an empty box that could not take focus (device-verified on
642
+ iOS 27). RN presents a `Modal` from the view controller nearest its host
643
+ view, so the dialog stacks above whatever screen, native stack modal or sheet
644
+ contains it (device-verified over a `presentation: "modal"` route);
645
+ `portalHost` is honored on Android and web only. `Drawer`, `Popover`,
646
+ `Select`, `DropdownMenu` and `Tooltip` still use that overlay on iOS, so do
647
+ not put `@expo/ui`-hosted controls inside them yet, and do not place a
648
+ `Dialog` inside their content either — it has no view controller to present
649
+ from there; render it at screen level and open it from the item's
650
+ `onPress`. The iOS Modal is outside `UIProvider`'s root
651
+ keyboard avoidance, so the dialog owns it there: the centered container is a
652
+ package `KeyboardAvoidingView` (`behavior="padding"`), the card recenters
653
+ above the keyboard with its fields and footer visible, and
654
+ `useKeyboardAvoidance()` is `true` inside dialog content. Do not wrap dialog
655
+ content in another `KeyboardAvoidingView`. The platform close request
656
+ (hardware back, TV menu) routes to the root's `onOpenChange(false)`. Android
657
+ and web render dialog content inline into the portal host, which sits
658
+ outside the root avoidance, so an Android dialog does not avoid the keyboard
659
+ yet.
586
660
  - `Carousel` renders every child (no virtualization), so slides survive into
587
661
  the exported HTML shell and the first client frame; use `FlatList` for large
588
662
  or unbounded data. An `itemWidth` below 1 (default `0.85`) is a fraction of
@@ -10,8 +10,28 @@ import { ViewProps, StyleProp, ViewStyle, ScrollViewProps } from "react-native";
10
10
  *
11
11
  * The compound surface (Trigger / Content / Handle / Header / Body / Footer /
12
12
  * Close), controlled + uncontrolled state, and theming match a hand-rolled
13
- * sheet, but the platform owns gestures and keyboard avoidance — so there's no
14
- * PanResponder, snap-physics, or keyboard lift-and-shrink code to maintain.
13
+ * sheet, but the platform owns gestures — so there's no PanResponder or
14
+ * snap-physics code to maintain. Keyboard avoidance is the platform's too.
15
+ * iOS (device-verified, iOS 27 / `@expo/ui` 58): UIKit's sheet presentation
16
+ * keeps the hosted RN column above the keyboard by itself — a short sheet is
17
+ * lifted whole, a tall one is shrunk — so the column's bottom edge lands at the
18
+ * keyboard's top and `Footer` / the tail of `Body` stay reachable with only
19
+ * their own home-indicator padding as clearance. The package adds no inset:
20
+ * nothing in JS can measure the column in screen space (`measureInWindow`
21
+ * inside the `layoutRoot` sheet host reports host-relative coordinates), and a
22
+ * window-height estimate would lift a lifted sheet twice. Android: Material3's
23
+ * `ModalBottomSheet` shrinks the sheet with `imePadding()`, `@expo/ui`'s
24
+ * `RNHostView` re-reports its Compose size to the shadow tree, and the
25
+ * `flexGrow: 1, height: 0` column the host wraps our children in follows, so
26
+ * `Footer` and the tail of `Body` stay above the keyboard. That report is
27
+ * delivered reliably only by `expo-modules-core` >= 57.0.4 (expo/expo#47778,
28
+ * fixed by #47810): older cores flushed it from a pre-draw listener on the
29
+ * activity window, which does not draw while the sheet's dialog window animates
30
+ * the IME, so the column kept its detent height until the activity redrew. The
31
+ * package warns once in dev on Android below that floor
32
+ * (`bottomSheetHostSupport.ts`). No JS keyboard signal exists inside the
33
+ * Compose dialog window, and none is needed. Web: none. Never nest a
34
+ * `KeyboardAvoidingView` in a sheet.
15
35
  *
16
36
  * Platform-owned behaviors (props accepted for ergonomics, but the platform
17
37
  * decides):
@@ -24,6 +44,22 @@ import { ViewProps, StyleProp, ViewStyle, ScrollViewProps } from "react-native";
24
44
  * platform's on native; theming reaches the content + background color.
25
45
  * - On Android only two snap states exist (partial / expanded); extra snap
26
46
  * points map to the nearest of those two.
47
+ * - `Body` sets `keyboardShouldPersistTaps="always"` on its ScrollView so RN's
48
+ * own tap-dismissal never claims the first tap on a chip, button or field
49
+ * while a sheet field is focused; the `Content` boundary owns tap-away
50
+ * dismissal instead. That covers ScrollViews *inside* the sheet only. The
51
+ * sheet's content is drawn in another native window but stays in the
52
+ * screen's React tree, and RN's responder negotiation walks that tree, so a
53
+ * ScrollView *around* the `BottomSheet` with the default
54
+ * `keyboardShouldPersistTaps="never"` claims a tap on any sheet control
55
+ * (`Footer` included) in the capture phase once a field is focused and
56
+ * blurs the field on release: the keyboard closes and the control never
57
+ * fires (Pixel_10 / API 36: 0 of 3 taps inside a default ScrollView, 3 of 3
58
+ * inside `always` or a plain View). Nothing inside the tree can preempt a
59
+ * capture-phase claim, so `Content` warns once in dev on Android when it
60
+ * observes one (`useAncestorClaimWarning`); the remedy is
61
+ * `keyboardShouldPersistTaps="always"` / `"handled"` on scroll views that
62
+ * contain a sheet, or `DismissKeyboard`, which already sets it.
27
63
  *
28
64
  * Scrollable bodies: the native sheet doesn't bound the hosted RN content to
29
65
  * the detent height, so a tall `Body` overflows and clips its footer/tail. When
@@ -113,16 +149,23 @@ interface BottomSheetTriggerProps {
113
149
  interface BottomSheetContentProps extends ViewProps {
114
150
  /** Accepted for call-site ergonomics; ignored (platform owns gestures). */
115
151
  swipeEnabled?: boolean;
116
- /** Accepted for call-site ergonomics; ignored (platform owns keyboard avoidance). */
152
+ /**
153
+ * Accepted for call-site ergonomics; ignored (platform owns keyboard
154
+ * avoidance). iOS: UIKit's sheet presentation lifts or shrinks the hosted
155
+ * content so its bottom edge sits at the keyboard's top (device-verified).
156
+ * Android: Material3's `ModalBottomSheet`. Web: none. `false` has no effect;
157
+ * do not wrap sheet content in another `KeyboardAvoidingView`.
158
+ */
117
159
  avoidKeyboard?: boolean;
118
160
  /** Accepted for call-site ergonomics; ignored (platform owns keyboard). */
119
161
  dismissKeyboardOnDrag?: boolean;
120
162
  /**
121
- * Style for the native sheet surface behind the RN content — the web (vaul)
122
- * panel, Android `containerColor`, iOS `presentationBackground`. Merged over
123
- * the themed card default, so `{ backgroundColor: "transparent" }` lets a
124
- * custom chrome (e.g. a glass backdrop) show through. The RN content column
125
- * paints its own card fill too; clear that via `style`.
163
+ * Style for the native sheet surface — the web (vaul) panel, Android
164
+ * `containerColor`, iOS `presentationBackground`. Merged over the themed card
165
+ * default. This is the sheet's only background: the RN content column paints
166
+ * no fill of its own, so a translucent color here reads as one layer from the
167
+ * grabber down, and `{ backgroundColor: "transparent" }` lets custom chrome
168
+ * (e.g. a glass backdrop) show through with nothing to clear via `style`.
126
169
  */
127
170
  backgroundStyle?: StyleProp<ViewStyle>;
128
171
  style?: StyleProp<ViewStyle>;
@@ -145,6 +188,12 @@ interface BottomSheetCloseProps {
145
188
  children: React.ReactNode;
146
189
  style?: StyleProp<ViewStyle>;
147
190
  }
191
+ /**
192
+ * Dev warning (Android) when an ancestor claimed a tap inside the sheet before
193
+ * the sheet was asked, while a package field held focus. See the header and
194
+ * `useAncestorClaimWarning`.
195
+ */
196
+ export declare const bottomSheetAncestorClaimWarning: string;
148
197
  declare function useBottomSheetContext(): BottomSheetContextValue;
149
198
  declare function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen, snapPoints, closeOnBackdropPress, children, }: BottomSheetProps): React.JSX.Element;
150
199
  declare function BottomSheetTrigger({ asChild, children, style: styleOverride }: BottomSheetTriggerProps): React.JSX.Element;
@@ -8,7 +8,8 @@ import { spacing } from "../constants/spacing.js";
8
8
  import { useScalePress } from "../hooks/useScalePress.js";
9
9
  import { TextColorContext, TextClassContext } from "./StyledText.context";
10
10
  import { Icon } from "./Icon.js";
11
- import { useKeyboardDismissResponder } from "./keyboardDismiss.js";
11
+ import { useAncestorClaimWarning, useKeyboardDismissResponder } from "./keyboardDismiss.js";
12
+ import { warnIfBottomSheetHostDropsResize } from "./bottomSheetHostSupport.js";
12
13
  const DEFAULT_SNAP_POINTS = ["50%"];
13
14
  function clampSnapIndex(index, maxSnapIndex) {
14
15
  return Math.min(Math.max(index, 0), maxSnapIndex);
@@ -86,6 +87,18 @@ function bottomSheetRootReducer(state, action) {
86
87
  // ============================================================================
87
88
  // Context
88
89
  // ============================================================================
90
+ /**
91
+ * Dev warning (Android) when an ancestor claimed a tap inside the sheet before
92
+ * the sheet was asked, while a package field held focus. See the header and
93
+ * `useAncestorClaimWarning`.
94
+ */
95
+ export const bottomSheetAncestorClaimWarning = "@mrmeg/expo-ui BottomSheet: an ancestor claimed a tap inside the sheet before the sheet " +
96
+ "was asked, while a sheet field was focused, so the tapped control did not fire and the " +
97
+ "keyboard was dismissed instead. That is usually a ScrollView around the BottomSheet on React " +
98
+ 'Native\'s default keyboardShouldPersistTaps="never" (sheet content stays in the screen\'s ' +
99
+ "React tree even though it is drawn in another window). Set " +
100
+ 'keyboardShouldPersistTaps="always" or "handled" on scroll views that contain the ' +
101
+ "BottomSheet, or wrap the screen in DismissKeyboard.";
89
102
  const BottomSheetContext = createContext(null);
90
103
  function useBottomSheetContext() {
91
104
  const context = use(BottomSheetContext);
@@ -272,6 +285,15 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
272
285
  // dead-space tap dismisses on release only, through the registered field's
273
286
  // own window-independent blur handle.
274
287
  const dismissResponderProps = useKeyboardDismissResponder();
288
+ // Android, dev, once per session: name an ancestor ScrollView that claimed a
289
+ // tap inside the sheet in the capture phase (nothing in here can preempt it).
290
+ const columnResponderProps = useAncestorClaimWarning(dismissResponderProps, bottomSheetAncestorClaimWarning);
291
+ // Android only, dev only, once per session: name the host floor the column's
292
+ // keyboard avoidance depends on when the app was built against an
293
+ // `expo-modules-core` that drops `RNHostView`'s size update (see the header).
294
+ useEffect(() => {
295
+ warnIfBottomSheetHostDropsResize();
296
+ }, []);
275
297
  // Boolean open → native imperative index. The root resets snapIndex to the
276
298
  // highest point while closed; -1 keeps the native sheet closed.
277
299
  const index = open ? snapIndex : -1;
@@ -287,7 +309,11 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
287
309
  // and Material's `ModalBottomSheet` ignores percentage snap points
288
310
  // (partial / expanded only) — a window-percentage cap would leave the Body
289
311
  // short of the rendered sheet with a blank strip below it. Android therefore
290
- // keeps `flex:1` alone (see the content column style).
312
+ // keeps `flex:1` alone (see the content column style). The same measured
313
+ // height is how the column follows the keyboard: Material shrinks the sheet
314
+ // with `imePadding()` and `RNHostView` re-reports its Compose size to the
315
+ // shadow tree, which needs `expo-modules-core` >= 57.0.4 to always land
316
+ // (expo/expo#47778) — see `warnIfBottomSheetHostDropsResize` above.
291
317
  const expandedSnap = snapPoints[snapPoints.length - 1];
292
318
  const detentHeight = typeof expandedSnap === "number"
293
319
  ? expandedSnap
@@ -321,15 +347,16 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
321
347
  { backgroundColor: theme.colors.card },
322
348
  backgroundStyleOverride,
323
349
  ]), children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { testID: testID, style: [
324
- {
325
- flex: 1,
326
- // Themes the content surface across all platforms regardless of
327
- // native sheet chrome.
328
- backgroundColor: theme.colors.card,
329
- },
350
+ // No fill of its own: the native surface (`backgroundStyle`) is
351
+ // the sheet's only background. On iOS the hosted column starts
352
+ // 16 pt below the sheet's top edge when the native grabber is
353
+ // shown, so a second, column-level fill made a translucent card
354
+ // read as two layers below that line and one above it — a
355
+ // differently colored strip behind the grabber.
356
+ { flex: 1 },
330
357
  Platform.OS !== "android" && { maxHeight: detentHeight },
331
358
  styleOverride,
332
- ], ...dismissResponderProps, children: [children, showFloatingClose && (_jsx(SheetCloseButton, { style: {
359
+ ], ...columnResponderProps, children: [children, showFloatingClose && (_jsx(SheetCloseButton, { style: {
333
360
  // The sheet card sits below the status bar, so no top inset
334
361
  // is needed — just clear the sheet's own top edge / handle.
335
362
  position: "absolute",
@@ -435,7 +462,15 @@ function BottomSheetBody({ children, style, contentContainerStyle, onContentSize
435
462
  paddingBottom: spacing.md + (hasFooter ? 0 : insets.bottom),
436
463
  },
437
464
  contentContainerStyle,
438
- ], showsVerticalScrollIndicator: false, onLayout: (e) => {
465
+ ],
466
+ // The `Content` column's keyboard-dismiss boundary owns tap-away
467
+ // dismissal (see DismissKeyboard). RN's default `never` claims the first
468
+ // tap on any non-input child while a registered field is focused and
469
+ // blurs it on release; `handled` can still blur independently after a
470
+ // non-scrolling drag. `always` leaves every tap to its target and lets
471
+ // dead-space taps bubble to the boundary. Placed before `{...props}` so an
472
+ // explicit consumer value still wins.
473
+ keyboardShouldPersistTaps: "always", showsVerticalScrollIndicator: false, onLayout: (e) => {
439
474
  viewportH.current = e.nativeEvent.layout.height;
440
475
  evaluate();
441
476
  onLayout?.(e);
@@ -11,6 +11,7 @@ import { useFontStyle } from "../hooks/useFontStyle.js";
11
11
  import { useScalePress } from "../hooks/useScalePress.js";
12
12
  import { useThemeStore } from "../state/themeStore.js";
13
13
  import { createThemedStyles } from "../lib/themedStyles.js";
14
+ import { stateSurfaceProps } from "../lib/stateSurface.js";
14
15
  const SIZE_CONFIGS = {
15
16
  sm: {
16
17
  paddingVertical: spacing.xxs,
@@ -181,7 +182,10 @@ function ButtonRoot(props) {
181
182
  return nextWidth;
182
183
  });
183
184
  }, [fullWidth, loading]);
184
- return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TextStyleContext.Provider, { value: labelTextStyle, children: _jsx(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: !!isDisabled, busy: loading }, ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: { alignSelf: fullWidth ? "stretch" : flattenedStyle?.alignSelf ?? "flex-start" }, hitSlop: rest.hitSlop ?? (Platform.OS === "web" ? undefined : getNativeHitSlop(sizeConfig)), disabled: isDisabled, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View, { style: [
185
+ return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TextStyleContext.Provider, { value: labelTextStyle, children: _jsx(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: !!isDisabled, busy: loading }, ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: { alignSelf: fullWidth ? "stretch" : flattenedStyle?.alignSelf ?? "flex-start" }, hitSlop: rest.hitSlop ?? (Platform.OS === "web" ? undefined : getNativeHitSlop(sizeConfig)), disabled: isDisabled, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View
186
+ // Android: fixed native tree across disabled/pressed/loading
187
+ // flips (see `stateSurfaceProps`).
188
+ , { ...stateSurfaceProps(), style: [
185
189
  styles.button,
186
190
  // Host-app radius override (see `setShape`). Sits right
187
191
  // after the static radius so presets, state styles, and —
@@ -205,7 +209,7 @@ function ButtonRoot(props) {
205
209
  loading && restingWidth !== undefined && !fullWidth && { width: restingWidth },
206
210
  // Spread array styles from Slot to prevent nested arrays on web
207
211
  ...(Array.isArray(styleOverride) ? styleOverride : [styleOverride]),
208
- ], onLayout: handleButtonLayout, children: [loading && (_jsx(View, { style: [styles.loaderOverlay, { pointerEvents: "none" }], children: _jsx(ActivityIndicator, { size: "small", color: textColor }) })), _jsxs(View, { style: [styles.content, loading && styles.loadingContent, { pointerEvents: loading ? "none" : "auto" }], children: [!!LeftAccessory && (_jsx(LeftAccessory, { style: styles.leftAccessory, pressableState: state, disabled: isDisabled })), (tx || text) ? (_jsx(StyledText, { tx: tx, text: text, txOptions: txOptions, style: [
212
+ ], onLayout: handleButtonLayout, children: [loading && (_jsx(View, { style: [styles.loaderOverlay, { pointerEvents: "none" }], children: _jsx(ActivityIndicator, { size: "small", color: textColor }) })), _jsxs(View, { ...stateSurfaceProps(), style: [styles.content, loading && styles.loadingContent, { pointerEvents: loading ? "none" : "auto" }], children: [!!LeftAccessory && (_jsx(LeftAccessory, { style: styles.leftAccessory, pressableState: state, disabled: isDisabled })), (tx || text) ? (_jsx(StyledText, { tx: tx, text: text, txOptions: txOptions, style: [
209
213
  labelTextStyle,
210
214
  state.pressed && styles.pressedText,
211
215
  state.pressed && pressedTextStyleOverride,