@oxy.so/bloom 4.18.0 → 4.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/docs/adoption-matrix.mdx +5 -5
  2. package/docs/overlay.mdx +32 -0
  3. package/docs/portal.mdx +37 -0
  4. package/docs/rating.mdx +19 -2
  5. package/docs/select.mdx +10 -0
  6. package/lib/commonjs/bottom-sheet/BottomSheet.web.js +1 -0
  7. package/lib/commonjs/bottom-sheet/BottomSheet.web.js.map +1 -1
  8. package/lib/commonjs/chat-list/parts.js +1 -1
  9. package/lib/commonjs/chat-list/parts.js.map +1 -1
  10. package/lib/commonjs/chat-screen/ChatBackground.js +6 -6
  11. package/lib/commonjs/chat-screen/ChatBackground.js.map +1 -1
  12. package/lib/commonjs/dialog/Dialog.js +5 -0
  13. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  14. package/lib/commonjs/dialog/Dialog.web.js +4 -1
  15. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  16. package/lib/commonjs/index.js +7 -0
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/index.web.js +7 -0
  19. package/lib/commonjs/index.web.js.map +1 -1
  20. package/lib/commonjs/location-puck/LocationPuck.js +1 -1
  21. package/lib/commonjs/location-puck/LocationPuck.js.map +1 -1
  22. package/lib/commonjs/mail-list/parts.js +1 -1
  23. package/lib/commonjs/mail-list/parts.js.map +1 -1
  24. package/lib/commonjs/media-card/parts.js +1 -1
  25. package/lib/commonjs/media-card/parts.js.map +1 -1
  26. package/lib/commonjs/overlay/Overlay.js +23 -4
  27. package/lib/commonjs/overlay/Overlay.js.map +1 -1
  28. package/lib/commonjs/overlay/OverlayInertBoundary.js +78 -0
  29. package/lib/commonjs/overlay/OverlayInertBoundary.js.map +1 -0
  30. package/lib/commonjs/overlay/index.js +14 -0
  31. package/lib/commonjs/overlay/index.js.map +1 -1
  32. package/lib/commonjs/overlay/modal-registry.js +104 -0
  33. package/lib/commonjs/overlay/modal-registry.js.map +1 -0
  34. package/lib/commonjs/overlay/use-hardware-back.js +32 -0
  35. package/lib/commonjs/overlay/use-hardware-back.js.map +1 -0
  36. package/lib/commonjs/overlay/use-modal-overlay-active.js +33 -0
  37. package/lib/commonjs/overlay/use-modal-overlay-active.js.map +1 -0
  38. package/lib/commonjs/portal/index.js +7 -0
  39. package/lib/commonjs/portal/index.js.map +1 -1
  40. package/lib/commonjs/portal/index.web.js +7 -0
  41. package/lib/commonjs/portal/index.web.js.map +1 -1
  42. package/lib/commonjs/rating/Rating.js +33 -2
  43. package/lib/commonjs/rating/Rating.js.map +1 -1
  44. package/lib/commonjs/search/Search.js +36 -3
  45. package/lib/commonjs/search/Search.js.map +1 -1
  46. package/lib/commonjs/settings-modal/SettingsModal.js +19 -12
  47. package/lib/commonjs/settings-modal/SettingsModal.js.map +1 -1
  48. package/lib/commonjs/teleport/PortalHost.js +1 -1
  49. package/lib/commonjs/zoomable-media-gallery/ZoomableMediaGallery.js +32 -12
  50. package/lib/commonjs/zoomable-media-gallery/ZoomableMediaGallery.js.map +1 -1
  51. package/lib/module/bottom-sheet/BottomSheet.web.js +1 -0
  52. package/lib/module/bottom-sheet/BottomSheet.web.js.map +1 -1
  53. package/lib/module/chat-list/parts.js +1 -1
  54. package/lib/module/chat-list/parts.js.map +1 -1
  55. package/lib/module/chat-screen/ChatBackground.js +6 -6
  56. package/lib/module/chat-screen/ChatBackground.js.map +1 -1
  57. package/lib/module/dialog/Dialog.js +5 -0
  58. package/lib/module/dialog/Dialog.js.map +1 -1
  59. package/lib/module/dialog/Dialog.web.js +4 -1
  60. package/lib/module/dialog/Dialog.web.js.map +1 -1
  61. package/lib/module/index.js +1 -1
  62. package/lib/module/index.js.map +1 -1
  63. package/lib/module/index.web.js +1 -1
  64. package/lib/module/index.web.js.map +1 -1
  65. package/lib/module/location-puck/LocationPuck.js +1 -1
  66. package/lib/module/location-puck/LocationPuck.js.map +1 -1
  67. package/lib/module/mail-list/parts.js +1 -1
  68. package/lib/module/mail-list/parts.js.map +1 -1
  69. package/lib/module/media-card/parts.js +1 -1
  70. package/lib/module/media-card/parts.js.map +1 -1
  71. package/lib/module/overlay/Overlay.js +23 -4
  72. package/lib/module/overlay/Overlay.js.map +1 -1
  73. package/lib/module/overlay/OverlayInertBoundary.js +73 -0
  74. package/lib/module/overlay/OverlayInertBoundary.js.map +1 -0
  75. package/lib/module/overlay/index.js +2 -0
  76. package/lib/module/overlay/index.js.map +1 -1
  77. package/lib/module/overlay/modal-registry.js +96 -0
  78. package/lib/module/overlay/modal-registry.js.map +1 -0
  79. package/lib/module/overlay/use-hardware-back.js +29 -0
  80. package/lib/module/overlay/use-hardware-back.js.map +1 -0
  81. package/lib/module/overlay/use-modal-overlay-active.js +28 -0
  82. package/lib/module/overlay/use-modal-overlay-active.js.map +1 -0
  83. package/lib/module/portal/index.js +4 -0
  84. package/lib/module/portal/index.js.map +1 -1
  85. package/lib/module/portal/index.web.js +4 -0
  86. package/lib/module/portal/index.web.js.map +1 -1
  87. package/lib/module/rating/Rating.js +32 -2
  88. package/lib/module/rating/Rating.js.map +1 -1
  89. package/lib/module/search/Search.js +37 -4
  90. package/lib/module/search/Search.js.map +1 -1
  91. package/lib/module/settings-modal/SettingsModal.js +20 -13
  92. package/lib/module/settings-modal/SettingsModal.js.map +1 -1
  93. package/lib/module/teleport/PortalHost.js +1 -1
  94. package/lib/module/zoomable-media-gallery/ZoomableMediaGallery.js +32 -12
  95. package/lib/module/zoomable-media-gallery/ZoomableMediaGallery.js.map +1 -1
  96. package/lib/typescript/commonjs/bottom-sheet/BottomSheet.web.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts +2 -1
  98. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/index.d.ts +1 -1
  100. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/index.web.d.ts +1 -1
  102. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/overlay/Overlay.d.ts +1 -1
  104. package/lib/typescript/commonjs/overlay/Overlay.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/overlay/OverlayInertBoundary.d.ts +6 -0
  106. package/lib/typescript/commonjs/overlay/OverlayInertBoundary.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/overlay/index.d.ts +3 -1
  108. package/lib/typescript/commonjs/overlay/index.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/overlay/modal-registry.d.ts +53 -0
  110. package/lib/typescript/commonjs/overlay/modal-registry.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/overlay/types.d.ts +36 -0
  112. package/lib/typescript/commonjs/overlay/types.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/overlay/use-hardware-back.d.ts +11 -0
  114. package/lib/typescript/commonjs/overlay/use-hardware-back.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/overlay/use-modal-overlay-active.d.ts +7 -0
  116. package/lib/typescript/commonjs/overlay/use-modal-overlay-active.d.ts.map +1 -0
  117. package/lib/typescript/commonjs/portal/index.d.ts +2 -0
  118. package/lib/typescript/commonjs/portal/index.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/portal/index.web.d.ts +2 -0
  120. package/lib/typescript/commonjs/portal/index.web.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/rating/Rating.d.ts +9 -1
  122. package/lib/typescript/commonjs/rating/Rating.d.ts.map +1 -1
  123. package/lib/typescript/commonjs/rating/types.d.ts +9 -0
  124. package/lib/typescript/commonjs/rating/types.d.ts.map +1 -1
  125. package/lib/typescript/commonjs/search/Search.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/select/types.d.ts +7 -0
  127. package/lib/typescript/commonjs/select/types.d.ts.map +1 -1
  128. package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/teleport/PortalHost.d.ts +1 -1
  130. package/lib/typescript/commonjs/zoomable-media-gallery/ZoomableMediaGallery.d.ts.map +1 -1
  131. package/lib/typescript/module/bottom-sheet/BottomSheet.web.d.ts.map +1 -1
  132. package/lib/typescript/module/dialog/Dialog.web.d.ts +2 -1
  133. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  134. package/lib/typescript/module/index.d.ts +1 -1
  135. package/lib/typescript/module/index.d.ts.map +1 -1
  136. package/lib/typescript/module/index.web.d.ts +1 -1
  137. package/lib/typescript/module/index.web.d.ts.map +1 -1
  138. package/lib/typescript/module/overlay/Overlay.d.ts +1 -1
  139. package/lib/typescript/module/overlay/Overlay.d.ts.map +1 -1
  140. package/lib/typescript/module/overlay/OverlayInertBoundary.d.ts +6 -0
  141. package/lib/typescript/module/overlay/OverlayInertBoundary.d.ts.map +1 -0
  142. package/lib/typescript/module/overlay/index.d.ts +3 -1
  143. package/lib/typescript/module/overlay/index.d.ts.map +1 -1
  144. package/lib/typescript/module/overlay/modal-registry.d.ts +53 -0
  145. package/lib/typescript/module/overlay/modal-registry.d.ts.map +1 -0
  146. package/lib/typescript/module/overlay/types.d.ts +36 -0
  147. package/lib/typescript/module/overlay/types.d.ts.map +1 -1
  148. package/lib/typescript/module/overlay/use-hardware-back.d.ts +11 -0
  149. package/lib/typescript/module/overlay/use-hardware-back.d.ts.map +1 -0
  150. package/lib/typescript/module/overlay/use-modal-overlay-active.d.ts +7 -0
  151. package/lib/typescript/module/overlay/use-modal-overlay-active.d.ts.map +1 -0
  152. package/lib/typescript/module/portal/index.d.ts +2 -0
  153. package/lib/typescript/module/portal/index.d.ts.map +1 -1
  154. package/lib/typescript/module/portal/index.web.d.ts +2 -0
  155. package/lib/typescript/module/portal/index.web.d.ts.map +1 -1
  156. package/lib/typescript/module/rating/Rating.d.ts +9 -1
  157. package/lib/typescript/module/rating/Rating.d.ts.map +1 -1
  158. package/lib/typescript/module/rating/types.d.ts +9 -0
  159. package/lib/typescript/module/rating/types.d.ts.map +1 -1
  160. package/lib/typescript/module/search/Search.d.ts.map +1 -1
  161. package/lib/typescript/module/select/types.d.ts +7 -0
  162. package/lib/typescript/module/select/types.d.ts.map +1 -1
  163. package/lib/typescript/module/settings-modal/SettingsModal.d.ts.map +1 -1
  164. package/lib/typescript/module/teleport/PortalHost.d.ts +1 -1
  165. package/lib/typescript/module/zoomable-media-gallery/ZoomableMediaGallery.d.ts.map +1 -1
  166. package/package.json +1 -1
  167. package/src/bottom-sheet/BottomSheet.web.tsx +6 -2
  168. package/src/chat-list/parts.tsx +1 -1
  169. package/src/chat-screen/ChatBackground.tsx +6 -6
  170. package/src/dialog/Dialog.tsx +9 -1
  171. package/src/dialog/Dialog.web.tsx +7 -4
  172. package/src/index.ts +1 -0
  173. package/src/index.web.ts +1 -0
  174. package/src/location-puck/LocationPuck.tsx +1 -1
  175. package/src/mail-list/parts.tsx +1 -1
  176. package/src/media-card/parts.tsx +1 -1
  177. package/src/overlay/Overlay.tsx +36 -14
  178. package/src/overlay/OverlayInertBoundary.tsx +72 -0
  179. package/src/overlay/index.ts +3 -1
  180. package/src/overlay/modal-registry.ts +101 -0
  181. package/src/overlay/types.ts +37 -0
  182. package/src/overlay/use-hardware-back.ts +27 -0
  183. package/src/overlay/use-modal-overlay-active.ts +31 -0
  184. package/src/portal/index.ts +5 -0
  185. package/src/portal/index.web.ts +5 -0
  186. package/src/rating/Rating.tsx +37 -2
  187. package/src/rating/types.ts +9 -0
  188. package/src/search/Search.tsx +42 -4
  189. package/src/select/types.ts +7 -0
  190. package/src/settings-modal/SettingsModal.tsx +20 -15
  191. package/src/teleport/PortalHost.tsx +1 -1
  192. package/src/zoomable-media-gallery/ZoomableMediaGallery.tsx +33 -12
