@mrmeg/expo-ui 0.27.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 +114 -0
- package/LLM_USAGE.md +33 -3
- package/README.md +63 -7
- package/dist/components/BottomSheet.d.ts +37 -8
- package/dist/components/BottomSheet.js +36 -9
- package/dist/components/Button.js +6 -2
- package/dist/components/Dialog.js +103 -25
- package/dist/components/InputOTP.js +2 -1
- package/dist/components/Slider.js +2 -1
- package/dist/components/Switch.js +3 -2
- package/dist/components/TextInput.js +2 -1
- package/dist/components/bottomSheetHostSupport.d.ts +46 -0
- package/dist/components/bottomSheetHostSupport.js +67 -0
- package/dist/components/keyboardDismiss.d.ts +25 -0
- package/dist/components/keyboardDismiss.js +49 -0
- package/dist/design-system.json +1 -1
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/stateSurface.d.ts +28 -0
- package/dist/lib/stateSurface.js +30 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,120 @@ 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
|
+
|
|
8
122
|
## [0.27.0]
|
|
9
123
|
|
|
10
124
|
### Changed
|
package/LLM_USAGE.md
CHANGED
|
@@ -87,17 +87,46 @@ platform owns gestures and keyboard avoidance: `swipeEnabled`, `avoidKeyboard`,
|
|
|
87
87
|
and `dismissKeyboardOnDrag` are accepted for call-site ergonomics but have no
|
|
88
88
|
effect. On iOS the sheet presentation lifts or shrinks the hosted content so
|
|
89
89
|
`Footer` and the tail of `Body` end at the keyboard's top (device-verified);
|
|
90
|
-
Android
|
|
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
|
|
91
96
|
sheet. `BottomSheet.Content` mounts the tap-away keyboard-dismiss boundary on
|
|
92
97
|
its column, and `BottomSheet.Body` sets `keyboardShouldPersistTaps="always"` on
|
|
93
|
-
its ScrollView so that boundary owns dismissal; do not pass `never`.
|
|
94
|
-
|
|
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.
|
|
95
107
|
|
|
96
108
|
`BottomSheet.Content` themes the native sheet surface with the card color. Pass
|
|
97
109
|
`backgroundStyle={{ backgroundColor: "transparent" }}`, plus a `style` clearing
|
|
98
110
|
the content column's card fill, when custom chrome such as a glass backdrop must
|
|
99
111
|
show through.
|
|
100
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
|
+
|
|
101
130
|
i18n is optional. Do not add app-level i18n setup just to use this package;
|
|
102
131
|
plain children and `text` props work without `i18next` or `react-i18next`. `tx`
|
|
103
132
|
props render their fallback text when provided, otherwise the key. Package-owned
|
|
@@ -282,6 +311,7 @@ already scope label selectability; ordinary iOS/web text stays selectable.
|
|
|
282
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.
|
|
283
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.
|
|
284
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.
|
|
285
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.
|
|
286
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.
|
|
287
317
|
|
package/README.md
CHANGED
|
@@ -558,6 +558,16 @@ web, where the window cannot be read during export or hydration.
|
|
|
558
558
|
Native targets add computed hit slop up to 44px. Nested `StyledText` inherits
|
|
559
559
|
the Button size, so use `size="sm"` for popover, tooltip, and toolbar
|
|
560
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.
|
|
561
571
|
- Pair a standalone `Label` with its control using two DISTINCT ids: `nativeID`
|
|
562
572
|
is the label's own id, `htmlFor` is the input's id (`<Label
|
|
563
573
|
nativeID="email-label" htmlFor="email-input">` + `<TextInput
|
|
@@ -581,9 +591,16 @@ web, where the window cannot be read during export or hydration.
|
|
|
581
591
|
hosted content above the keyboard — a short sheet is lifted whole, a tall one
|
|
582
592
|
is shrunk — so `Footer` and the tail of `Body` end at the keyboard's top with
|
|
583
593
|
only their home-indicator padding as clearance (device-verified on iOS 27 with
|
|
584
|
-
`@expo/ui` 58); on Android Material3's `ModalBottomSheet`
|
|
585
|
-
|
|
586
|
-
|
|
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.
|
|
587
604
|
The sheet hosts its content in a separate
|
|
588
605
|
native window outside the app's `DismissKeyboard`, so `BottomSheet.Content`
|
|
589
606
|
mounts its own tap-away keyboard-dismiss boundary on the content column: it
|
|
@@ -592,15 +609,54 @@ web, where the window cannot be read during export or hydration.
|
|
|
592
609
|
matching `DismissKeyboard`. `BottomSheet.Body` sets
|
|
593
610
|
`keyboardShouldPersistTaps="always"` on its ScrollView so that column boundary
|
|
594
611
|
owns dismissal; do not pass `never`, which lets RN claim the first tap on a
|
|
595
|
-
body control and blur the field instead.
|
|
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
|
|
596
622
|
(partial / expanded) and maps extra snap points to the nearest; because
|
|
597
623
|
Material ignores percentage snap points, the Android body fills the rendered
|
|
598
624
|
sheet height rather than a window-percentage cap. `BottomSheet.Content` also
|
|
599
625
|
takes `backgroundStyle`, merged over the themed card default on the native
|
|
600
626
|
sheet surface (web panel, Android `containerColor`, iOS
|
|
601
|
-
`presentationBackground`)
|
|
602
|
-
|
|
603
|
-
a
|
|
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.
|
|
604
660
|
- `Carousel` renders every child (no virtualization), so slides survive into
|
|
605
661
|
the exported HTML shell and the first client frame; use `FlatList` for large
|
|
606
662
|
or unbounded data. An `itemWidth` below 1 (default `0.85`) is a fraction of
|
|
@@ -20,8 +20,18 @@ import { ViewProps, StyleProp, ViewStyle, ScrollViewProps } from "react-native";
|
|
|
20
20
|
* nothing in JS can measure the column in screen space (`measureInWindow`
|
|
21
21
|
* inside the `layoutRoot` sheet host reports host-relative coordinates), and a
|
|
22
22
|
* window-height estimate would lift a lifted sheet twice. Android: Material3's
|
|
23
|
-
* `ModalBottomSheet`
|
|
24
|
-
*
|
|
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.
|
|
25
35
|
*
|
|
26
36
|
* Platform-owned behaviors (props accepted for ergonomics, but the platform
|
|
27
37
|
* decides):
|
|
@@ -37,7 +47,19 @@ import { ViewProps, StyleProp, ViewStyle, ScrollViewProps } from "react-native";
|
|
|
37
47
|
* - `Body` sets `keyboardShouldPersistTaps="always"` on its ScrollView so RN's
|
|
38
48
|
* own tap-dismissal never claims the first tap on a chip, button or field
|
|
39
49
|
* while a sheet field is focused; the `Content` boundary owns tap-away
|
|
40
|
-
* dismissal instead.
|
|
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.
|
|
41
63
|
*
|
|
42
64
|
* Scrollable bodies: the native sheet doesn't bound the hosted RN content to
|
|
43
65
|
* the detent height, so a tall `Body` overflows and clips its footer/tail. When
|
|
@@ -138,11 +160,12 @@ interface BottomSheetContentProps extends ViewProps {
|
|
|
138
160
|
/** Accepted for call-site ergonomics; ignored (platform owns keyboard). */
|
|
139
161
|
dismissKeyboardOnDrag?: boolean;
|
|
140
162
|
/**
|
|
141
|
-
* Style for the native sheet surface
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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`.
|
|
146
169
|
*/
|
|
147
170
|
backgroundStyle?: StyleProp<ViewStyle>;
|
|
148
171
|
style?: StyleProp<ViewStyle>;
|
|
@@ -165,6 +188,12 @@ interface BottomSheetCloseProps {
|
|
|
165
188
|
children: React.ReactNode;
|
|
166
189
|
style?: StyleProp<ViewStyle>;
|
|
167
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;
|
|
168
197
|
declare function useBottomSheetContext(): BottomSheetContextValue;
|
|
169
198
|
declare function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen, snapPoints, closeOnBackdropPress, children, }: BottomSheetProps): React.JSX.Element;
|
|
170
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
], ...
|
|
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",
|
|
@@ -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
|
|
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,
|
|
@@ -1,16 +1,87 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
3
|
-
import { Platform, StyleSheet, View } from "react-native";
|
|
4
|
-
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Modal, Platform, StyleSheet, View } from "react-native";
|
|
5
3
|
import * as DialogPrimitive from "@rn-primitives/dialog";
|
|
6
4
|
import * as AlertDialogPrimitive from "@rn-primitives/alert-dialog";
|
|
7
5
|
import { AnimatedView } from "./AnimatedView.js";
|
|
6
|
+
import { KeyboardAvoidingView } from "./KeyboardAvoidingView.js";
|
|
8
7
|
import { TextClassContext, TextColorContext } from "./StyledText.context";
|
|
9
8
|
import { StyledText } from "./StyledText.js";
|
|
10
9
|
import { useTheme } from "../hooks/useTheme.js";
|
|
11
10
|
import { spacing } from "../constants/spacing.js";
|
|
12
11
|
import { palette } from "../constants/colors.js";
|
|
13
|
-
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Presentation
|
|
14
|
+
// ============================================================================
|
|
15
|
+
/** The Modal follows the app's own orientation mask instead of RN's portrait-only default. */
|
|
16
|
+
const SUPPORTED_ORIENTATIONS = [
|
|
17
|
+
"portrait",
|
|
18
|
+
"portrait-upside-down",
|
|
19
|
+
"landscape",
|
|
20
|
+
"landscape-left",
|
|
21
|
+
"landscape-right",
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Presentation for dialog content.
|
|
25
|
+
*
|
|
26
|
+
* iOS presents through React Native's `Modal`, rendered inline where the
|
|
27
|
+
* dialog sits in the tree rather than through the portal host. Until 0.27.1
|
|
28
|
+
* both dialogs rendered into `UIProvider`'s portal host inside
|
|
29
|
+
* react-native-screens' `FullWindowOverlay`, which stacks above native stack
|
|
30
|
+
* modals by adding its container straight to the `UIWindow`. No
|
|
31
|
+
* `UIViewController` sits above that container, and `expo-modules-core`'s
|
|
32
|
+
* `ExpoSwiftUI.HostingView` — which attaches its `UIHostingController` only
|
|
33
|
+
* when `reactViewController()` finds a parent controller — removed the SwiftUI
|
|
34
|
+
* view instead. Every `@expo/ui`-hosted control inside (`TextInput`, `Slider`,
|
|
35
|
+
* `SegmentedControl`) was a zero-height box: no editable element, no focus, no
|
|
36
|
+
* keyboard (fieldnest on iOS 27, `@mrmeg/expo-ui` 0.27.0; reproduced in the
|
|
37
|
+
* template's `Dialog` `form` variant — the accessibility tree listed only the
|
|
38
|
+
* field labels, and the same content mounted with the overlay removed).
|
|
39
|
+
*
|
|
40
|
+
* `Modal` presents a real view controller, so hosted controls mount and take
|
|
41
|
+
* focus on the first tap. RN presents it from the view controller nearest the
|
|
42
|
+
* `Modal`'s own host view, which is why it renders inline: a `Modal` in the
|
|
43
|
+
* root portal host presents from the root controller and silently fails
|
|
44
|
+
* whenever that controller already presents a native stack modal or a sheet
|
|
45
|
+
* (device-verified: the trigger reported expanded, nothing appeared). Inline,
|
|
46
|
+
* the presenter is whatever screen, native stack modal or sheet contains the
|
|
47
|
+
* dialog, and the dialog stacks above it. The trade-off: content hosted by
|
|
48
|
+
* `FullWindowOverlay` (`Drawer`, `Popover`, `Select`, `DropdownMenu`,
|
|
49
|
+
* `Tooltip`) has no view controller either, so a dialog placed inside that
|
|
50
|
+
* content cannot present on iOS — render it at screen level and open it from
|
|
51
|
+
* the item's `onPress`.
|
|
52
|
+
*
|
|
53
|
+
* Android and web render into the portal host, as before.
|
|
54
|
+
*/
|
|
55
|
+
function DialogPresentation({ Portal, open, portalHost, onRequestClose, children, }) {
|
|
56
|
+
if (Platform.OS !== "ios") {
|
|
57
|
+
return _jsx(Portal, { hostName: portalHost, children: children });
|
|
58
|
+
}
|
|
59
|
+
if (!open) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return (_jsx(Modal, { visible: true, transparent: true, animationType: "none", presentationStyle: "overFullScreen", supportedOrientations: SUPPORTED_ORIENTATIONS, onRequestClose: onRequestClose, children: children }));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Keyboard avoidance owner for dialog content.
|
|
66
|
+
*
|
|
67
|
+
* On iOS the `Modal` above is presented outside `UIProvider`'s root
|
|
68
|
+
* `KeyboardAvoidingView`, so nothing else can keep a focused dialog field and
|
|
69
|
+
* the footer above the keyboard: this wrapper pads the centered container by
|
|
70
|
+
* the keyboard height, and the card (capped at 85% of the remaining height)
|
|
71
|
+
* recenters in the space that is left. It is the package `KeyboardAvoidingView`,
|
|
72
|
+
* so `useKeyboardAvoidance()` is `true` inside `DialogContent` and a
|
|
73
|
+
* `DismissKeyboard` in dialog content adds no second layer. Do not wrap dialog
|
|
74
|
+
* content in another `KeyboardAvoidingView`.
|
|
75
|
+
*
|
|
76
|
+
* Android and web keep the portal-host tree unchanged; the portal host sits
|
|
77
|
+
* outside the root avoidance there, and a dialog is not keyboard-avoided yet.
|
|
78
|
+
*/
|
|
79
|
+
function DialogKeyboardAvoidance({ children }) {
|
|
80
|
+
if (Platform.OS !== "ios") {
|
|
81
|
+
return _jsx(_Fragment, { children: children });
|
|
82
|
+
}
|
|
83
|
+
return (_jsx(KeyboardAvoidingView, { style: overlayStyles.fill, behavior: "padding", keyboardVerticalOffset: 0, children: children }));
|
|
84
|
+
}
|
|
14
85
|
// ============================================================================
|
|
15
86
|
// Dialog
|
|
16
87
|
// ============================================================================
|
|
@@ -26,8 +97,18 @@ function DialogRoot({ children, ...props }) {
|
|
|
26
97
|
}
|
|
27
98
|
function DialogContent({ portalHost, style, children, ...props }) {
|
|
28
99
|
const { theme, getShadowStyle, getContrastingColor } = useTheme();
|
|
100
|
+
const { open, onOpenChange } = DialogPrimitive.useRootContext();
|
|
29
101
|
const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
|
|
30
|
-
return (_jsx(DialogPrimitive.Portal,
|
|
102
|
+
return (_jsx(DialogPresentation, { Portal: DialogPrimitive.Portal, open: open, portalHost: portalHost, onRequestClose: () => onOpenChange(false), children: _jsx(DialogPrimitive.Overlay
|
|
103
|
+
// On web the primitive wraps the overlay in a react-native-web
|
|
104
|
+
// Pressable whose click handler stops propagation unconditionally.
|
|
105
|
+
// With that Pressable between the overlay and the Radix content,
|
|
106
|
+
// outside presses (mouse and touch) and Escape never dismissed the
|
|
107
|
+
// dialog, so a dialog without a Close button was stuck. `asChild`
|
|
108
|
+
// makes the primitive clone our fade wrapper instead, removing the
|
|
109
|
+
// Pressable; Radix dismissal then works. Native keeps the Pressable,
|
|
110
|
+
// which is what implements closeOnPress there.
|
|
111
|
+
, {
|
|
31
112
|
// On web the primitive wraps the overlay in a react-native-web
|
|
32
113
|
// Pressable whose click handler stops propagation unconditionally.
|
|
33
114
|
// With that Pressable between the overlay and the Radix content,
|
|
@@ -36,20 +117,11 @@ function DialogContent({ portalHost, style, children, ...props }) {
|
|
|
36
117
|
// makes the primitive clone our fade wrapper instead, removing the
|
|
37
118
|
// Pressable; Radix dismissal then works. Native keeps the Pressable,
|
|
38
119
|
// which is what implements closeOnPress there.
|
|
39
|
-
,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// dialog, so a dialog without a Close button was stuck. `asChild`
|
|
45
|
-
// makes the primitive clone our fade wrapper instead, removing the
|
|
46
|
-
// Pressable; Radix dismissal then works. Native keeps the Pressable,
|
|
47
|
-
// which is what implements closeOnPress there.
|
|
48
|
-
asChild: Platform.OS === "web", style: StyleSheet.flatten([
|
|
49
|
-
StyleSheet.absoluteFill,
|
|
50
|
-
{ backgroundColor: theme.colors.overlay },
|
|
51
|
-
Platform.OS === "web" && { zIndex: 50 },
|
|
52
|
-
]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(DialogPrimitive.Content, { style: StyleSheet.flatten([
|
|
120
|
+
asChild: Platform.OS === "web", style: StyleSheet.flatten([
|
|
121
|
+
StyleSheet.absoluteFill,
|
|
122
|
+
{ backgroundColor: theme.colors.overlay },
|
|
123
|
+
Platform.OS === "web" && { zIndex: 50 },
|
|
124
|
+
]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(DialogKeyboardAvoidance, { children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(DialogPrimitive.Content, { style: StyleSheet.flatten([
|
|
53
125
|
{
|
|
54
126
|
backgroundColor: theme.colors.popover,
|
|
55
127
|
borderColor: theme.colors.border,
|
|
@@ -119,12 +191,13 @@ function AlertDialogRoot({ children, ...props }) {
|
|
|
119
191
|
}
|
|
120
192
|
function AlertDialogContent({ portalHost, style, children, ...props }) {
|
|
121
193
|
const { theme, getShadowStyle, getContrastingColor } = useTheme();
|
|
194
|
+
const { open, onOpenChange } = AlertDialogPrimitive.useRootContext();
|
|
122
195
|
const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
|
|
123
|
-
return (_jsx(AlertDialogPrimitive.Portal,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
196
|
+
return (_jsx(DialogPresentation, { Portal: AlertDialogPrimitive.Portal, open: open, portalHost: portalHost, onRequestClose: () => onOpenChange(false), children: _jsx(AlertDialogPrimitive.Overlay, { style: StyleSheet.flatten([
|
|
197
|
+
StyleSheet.absoluteFill,
|
|
198
|
+
{ backgroundColor: theme.colors.overlay },
|
|
199
|
+
Platform.OS === "web" && { zIndex: 52 },
|
|
200
|
+
]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(DialogKeyboardAvoidance, { children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(AlertDialogPrimitive.Content, { style: StyleSheet.flatten([
|
|
128
201
|
{
|
|
129
202
|
backgroundColor: theme.colors.popover,
|
|
130
203
|
borderColor: theme.colors.border,
|
|
@@ -175,6 +248,11 @@ const AlertDialog = Object.assign(AlertDialogRoot, {
|
|
|
175
248
|
// Shared styles
|
|
176
249
|
// ============================================================================
|
|
177
250
|
const overlayStyles = StyleSheet.create({
|
|
251
|
+
// The keyboard-avoiding wrapper fills the overlay so its bottom padding
|
|
252
|
+
// shrinks the centered container instead of the card.
|
|
253
|
+
fill: {
|
|
254
|
+
flex: 1,
|
|
255
|
+
},
|
|
178
256
|
centeredContainer: {
|
|
179
257
|
flex: 1,
|
|
180
258
|
justifyContent: "center",
|
|
@@ -5,6 +5,7 @@ import { useTheme } from "../hooks/useTheme.js";
|
|
|
5
5
|
import { spacing } from "../constants/spacing.js";
|
|
6
6
|
import { StyledText } from "./StyledText.js";
|
|
7
7
|
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
8
|
+
import { stateSurfaceProps } from "../lib/stateSurface.js";
|
|
8
9
|
const CELL_WIDTH = 36;
|
|
9
10
|
const CELL_HEIGHT = 40;
|
|
10
11
|
const CELL_FONT_SIZE = 20;
|
|
@@ -89,7 +90,7 @@ function OTPCell({ index, total, char, isActive, hasError, disabled, theme, onPr
|
|
|
89
90
|
: isActive
|
|
90
91
|
? theme.colors.primary
|
|
91
92
|
: theme.colors.border;
|
|
92
|
-
return (_jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityLabel: `Digit ${index + 1} of ${total}`, accessibilityState: { disabled }, children: _jsx(View, { style: [
|
|
93
|
+
return (_jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityLabel: `Digit ${index + 1} of ${total}`, accessibilityState: { disabled }, children: _jsx(View, { ...stateSurfaceProps(), style: [
|
|
93
94
|
{
|
|
94
95
|
width: CELL_WIDTH,
|
|
95
96
|
height: CELL_HEIGHT,
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { palette } from "../constants/colors.js";
|
|
3
3
|
import { useTheme } from "../hooks/useTheme.js";
|
|
4
4
|
import { hapticLight } from "../lib/haptics.js";
|
|
5
|
+
import { stateSurfaceProps } from "../lib/stateSurface.js";
|
|
5
6
|
import { useCallback, useRef } from "react";
|
|
6
7
|
import { View } from "react-native";
|
|
7
8
|
import { Slider as NativeSlider } from "@expo/ui/community/slider";
|
|
@@ -31,7 +32,7 @@ size: _size = "md", disabled = false, showValue = false, style: styleOverride, }
|
|
|
31
32
|
}
|
|
32
33
|
onValueChange?.(next);
|
|
33
34
|
}, [onValueChange]);
|
|
34
|
-
return (_jsxs(View, { style: [{ opacity: disabled ? 0.5 : 1, alignSelf: "stretch" }, styleOverride], children: [showValue && (_jsx(StyledText, { selectable: false, style: {
|
|
35
|
+
return (_jsxs(View, { ...stateSurfaceProps(), style: [{ opacity: disabled ? 0.5 : 1, alignSelf: "stretch" }, styleOverride], children: [showValue && (_jsx(StyledText, { selectable: false, style: {
|
|
35
36
|
fontSize: 12,
|
|
36
37
|
color: theme.colors.textDim,
|
|
37
38
|
marginBottom: 4,
|
|
@@ -3,6 +3,7 @@ import { palette } from "../constants/colors.js";
|
|
|
3
3
|
import { spacing } from "../constants/spacing.js";
|
|
4
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
5
5
|
import { hapticLight } from "../lib/haptics.js";
|
|
6
|
+
import { stateSurfaceProps } from "../lib/stateSurface.js";
|
|
6
7
|
import * as SwitchPrimitives from "@rn-primitives/switch";
|
|
7
8
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
8
9
|
import { ActivityIndicator, Platform, StyleSheet, View, Animated } from "react-native";
|
|
@@ -113,7 +114,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
|
|
|
113
114
|
borderWidth: 1,
|
|
114
115
|
borderColor: trackBorderColor,
|
|
115
116
|
pointerEvents: "none",
|
|
116
|
-
} }), labelOn && !isIOS && (_jsx(View, { style: {
|
|
117
|
+
} }), labelOn && !isIOS && (_jsx(View, { ...stateSurfaceProps(), style: {
|
|
117
118
|
...styles.label,
|
|
118
119
|
left: labelHorizontalInset,
|
|
119
120
|
right: thumbInset + thumbSize + labelGap,
|
|
@@ -136,7 +137,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
|
|
|
136
137
|
...getShadowStyle("subtle"),
|
|
137
138
|
},
|
|
138
139
|
thumbAnimatedStyle,
|
|
139
|
-
], children: loading && (_jsx(ActivityIndicator, { size: "small", color: thumbIndicatorColor })) }) }), labelOff && !isIOS && (_jsx(View, { style: {
|
|
140
|
+
], children: loading && (_jsx(ActivityIndicator, { size: "small", color: thumbIndicatorColor })) }) }), labelOff && !isIOS && (_jsx(View, { ...stateSurfaceProps(), style: {
|
|
140
141
|
...styles.label,
|
|
141
142
|
left: thumbInset + thumbSize + labelGap,
|
|
142
143
|
right: labelHorizontalInset,
|
|
@@ -10,6 +10,7 @@ import { StyledText } from "./StyledText.js";
|
|
|
10
10
|
import { Icon } from "./Icon.js";
|
|
11
11
|
import { hapticLight } from "../lib/haptics.js";
|
|
12
12
|
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
13
|
+
import { stateSurfaceProps } from "../lib/stateSurface.js";
|
|
13
14
|
import { palette } from "../constants/colors.js";
|
|
14
15
|
import { clearKeyboardFocusedInput, setKeyboardFocusedInput, } from "./keyboardFocusRegistry.js";
|
|
15
16
|
import { useTextInputSurfaceResponder } from "./keyboardDismiss.js";
|
|
@@ -487,7 +488,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
487
488
|
fontSize: sizeConfig.fontSize,
|
|
488
489
|
...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
|
|
489
490
|
};
|
|
490
|
-
return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [surfaceStyle, hasSecureToggle && styles.nativeRow], hitSlop: { top: surfaceHitSlop, bottom: surfaceHitSlop }, ...surfaceResponderProps, children: [(Platform.OS === "android" ? [effectiveSecureTextEntry] : [true, false]).map((secure) => {
|
|
491
|
+
return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { ...stateSurfaceProps(), style: [surfaceStyle, hasSecureToggle && styles.nativeRow], hitSlop: { top: surfaceHitSlop, bottom: surfaceHitSlop }, ...surfaceResponderProps, children: [(Platform.OS === "android" ? [effectiveSecureTextEntry] : [true, false]).map((secure) => {
|
|
491
492
|
const active = secure === effectiveSecureTextEntry;
|
|
492
493
|
const outgoing = handoff != null && secure === handoff.outgoingSecure;
|
|
493
494
|
if (!active && !outgoing)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Android `BottomSheet` keyboard avoidance is platform-owned, and the part the
|
|
3
|
+
* package depends on lives in the host: Material3's `ModalBottomSheet` shrinks
|
|
4
|
+
* the sheet with `imePadding()`, `@expo/ui`'s `RNHostView` re-reports its
|
|
5
|
+
* Compose size to the shadow tree (`onSizeChanged` → `ShadowNodeProxy.setViewSize`),
|
|
6
|
+
* and the `flexGrow: 1, height: 0` column the host wraps our children in
|
|
7
|
+
* follows. `expo-modules-core` <= 57.0.3 flushed that report only from a
|
|
8
|
+
* one-shot pre-draw listener on the activity window, which does not draw while
|
|
9
|
+
* the sheet's own dialog window animates the IME, so the column kept its
|
|
10
|
+
* detent height and `Footer` sat under the keyboard until the activity redrew
|
|
11
|
+
* (expo/expo#47778, fixed by expo/expo#47810 in 57.0.4 and 58.0.0; no 56.x
|
|
12
|
+
* release has it). Nothing in JS can observe a size the shadow tree never
|
|
13
|
+
* received, so the package names the floor instead: once per session, in dev,
|
|
14
|
+
* on Android, when the *native* core compiled into the app is older.
|
|
15
|
+
*/
|
|
16
|
+
export interface ExpoModulesCoreVersion {
|
|
17
|
+
version: string;
|
|
18
|
+
major: number;
|
|
19
|
+
minor: number;
|
|
20
|
+
patch: number;
|
|
21
|
+
}
|
|
22
|
+
/** First `expo-modules-core` release whose `RNHostView` size flush always lands. */
|
|
23
|
+
export declare const MIN_EXPO_MODULES_CORE: Readonly<Omit<ExpoModulesCoreVersion, "version">>;
|
|
24
|
+
/**
|
|
25
|
+
* The native `expo-modules-core` version compiled into the running app, as the
|
|
26
|
+
* core's own `CoreModule` publishes it on `globalThis.expo` (Android and iOS,
|
|
27
|
+
* present on every Expo release the package supports, 56 through 58).
|
|
28
|
+
* `undefined` on web, in Jest, or when the core is not loaded.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readExpoModulesCoreVersion(): ExpoModulesCoreVersion | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Whether this core drops the sheet host's size update when the keyboard opens:
|
|
33
|
+
* a defined version lexicographically below {@link MIN_EXPO_MODULES_CORE}.
|
|
34
|
+
* An unknown version is not reported as broken.
|
|
35
|
+
*/
|
|
36
|
+
export declare function bottomSheetHostDropsResize(version: ExpoModulesCoreVersion | undefined): boolean;
|
|
37
|
+
export declare function bottomSheetHostWarning(version: ExpoModulesCoreVersion): string;
|
|
38
|
+
/**
|
|
39
|
+
* Warn once per JS session, in development, on Android, when the native
|
|
40
|
+
* `expo-modules-core` is older than {@link MIN_EXPO_MODULES_CORE}. Called when a
|
|
41
|
+
* `BottomSheet.Content` mounts. Silent on iOS and web, where the sheet's
|
|
42
|
+
* avoidance does not go through `RNHostView`'s size state.
|
|
43
|
+
*/
|
|
44
|
+
export declare function warnIfBottomSheetHostDropsResize(): void;
|
|
45
|
+
/** Test hook: forget that the warning was already shown. */
|
|
46
|
+
export declare function resetBottomSheetHostWarningForTests(): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
2
|
+
/** First `expo-modules-core` release whose `RNHostView` size flush always lands. */
|
|
3
|
+
export const MIN_EXPO_MODULES_CORE = {
|
|
4
|
+
major: 57,
|
|
5
|
+
minor: 0,
|
|
6
|
+
patch: 4,
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The native `expo-modules-core` version compiled into the running app, as the
|
|
10
|
+
* core's own `CoreModule` publishes it on `globalThis.expo` (Android and iOS,
|
|
11
|
+
* present on every Expo release the package supports, 56 through 58).
|
|
12
|
+
* `undefined` on web, in Jest, or when the core is not loaded.
|
|
13
|
+
*/
|
|
14
|
+
export function readExpoModulesCoreVersion() {
|
|
15
|
+
const expo = globalThis.expo;
|
|
16
|
+
const candidate = expo?.expoModulesCoreVersion;
|
|
17
|
+
if (typeof candidate !== "object" ||
|
|
18
|
+
candidate === null ||
|
|
19
|
+
typeof candidate.major !== "number" ||
|
|
20
|
+
typeof candidate.minor !== "number" ||
|
|
21
|
+
typeof candidate.patch !== "number") {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return candidate;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Whether this core drops the sheet host's size update when the keyboard opens:
|
|
28
|
+
* a defined version lexicographically below {@link MIN_EXPO_MODULES_CORE}.
|
|
29
|
+
* An unknown version is not reported as broken.
|
|
30
|
+
*/
|
|
31
|
+
export function bottomSheetHostDropsResize(version) {
|
|
32
|
+
if (!version)
|
|
33
|
+
return false;
|
|
34
|
+
const { major, minor, patch } = MIN_EXPO_MODULES_CORE;
|
|
35
|
+
if (version.major !== major)
|
|
36
|
+
return version.major < major;
|
|
37
|
+
if (version.minor !== minor)
|
|
38
|
+
return version.minor < minor;
|
|
39
|
+
return version.patch < patch;
|
|
40
|
+
}
|
|
41
|
+
export function bottomSheetHostWarning(version) {
|
|
42
|
+
const { major, minor, patch } = MIN_EXPO_MODULES_CORE;
|
|
43
|
+
return (`@mrmeg/expo-ui BottomSheet: expo-modules-core ${version.version} drops the sheet's ` +
|
|
44
|
+
"size update when the keyboard opens on Android, so BottomSheet.Footer and the tail " +
|
|
45
|
+
"of Body end up under the keyboard (expo/expo#47778). Update expo-modules-core to " +
|
|
46
|
+
`>= ${major}.${minor}.${patch} (fixed in 57.0.4 / 58.0.0) and rebuild the app.`);
|
|
47
|
+
}
|
|
48
|
+
let warned = false;
|
|
49
|
+
/**
|
|
50
|
+
* Warn once per JS session, in development, on Android, when the native
|
|
51
|
+
* `expo-modules-core` is older than {@link MIN_EXPO_MODULES_CORE}. Called when a
|
|
52
|
+
* `BottomSheet.Content` mounts. Silent on iOS and web, where the sheet's
|
|
53
|
+
* avoidance does not go through `RNHostView`'s size state.
|
|
54
|
+
*/
|
|
55
|
+
export function warnIfBottomSheetHostDropsResize() {
|
|
56
|
+
if (warned || !__DEV__ || Platform.OS !== "android")
|
|
57
|
+
return;
|
|
58
|
+
const version = readExpoModulesCoreVersion();
|
|
59
|
+
if (!version || !bottomSheetHostDropsResize(version))
|
|
60
|
+
return;
|
|
61
|
+
warned = true;
|
|
62
|
+
console.warn(bottomSheetHostWarning(version));
|
|
63
|
+
}
|
|
64
|
+
/** Test hook: forget that the warning was already shown. */
|
|
65
|
+
export function resetBottomSheetHostWarningForTests() {
|
|
66
|
+
warned = false;
|
|
67
|
+
}
|
|
@@ -27,6 +27,31 @@ export type TextInputSurfaceResponderProps = Pick<ViewProps, "onStartShouldSetRe
|
|
|
27
27
|
* for that.
|
|
28
28
|
*/
|
|
29
29
|
export declare function useTextInputSurfaceResponder(focus: () => void): TextInputSurfaceResponderProps;
|
|
30
|
+
export type AncestorClaimWarningProps = Pick<ViewProps, "onStartShouldSetResponderCapture" | "onTouchStart">;
|
|
31
|
+
/** Test hook: let the once-per-session ancestor-claim warning fire again. */
|
|
32
|
+
export declare function resetAncestorClaimWarningForTests(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Dev-only diagnostic for a boundary whose subtree is drawn in another native
|
|
35
|
+
* window but still lives in the screen's React tree (`BottomSheet.Content`).
|
|
36
|
+
*
|
|
37
|
+
* React Native's responder negotiation walks the React tree, so every ancestor
|
|
38
|
+
* of the sheet takes part in the capture phase for a tap inside it, even though
|
|
39
|
+
* none of them is under the finger. An ancestor `ScrollView` left on the default
|
|
40
|
+
* `keyboardShouldPersistTaps="never"` claims the tap there as soon as a text
|
|
41
|
+
* input holds focus and RN's `Keyboard` has reported the IME, and blurs the
|
|
42
|
+
* field on release: the keyboard closes and the tapped control never fires.
|
|
43
|
+
* Nothing inside the tree can preempt a capture-phase claim, so the boundary
|
|
44
|
+
* names it instead.
|
|
45
|
+
*
|
|
46
|
+
* The signal needs no heuristics: `onStartShouldSetResponderCapture` runs on
|
|
47
|
+
* this view for every touch that reaches the negotiation without an ancestor
|
|
48
|
+
* claiming it, while `onTouchStart` bubbles regardless. A touch start with no
|
|
49
|
+
* capture call for the same native event, while a package field is focused,
|
|
50
|
+
* is exactly a tap an ancestor took. Warns once per session, Android only,
|
|
51
|
+
* `__DEV__` only; elsewhere the inner props are returned untouched. The wrapped
|
|
52
|
+
* `onTouchStart` still runs, so the boundary's own behavior is unchanged.
|
|
53
|
+
*/
|
|
54
|
+
export declare function useAncestorClaimWarning<P extends Pick<ViewProps, "onTouchStart">>(inner: P, message: string): P & AncestorClaimWarningProps;
|
|
30
55
|
export type KeyboardDismissResponderProps = Pick<ViewProps, "onStartShouldSetResponder" | "onTouchStart" | "onTouchMove" | "onTouchEnd" | "onTouchCancel">;
|
|
31
56
|
/**
|
|
32
57
|
* View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
|
|
@@ -191,6 +191,55 @@ export function useTextInputSurfaceResponder(focus) {
|
|
|
191
191
|
};
|
|
192
192
|
}, []);
|
|
193
193
|
}
|
|
194
|
+
let warnedAncestorClaim = false;
|
|
195
|
+
/** Test hook: let the once-per-session ancestor-claim warning fire again. */
|
|
196
|
+
export function resetAncestorClaimWarningForTests() {
|
|
197
|
+
warnedAncestorClaim = false;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Dev-only diagnostic for a boundary whose subtree is drawn in another native
|
|
201
|
+
* window but still lives in the screen's React tree (`BottomSheet.Content`).
|
|
202
|
+
*
|
|
203
|
+
* React Native's responder negotiation walks the React tree, so every ancestor
|
|
204
|
+
* of the sheet takes part in the capture phase for a tap inside it, even though
|
|
205
|
+
* none of them is under the finger. An ancestor `ScrollView` left on the default
|
|
206
|
+
* `keyboardShouldPersistTaps="never"` claims the tap there as soon as a text
|
|
207
|
+
* input holds focus and RN's `Keyboard` has reported the IME, and blurs the
|
|
208
|
+
* field on release: the keyboard closes and the tapped control never fires.
|
|
209
|
+
* Nothing inside the tree can preempt a capture-phase claim, so the boundary
|
|
210
|
+
* names it instead.
|
|
211
|
+
*
|
|
212
|
+
* The signal needs no heuristics: `onStartShouldSetResponderCapture` runs on
|
|
213
|
+
* this view for every touch that reaches the negotiation without an ancestor
|
|
214
|
+
* claiming it, while `onTouchStart` bubbles regardless. A touch start with no
|
|
215
|
+
* capture call for the same native event, while a package field is focused,
|
|
216
|
+
* is exactly a tap an ancestor took. Warns once per session, Android only,
|
|
217
|
+
* `__DEV__` only; elsewhere the inner props are returned untouched. The wrapped
|
|
218
|
+
* `onTouchStart` still runs, so the boundary's own behavior is unchanged.
|
|
219
|
+
*/
|
|
220
|
+
export function useAncestorClaimWarning(inner, message) {
|
|
221
|
+
const messageRef = useRef(message);
|
|
222
|
+
messageRef.current = message;
|
|
223
|
+
return useMemo(() => {
|
|
224
|
+
if (!__DEV__ || Platform.OS !== "android")
|
|
225
|
+
return inner;
|
|
226
|
+
let captured = null;
|
|
227
|
+
return {
|
|
228
|
+
...inner,
|
|
229
|
+
onStartShouldSetResponderCapture: (event) => {
|
|
230
|
+
captured = keyOf(event);
|
|
231
|
+
return false;
|
|
232
|
+
},
|
|
233
|
+
onTouchStart: (event) => {
|
|
234
|
+
if (!warnedAncestorClaim && !isSameEvent(captured, event) && hasKeyboardFocusedInput()) {
|
|
235
|
+
warnedAncestorClaim = true;
|
|
236
|
+
console.warn(messageRef.current);
|
|
237
|
+
}
|
|
238
|
+
inner.onTouchStart?.(event);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}, [inner]);
|
|
242
|
+
}
|
|
194
243
|
/**
|
|
195
244
|
* View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
|
|
196
245
|
* `View` that encloses the screen (or a form). Nesting is safe: the innermost
|
package/dist/design-system.json
CHANGED
package/dist/lib/index.d.ts
CHANGED
package/dist/lib/index.js
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ViewProps } from "react-native";
|
|
2
|
+
/**
|
|
3
|
+
* Props for a "state surface": a plain `View` whose `opacity`, `pointerEvents`
|
|
4
|
+
* or `display` is computed from component state (`disabled`, `loading`,
|
|
5
|
+
* `checked`, `editable`, …).
|
|
6
|
+
*
|
|
7
|
+
* On Android (Fabric) a View forms a stacking context only while one of those
|
|
8
|
+
* props says so. A filled or bordered View at opacity 1 still gets a native
|
|
9
|
+
* view, but its children are hoisted into the nearest stacking-context
|
|
10
|
+
* ancestor; the moment its opacity drops below 1 the children are pulled back
|
|
11
|
+
* under it. A state flip that races a navigation pop therefore re-parents
|
|
12
|
+
* views mid-mutation and crashed with `addViewAt: cannot insert view … View
|
|
13
|
+
* already has a parent` (doglog #57, Pixel 6a). `collapsable={false}` makes
|
|
14
|
+
* the View a permanent stacking context, so the native tree shape never
|
|
15
|
+
* changes with state. iOS and web return nothing: their mounting layers
|
|
16
|
+
* re-parent safely and the extra native view would only cost.
|
|
17
|
+
*
|
|
18
|
+
* RN's `Pressable` already sets `collapsable={false}` on its own View, so only
|
|
19
|
+
* plain `View` surfaces need this. Spread it before any consumer prop spread so
|
|
20
|
+
* a caller can still override it.
|
|
21
|
+
*
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <View {...stateSurfaceProps()} style={{ opacity: disabled ? 0.5 : 1 }} />
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Reads `Platform.OS` at call time so per-test platform flips apply.
|
|
27
|
+
*/
|
|
28
|
+
export declare function stateSurfaceProps(): Pick<ViewProps, "collapsable">;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
2
|
+
/**
|
|
3
|
+
* Props for a "state surface": a plain `View` whose `opacity`, `pointerEvents`
|
|
4
|
+
* or `display` is computed from component state (`disabled`, `loading`,
|
|
5
|
+
* `checked`, `editable`, …).
|
|
6
|
+
*
|
|
7
|
+
* On Android (Fabric) a View forms a stacking context only while one of those
|
|
8
|
+
* props says so. A filled or bordered View at opacity 1 still gets a native
|
|
9
|
+
* view, but its children are hoisted into the nearest stacking-context
|
|
10
|
+
* ancestor; the moment its opacity drops below 1 the children are pulled back
|
|
11
|
+
* under it. A state flip that races a navigation pop therefore re-parents
|
|
12
|
+
* views mid-mutation and crashed with `addViewAt: cannot insert view … View
|
|
13
|
+
* already has a parent` (doglog #57, Pixel 6a). `collapsable={false}` makes
|
|
14
|
+
* the View a permanent stacking context, so the native tree shape never
|
|
15
|
+
* changes with state. iOS and web return nothing: their mounting layers
|
|
16
|
+
* re-parent safely and the extra native view would only cost.
|
|
17
|
+
*
|
|
18
|
+
* RN's `Pressable` already sets `collapsable={false}` on its own View, so only
|
|
19
|
+
* plain `View` surfaces need this. Spread it before any consumer prop spread so
|
|
20
|
+
* a caller can still override it.
|
|
21
|
+
*
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <View {...stateSurfaceProps()} style={{ opacity: disabled ? 0.5 : 1 }} />
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Reads `Platform.OS` at call time so per-test platform flips apply.
|
|
27
|
+
*/
|
|
28
|
+
export function stateSurfaceProps() {
|
|
29
|
+
return Platform.OS === "android" ? { collapsable: false } : {};
|
|
30
|
+
}
|