@@ -0,0 +1,27 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { BackHandler, Platform } from 'react-native';
3
+
4
+ /**
5
+ * Consume Android's hardware back for as long as the caller is mounted with a
6
+ * handler, calling the CURRENT `onBack` (read through a ref, so a new closure
7
+ * each render does not re-register and lose its place in the LIFO order).
8
+ *
9
+ * `BackHandler` runs the newest listener first, so surfaces opened later close
10
+ * first. No-op off Android and when `BackHandler` is absent (web bundles, and
11
+ * some test environments' react-native mocks).
12
+ */
13
+ export function useHardwareBack(onBack: (() => void) | undefined): void {
14
+ const onBackRef = useRef(onBack);
15
+ onBackRef.current = onBack;
16
+ const active = onBack !== undefined;
17
+
18
+ useEffect(() => {
19
+ if (!active || Platform.OS !== 'android') return;
20
+ if (typeof BackHandler?.addEventListener !== 'function') return;
21
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
22
+ onBackRef.current?.();
23
+ return true;
24
+ });
25
+ return () => subscription.remove();
26
+ }, [active]);
27
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `useModalOverlayActive` — whether any modal Bloom overlay is open.
3
+ *
4
+ * Re-renders only when the answer flips. See `./modal-registry.ts` for who
5
+ * registers and why.
6
+ */
7
+ import { useEffect, useSyncExternalStore } from 'react';
8
+
9
+ import {
10
+ hasActiveModalOverlays,
11
+ registerModalOverlay,
12
+ subscribeModalOverlays,
13
+ } from './modal-registry';
14
+
15
+ /** `false` on the server: nothing is open before hydration. */
16
+ const serverSnapshot = () => false;
17
+
18
+ export function useModalOverlayActive(): boolean {
19
+ return useSyncExternalStore(subscribeModalOverlays, hasActiveModalOverlays, serverSnapshot);
20
+ }
21
+
22
+ /**
23
+ * Register as an open modal overlay while mounted and `modal` is true. Used by
24
+ * `OverlayRoot`; exported only within the family.
25
+ */
26
+ export function useModalOverlayRegistration(modal: boolean): void {
27
+ useEffect(() => {
28
+ if (!modal) return undefined;
29
+ return registerModalOverlay({});
30
+ }, [modal]);
31
+ }
@@ -3,3 +3,8 @@ export {
3
3
  PortalOutlet,
4
4
  Portal,
5
5
  } from './Portal';
6
+ // The app-content half of a modal overlay: wrap everything EXCEPT the
7
+ // `PortalOutlet` in it. Lives in `overlay/` (it reads the modal registry) and is
8
+ // offered here because this is the subpath the app root already imports.
9
+ export { OverlayInertBoundary } from '../overlay/OverlayInertBoundary';
10
+ export type { OverlayInertBoundaryProps } from '../overlay/types';
@@ -8,3 +8,8 @@ export {
8
8
  PortalOutlet,
9
9
  Portal,
10
10
  } from './Portal.web';
11
+ // The app-content half of a modal overlay: wrap everything EXCEPT the
12
+ // `PortalOutlet` in it. Lives in `overlay/` (it reads the modal registry) and is
13
+ // offered here because this is the subpath the app root already imports.
14
+ export { OverlayInertBoundary } from '../overlay/OverlayInertBoundary';
15
+ export type { OverlayInertBoundaryProps } from '../overlay/types';
@@ -26,7 +26,8 @@ import type { RatingProps, RatingSize } from './types';
26
26
  * `variant="stars"` draws five stars before the value instead of one, 2px
27
27
  * apart, each filled to its share of the value: the unfilled star in
28
28
  * `emptyStarColor` (border), and over it the filled star clipped to the
29
- * fraction. The clip is anchored with a LOGICAL inset (`insetInlineStart`) so a
29
+ * fraction. The fill comes from `fillValue` when given, else from `value`
30
+ * (parsed by `parseRatingValue` when it is a localised string). The clip is anchored with a LOGICAL inset (`insetInlineStart`) so a
30
31
  * right-to-left row fills from the right — the root carries
31
32
  * `useDirectionProps()` for react-native-web to resolve it.
32
33
  *
@@ -52,12 +53,45 @@ const IS_WEB = Platform.OS === 'web';
52
53
  const STAR_COUNT = 5;
53
54
  const STAR_GAP = 2;
54
55
 
56
+ /**
57
+ * Bidi isolates and marks (U+2066–U+2069 LRI/RLI/FSI/PDI, U+200E/U+200F
58
+ * LRM/RLM, U+061C ALM) a localised string is wrapped in. None of them are
59
+ * part of the number.
60
+ */
61
+ const FORMAT_CHARACTERS = /[\u2066-\u2069\u200E\u200F\u061C]/g;
62
+ /** Arabic-Indic (U+0660) and extended Arabic-Indic (U+06F0) digit zeros. */
63
+ const ARABIC_ZERO = 0x0660;
64
+ const EXTENDED_ARABIC_ZERO = 0x06f0;
65
+
66
+ /**
67
+ * The number a drawn rating value stands for, for the `stars` fill: a number
68
+ * is itself; a string is read after dropping bidi/format characters, with a
69
+ * comma or Arabic decimal separator (U+066B) accepted as the decimal point and
70
+ * Arabic-Indic digits accepted — so "4,5" wrapped in FSI…PDI is 4.5. Otherwise it is
71
+ * `Number()`'s reading, so anything unreadable is `NaN` (no fill).
72
+ */
73
+ export function parseRatingValue(value: number | string): number {
74
+ if (typeof value === 'number') return value;
75
+ const text = value
76
+ .replace(FORMAT_CHARACTERS, '')
77
+ .trim()
78
+ .replace(/[\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) - ARABIC_ZERO))
79
+ .replace(/[\u06F0-\u06F9]/g, (d) => String(d.charCodeAt(0) - EXTENDED_ARABIC_ZERO))
80
+ .replace(/[,\u066B]/, '.');
81
+ return Number(text);
82
+ }
83
+
55
84
  /** How much of star `index` (0-based) a rating fills, `0..1`. */
56
85
  export function starFill(rating: number, index: number): number {
57
86
  if (!Number.isFinite(rating)) return 0;
58
87
  return Math.min(1, Math.max(0, rating - index));
59
88
  }
60
89
 
90
+ /** A fill on the five-star scale, clamped to `0..5`; `NaN` stays `NaN` (no fill). */
91
+ function clampRating(rating: number): number {
92
+ return Math.min(STAR_COUNT, Math.max(0, rating));
93
+ }
94
+
61
95
  function StarRow({
62
96
  rating,
63
97
  size,
@@ -113,6 +147,7 @@ function RatingComponent({
113
147
  starColor,
114
148
  countColor,
115
149
  emptyStarColor,
150
+ fillValue,
116
151
  accessibilityLabel,
117
152
  style,
118
153
  testID,
@@ -148,7 +183,7 @@ function RatingComponent({
148
183
  >
149
184
  {variant === 'stars' && rated ? (
150
185
  <StarRow
151
- rating={typeof value === 'number' ? value : Number(value)}
186
+ rating={clampRating(fillValue ?? parseRatingValue(value))}
152
187
  size={config.star}
153
188
  fill={starPaint}
154
189
  empty={emptyStarColor ?? theme.colors.border}
@@ -44,6 +44,15 @@ export interface RatingProps {
44
44
  countColor?: string;
45
45
  /** `stars` variant: the unfilled part of each star. Default the theme's border colour. */
46
46
  emptyStarColor?: string;
47
+ /**
48
+ * `stars` variant: the number the stars are filled to, clamped to `0..5`,
49
+ * independent of the drawn `value`. Pass it with a localised `value` string
50
+ * (`value="4,5"` with `fillValue={4.5}`) so the text follows the locale and
51
+ * the fill does not depend on parsing it. Without it a string `value` is
52
+ * parsed (bidi marks dropped, a comma decimal accepted). Ignored by
53
+ * `compact` and while there is no rating (`value` empty).
54
+ */
55
+ fillValue?: number;
47
56
  /**
48
57
  * Overrides the composed accessible name ("Rated 4.92 out of 5, 128
49
58
  * reviews"), which is English — pass a translated sentence here.
@@ -1,6 +1,8 @@
1
- import { forwardRef } from 'react';
1
+ import { forwardRef, useCallback, useMemo, useRef } from 'react';
2
2
  import { Platform, type TextInput, View } from 'react-native';
3
3
 
4
+ import { mergeRefs } from '../hooks/merge-refs';
5
+
4
6
  import { useTheme } from '../theme/use-theme';
5
7
  import { atoms as a } from '../styles';
6
8
  import { borderRadius } from '../styles/tokens';
@@ -18,22 +20,56 @@ type SearchProps = Omit<TextFieldInputProps, 'label'> & {
18
20
  };
19
21
 
20
22
  export const Search = forwardRef<TextInput, SearchProps>(
21
- function Search({ value, label = 'Search', onClearText, ...rest }, ref) {
23
+ function Search({ value, label = 'Search', onClearText, onFocus, onPressIn, ...rest }, ref) {
22
24
  const theme = useTheme();
23
25
  const showClear = value != null && value.length > 0;
24
26
 
27
+ // Select-on-focus, done here rather than through RN's `selectTextOnFocus`.
28
+ // On Android that prop does not select on focus: it arms a one-shot flag and
29
+ // calls `selectAll()` on the input's NEXT layout while focused. An
30
+ // autofocused search lays out nothing after focusing until the first
31
+ // keystroke mounts the clear button and pads the input for it — and that
32
+ // layout selects everything, so the SECOND keystroke replaced the query.
33
+ // Typing "mention" in one burst left "on" (OxyHQ/Mention#1126). Now a
34
+ // press-driven focus selects the current query once, immediately, and a
35
+ // programmatic focus (autoFocus, `ref.focus()`) selects nothing. Web never
36
+ // selected on focus and still does not.
37
+ const inputRef = useRef<TextInput | null>(null);
38
+ const refs = useMemo(() => mergeRefs([inputRef, ref]), [ref]);
39
+ const pressedRef = useRef(false);
40
+ const valueRef = useRef(value);
41
+ valueRef.current = value;
42
+ const handlePressIn = useCallback<NonNullable<SearchProps['onPressIn']>>(
43
+ (event) => {
44
+ pressedRef.current = true;
45
+ onPressIn?.(event);
46
+ },
47
+ [onPressIn],
48
+ );
49
+ const handleFocus = useCallback<NonNullable<SearchProps['onFocus']>>(
50
+ (event) => {
51
+ onFocus?.(event);
52
+ const pressed = pressedRef.current;
53
+ pressedRef.current = false;
54
+ const length = valueRef.current?.length ?? 0;
55
+ if (pressed && Platform.OS !== 'web' && length > 0) {
56
+ inputRef.current?.setSelection(0, length);
57
+ }
58
+ },
59
+ [onFocus],
60
+ );
61
+
25
62
  return (
26
63
  <View style={[a.w_full, a.relative]}>
27
64
  <TextField radius={borderRadius.full}>
28
65
  <TextFieldIcon icon={MagnifyingGlassIcon} />
29
66
  <TextFieldInput
30
- inputRef={ref}
67
+ inputRef={refs}
31
68
  label={label}
32
69
  value={value}
33
70
  placeholder={label}
34
71
  returnKeyType="search"
35
72
  keyboardAppearance={theme.mode === 'light' ? 'light' : 'dark'}
36
- selectTextOnFocus={Platform.OS !== 'web'}
37
73
  autoFocus={false}
38
74
  accessibilityRole="search"
39
75
  autoCorrect={false}
@@ -41,6 +77,8 @@ export const Search = forwardRef<TextInput, SearchProps>(
41
77
  autoCapitalize="none"
42
78
  style={showClear ? { paddingRight: 24 } : undefined}
43
79
  {...rest}
80
+ onPressIn={handlePressIn}
81
+ onFocus={handleFocus}
44
82
  />
45
83
  </TextField>
46
84
 
@@ -37,6 +37,13 @@ export type SelectTriggerProps = {
37
37
  /**
38
38
  * Utility classes APPENDED to the part's own — never substituted for them, so
39
39
  * a single layout class cannot strip the chrome.
40
+ *
41
+ * Never pin a HEIGHT here (`h-8`). The value's line box is 20 × the system
42
+ * font scale, and on native Yoga measures the text at most as tall as the
43
+ * space left inside the field: a fixed height cuts the glyphs in half on
44
+ * Android (OxyHQ/Mention#1126 — `h-8` is 28dp there, leaving 15.5dp for a
45
+ * 20dp line). Size a compact trigger with its padding (`fieldStyle`) so the
46
+ * text sets the height.
40
47
  */
41
48
  className?: string;
42
49
  /**
@@ -6,7 +6,7 @@ import React, {
6
6
  useRef,
7
7
  useState,
8
8
  } from 'react';
9
- import { BackHandler, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
9
+ import { Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
10
10
  import Animated, {
11
11
  Easing,
12
12
  useAnimatedStyle,
@@ -263,18 +263,13 @@ export function SettingsModal({
263
263
  const compactPageOpenRef = useRef(false);
264
264
  compactPageOpenRef.current = compactPageOpen && layoutRef.current === 'compact';
265
265
 
266
- // Android back closes, like a native modal.
267
- useEffect(() => {
268
- // `BackHandler` is absent from some test environments' react-native mocks.
269
- if (!mounted || IS_WEB || typeof BackHandler?.addEventListener !== 'function') return;
270
- const sub = BackHandler.addEventListener('hardwareBackPress', () => {
271
- // On a pushed page (compact), back returns to the section list first.
272
- if (compactPageOpenRef.current) requestNavigation();
273
- else requestClose();
274
- return true;
275
- });
276
- return () => sub.remove();
277
- }, [mounted, requestClose, requestNavigation]);
266
+ // Android back closes, like a native modal — handed to `OverlayRoot`, which
267
+ // owns hardware back for every portaled surface.
268
+ const onHardwareBack = useCallback(() => {
269
+ // On a pushed page (compact), back returns to the section list first.
270
+ if (compactPageOpenRef.current) requestNavigation();
271
+ else requestClose();
272
+ }, [requestClose, requestNavigation]);
278
273
 
279
274
  // ----- saved toast -------------------------------------------------------
280
275
  const [savedPhase, setSavedPhase] = useState<SavedPhase>('hidden');
@@ -351,7 +346,7 @@ export function SettingsModal({
351
346
  return (
352
347
  <ModalPortal>
353
348
  <SettingsModalContext.Provider value={context}>
354
- <OverlayRoot>
349
+ <OverlayRoot onRequestClose={onHardwareBack} modal>
355
350
  <Backdrop
356
351
  onPress={requestClose}
357
352
  progress={progress}
@@ -813,7 +808,17 @@ function SavedToast({
813
808
 
814
809
  const styles = StyleSheet.create({
815
810
  center: {
816
- ...StyleSheet.absoluteFillObject,
811
+ // Spelled out, not `StyleSheet.absoluteFillObject`: RN 0.85 removed it, and
812
+ // spreading the resulting `undefined` left this box in flow at ZERO height.
813
+ // The panel still painted (nothing clips it) and still took touches, but
814
+ // Android reports every view under a zero-area ancestor as not visible to
815
+ // the user, so TalkBack and UI Automator saw no modal content at all —
816
+ // only the backdrop's full-screen "Close".
817
+ position: 'absolute',
818
+ top: 0,
819
+ left: 0,
820
+ right: 0,
821
+ bottom: 0,
817
822
  alignItems: 'center',
818
823
  justifyContent: 'center',
819
824
  padding: VIEWPORT_GUTTER,
@@ -25,7 +25,7 @@ import PortalHost from "./PortalHostView";
25
25
  * export default function App() {
26
26
  * return (
27
27
  * <PortalProvider>
28
- * <PortalHost style={StyleSheet.absoluteFillObject} name="overlay" />
28
+ * <PortalHost style={StyleSheet.absoluteFill} name="overlay" />
29
29
  * </PortalProvider>
30
30
  * );
31
31
  * }
@@ -25,6 +25,7 @@ import {
25
25
  Gesture,
26
26
  GestureDetector,
27
27
  } from 'react-native-gesture-handler';
28
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
28
29
  import { borderRadius } from '../styles/tokens';
29
30
  import { Backdrop, OverlayRoot } from '../overlay';
30
31
  import { Portal } from '../portal';
@@ -109,6 +110,13 @@ function shareUri(item: GalleryMedia): string | undefined {
109
110
  // image. `Platform.select` keeps them off native. `cursor` is a real
110
111
  // `ViewStyle` key on RN 0.83; `userSelect` is declared on `TextStyle` only,
111
112
  // which is what `WebCssStyle` covers.
113
+ /** Share button inset from the top-right corner of the safe area. */
114
+ const CHROME_EDGE = 16;
115
+ /** Page indicator (and a lone caption) above the bottom of the safe area. */
116
+ const INDICATOR_BOTTOM = 48;
117
+ /** A caption lifted above the page counter + dots when the indicator shows. */
118
+ const CAPTION_BOTTOM_WITH_INDICATOR = 110;
119
+
112
120
  const WEB_POINTER: WebCssStyle = { userSelect: 'none', cursor: 'pointer' };
113
121
  const WEB_USER_SELECT_NONE: WebCssStyle = { userSelect: 'none' };
114
122
 
@@ -207,6 +215,9 @@ function resolveCornerRadius(cornerRadius: number | 'circle', fit: FittedSize):
207
215
  */
208
216
  const ZoomableMediaGalleryInner = React.forwardRef<ZoomableMediaGalleryHandle, ZoomableMediaGalleryProps>(({ measureThumb, cornerRadius = DEFAULT_CORNER_RADIUS, indicatorVariant = 'dots', videoControls = false }, ref) => {
209
217
  const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = useWindowDimensions();
218
+ // The viewer is full-bleed, so its chrome must clear the status bar, the
219
+ // cutout and the home indicator itself — nothing above it applies them.
220
+ const insets = useSafeAreaInsets();
210
221
 
211
222
  const radiusFor = useCallback(
212
223
  (fit: FittedSize) => resolveCornerRadius(cornerRadius, fit),
@@ -928,7 +939,10 @@ const ZoomableMediaGalleryInner = React.forwardRef<ZoomableMediaGalleryHandle, Z
928
939
  }, [handleDismiss, isOpen, pageTo]);
929
940
 
930
941
  const renderContent = () => (
931
- <OverlayRoot style={styles.modalContainer}>
942
+ // `onRequestClose`: Android back flies the media home like every other
943
+ // dismiss. The viewer is a Portal, not an RN `Modal`, so without it nothing
944
+ // consumed the press and back finished the whole app.
945
+ <OverlayRoot style={styles.modalContainer} onRequestClose={handleDismiss} modal>
932
946
  <GestureHandlerRootView style={StyleSheet.absoluteFill}>
933
947
  {/* Shared `Backdrop` (a Pressable) rather than a `Gesture.Tap()`: the
934
948
  viewer renders through the web Portal, whose root is
@@ -1063,7 +1077,10 @@ const ZoomableMediaGalleryInner = React.forwardRef<ZoomableMediaGalleryHandle, Z
1063
1077
  )}
1064
1078
 
1065
1079
  {pagerReady && items.length > 1 && (
1066
- <Animated.View style={[styles.indicatorWrap, backdropStyle]} pointerEvents="box-none">
1080
+ <Animated.View
1081
+ style={[styles.indicatorWrap, { bottom: INDICATOR_BOTTOM + insets.bottom }, backdropStyle]}
1082
+ pointerEvents="box-none"
1083
+ >
1067
1084
  <View style={styles.counterPill} pointerEvents="none">
1068
1085
  <Text style={styles.counterText}>{`${activeIndex + 1} / ${items.length}`}</Text>
1069
1086
  </View>
@@ -1120,7 +1137,11 @@ const ZoomableMediaGalleryInner = React.forwardRef<ZoomableMediaGalleryHandle, Z
1120
1137
  accessibilityRole="button"
1121
1138
  accessibilityLabel="Share media"
1122
1139
  hitSlop={8}
1123
- style={[styles.shareButton, webPointerStyle]}
1140
+ style={[
1141
+ styles.shareButton,
1142
+ { top: CHROME_EDGE + insets.top, right: CHROME_EDGE + insets.right },
1143
+ webPointerStyle,
1144
+ ]}
1124
1145
  >
1125
1146
  <RiUpload2Line fill="#fff" size="md" />
1126
1147
  </Pressable>
@@ -1130,7 +1151,11 @@ const ZoomableMediaGalleryInner = React.forwardRef<ZoomableMediaGalleryHandle, Z
1130
1151
  <Animated.View
1131
1152
  style={[
1132
1153
  styles.altCaptionWrap,
1133
- items.length > 1 && styles.altCaptionWrapWithIndicator,
1154
+ {
1155
+ bottom:
1156
+ (items.length > 1 ? CAPTION_BOTTOM_WITH_INDICATOR : INDICATOR_BOTTOM) +
1157
+ insets.bottom,
1158
+ },
1134
1159
  backdropStyle,
1135
1160
  ]}
1136
1161
  pointerEvents="none"
@@ -1209,9 +1234,9 @@ const styles = StyleSheet.create({
1209
1234
  mediaBox: {
1210
1235
  overflow: 'hidden',
1211
1236
  },
1237
+ // `bottom` is inline: INDICATOR_BOTTOM above the bottom safe-area inset.
1212
1238
  indicatorWrap: {
1213
1239
  position: 'absolute',
1214
- bottom: 48,
1215
1240
  left: 0,
1216
1241
  right: 0,
1217
1242
  alignItems: 'center',
@@ -1298,10 +1323,10 @@ const styles = StyleSheet.create({
1298
1323
  backgroundColor: 'rgba(0,0,0,0.7)',
1299
1324
  transform: [{ translateY: -22 }, { scale: 1.08 }],
1300
1325
  },
1326
+ // `top`/`right` are inline: CHROME_EDGE inside the safe area. A bare 16
1327
+ // put the button under the Android status bar, over the battery icon.
1301
1328
  shareButton: {
1302
1329
  position: 'absolute',
1303
- top: 16,
1304
- right: 16,
1305
1330
  width: 40,
1306
1331
  height: 40,
1307
1332
  borderRadius: borderRadius.full,
@@ -1309,18 +1334,14 @@ const styles = StyleSheet.create({
1309
1334
  justifyContent: 'center',
1310
1335
  backgroundColor: 'rgba(0,0,0,0.45)',
1311
1336
  },
1337
+ // `bottom` is inline, above the indicator when there is one, inside the safe area.
1312
1338
  altCaptionWrap: {
1313
1339
  position: 'absolute',
1314
- bottom: 48,
1315
1340
  left: 0,
1316
1341
  right: 0,
1317
1342
  alignItems: 'center',
1318
1343
  paddingHorizontal: 24,
1319
1344
  },
1320
- // Lifted above the page counter + dots when the multi-image indicator shows.
1321
- altCaptionWrapWithIndicator: {
1322
- bottom: 110,
1323
- },
1324
1345
  altCaptionPill: {
1325
1346
  maxWidth: 600,
1326
1347
  paddingHorizontal: 14,