@oxyhq/bloom 0.51.1 → 0.52.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.
Files changed (233) hide show
  1. package/lib/commonjs/bottom-sheet/BottomSheetBase.js +7 -4
  2. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
  3. package/lib/commonjs/connection-dots/ConnectionDots.web.js.map +1 -1
  4. package/lib/commonjs/design-tokens/shadows.js +4 -0
  5. package/lib/commonjs/design-tokens/shadows.js.map +1 -1
  6. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  7. package/lib/commonjs/dialog/Dialog.web.js +26 -10
  8. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  9. package/lib/commonjs/frosted-icon-button/shared.js +10 -0
  10. package/lib/commonjs/frosted-icon-button/shared.js.map +1 -1
  11. package/lib/commonjs/index.js +66 -66
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +90 -90
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/commonjs/loading/Loading.web.js.map +1 -1
  16. package/lib/commonjs/loading/SpinnerIcon.web.js +10 -6
  17. package/lib/commonjs/loading/SpinnerIcon.web.js.map +1 -1
  18. package/lib/commonjs/motion/index.js +1 -1
  19. package/lib/commonjs/motion/index.web.js +32 -0
  20. package/lib/commonjs/motion/index.web.js.map +1 -0
  21. package/lib/commonjs/motion/motion.js +10 -2
  22. package/lib/commonjs/motion/motion.js.map +1 -1
  23. package/lib/commonjs/motion/motion.web.js +137 -0
  24. package/lib/commonjs/motion/motion.web.js.map +1 -0
  25. package/lib/commonjs/progressive-blur/index.web.js.map +1 -1
  26. package/lib/commonjs/styles/web-view-style.js +33 -0
  27. package/lib/commonjs/styles/web-view-style.js.map +1 -1
  28. package/lib/commonjs/tab-bar/TabBarBase.js +46 -7
  29. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  30. package/lib/commonjs/tab-bar/glyph.js +9 -4
  31. package/lib/commonjs/tab-bar/glyph.js.map +1 -1
  32. package/lib/commonjs/tab-bar/glyph.native.js +7 -2
  33. package/lib/commonjs/tab-bar/glyph.native.js.map +1 -1
  34. package/lib/commonjs/tab-bar/index.js +7 -0
  35. package/lib/commonjs/tab-bar/index.js.map +1 -1
  36. package/lib/commonjs/tab-bar/index.web.js +7 -0
  37. package/lib/commonjs/tab-bar/index.web.js.map +1 -1
  38. package/lib/commonjs/tab-bar/shared.js +32 -1
  39. package/lib/commonjs/tab-bar/shared.js.map +1 -1
  40. package/lib/commonjs/tab-bar/surface.web.js.map +1 -1
  41. package/lib/commonjs/tab-bar/use-footprint.js +56 -0
  42. package/lib/commonjs/tab-bar/use-footprint.js.map +1 -0
  43. package/lib/commonjs/toast/ToastRow.js +7 -3
  44. package/lib/commonjs/toast/ToastRow.js.map +1 -1
  45. package/lib/commonjs/toast/ToastSwipeHandler.js +29 -13
  46. package/lib/commonjs/toast/ToastSwipeHandler.js.map +1 -1
  47. package/lib/commonjs/toast/constants.js +29 -1
  48. package/lib/commonjs/toast/constants.js.map +1 -1
  49. package/lib/commonjs/toast/position-utils.js +9 -4
  50. package/lib/commonjs/toast/position-utils.js.map +1 -1
  51. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +12 -9
  52. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  53. package/lib/module/bottom-sheet/BottomSheetBase.js +7 -4
  54. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  55. package/lib/module/connection-dots/ConnectionDots.web.js.map +1 -1
  56. package/lib/module/design-tokens/shadows.js +4 -0
  57. package/lib/module/design-tokens/shadows.js.map +1 -1
  58. package/lib/module/dialog/Dialog.js.map +1 -1
  59. package/lib/module/dialog/Dialog.web.js +26 -10
  60. package/lib/module/dialog/Dialog.web.js.map +1 -1
  61. package/lib/module/frosted-icon-button/shared.js +10 -0
  62. package/lib/module/frosted-icon-button/shared.js.map +1 -1
  63. package/lib/module/index.js +1 -1
  64. package/lib/module/index.js.map +1 -1
  65. package/lib/module/index.web.js +1 -1
  66. package/lib/module/index.web.js.map +1 -1
  67. package/lib/module/loading/Loading.web.js.map +1 -1
  68. package/lib/module/loading/SpinnerIcon.web.js +10 -6
  69. package/lib/module/loading/SpinnerIcon.web.js.map +1 -1
  70. package/lib/module/motion/index.js +1 -1
  71. package/lib/module/motion/index.js.map +1 -1
  72. package/lib/module/motion/index.web.js +10 -0
  73. package/lib/module/motion/index.web.js.map +1 -0
  74. package/lib/module/motion/motion.js +10 -2
  75. package/lib/module/motion/motion.js.map +1 -1
  76. package/lib/module/motion/motion.web.js +133 -0
  77. package/lib/module/motion/motion.web.js.map +1 -0
  78. package/lib/module/progressive-blur/index.web.js.map +1 -1
  79. package/lib/module/styles/web-view-style.js +33 -0
  80. package/lib/module/styles/web-view-style.js.map +1 -1
  81. package/lib/module/tab-bar/TabBarBase.js +47 -8
  82. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  83. package/lib/module/tab-bar/glyph.js +9 -4
  84. package/lib/module/tab-bar/glyph.js.map +1 -1
  85. package/lib/module/tab-bar/glyph.native.js +7 -2
  86. package/lib/module/tab-bar/glyph.native.js.map +1 -1
  87. package/lib/module/tab-bar/index.js +1 -0
  88. package/lib/module/tab-bar/index.js.map +1 -1
  89. package/lib/module/tab-bar/index.web.js +1 -0
  90. package/lib/module/tab-bar/index.web.js.map +1 -1
  91. package/lib/module/tab-bar/shared.js +30 -0
  92. package/lib/module/tab-bar/shared.js.map +1 -1
  93. package/lib/module/tab-bar/surface.web.js.map +1 -1
  94. package/lib/module/tab-bar/use-footprint.js +52 -0
  95. package/lib/module/tab-bar/use-footprint.js.map +1 -0
  96. package/lib/module/toast/ToastRow.js +8 -4
  97. package/lib/module/toast/ToastRow.js.map +1 -1
  98. package/lib/module/toast/ToastSwipeHandler.js +29 -13
  99. package/lib/module/toast/ToastSwipeHandler.js.map +1 -1
  100. package/lib/module/toast/constants.js +28 -1
  101. package/lib/module/toast/constants.js.map +1 -1
  102. package/lib/module/toast/position-utils.js +9 -4
  103. package/lib/module/toast/position-utils.js.map +1 -1
  104. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +12 -9
  105. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  106. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/connection-dots/ConnectionDots.web.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/design-tokens/shadows.d.ts +4 -0
  109. package/lib/typescript/commonjs/design-tokens/shadows.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +10 -0
  112. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/index.web.d.ts +2 -2
  114. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/loading/Loading.web.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/loading/SpinnerIcon.web.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/motion/index.web.d.ts +9 -0
  118. package/lib/typescript/commonjs/motion/index.web.d.ts.map +1 -0
  119. package/lib/typescript/commonjs/motion/motion.d.ts +14 -6
  120. package/lib/typescript/commonjs/motion/motion.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/motion/motion.web.d.ts +79 -0
  122. package/lib/typescript/commonjs/motion/motion.web.d.ts.map +1 -0
  123. package/lib/typescript/commonjs/progressive-blur/index.web.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/styles/web-view-style.d.ts +49 -0
  125. package/lib/typescript/commonjs/styles/web-view-style.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/tab-bar/glyph.d.ts +1 -1
  128. package/lib/typescript/commonjs/tab-bar/glyph.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts +1 -1
  130. package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/tab-bar/index.d.ts +1 -0
  132. package/lib/typescript/commonjs/tab-bar/index.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/tab-bar/index.web.d.ts +1 -0
  134. package/lib/typescript/commonjs/tab-bar/index.web.d.ts.map +1 -1
  135. package/lib/typescript/commonjs/tab-bar/shared.d.ts +25 -0
  136. package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
  137. package/lib/typescript/commonjs/tab-bar/surface.web.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/tab-bar/types.d.ts +37 -1
  139. package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
  140. package/lib/typescript/commonjs/tab-bar/use-footprint.d.ts +31 -0
  141. package/lib/typescript/commonjs/tab-bar/use-footprint.d.ts.map +1 -0
  142. package/lib/typescript/commonjs/toast/ToastRow.d.ts.map +1 -1
  143. package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts +12 -6
  144. package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts.map +1 -1
  145. package/lib/typescript/commonjs/toast/constants.d.ts +26 -8
  146. package/lib/typescript/commonjs/toast/constants.d.ts.map +1 -1
  147. package/lib/typescript/commonjs/toast/position-utils.d.ts +8 -3
  148. package/lib/typescript/commonjs/toast/position-utils.d.ts.map +1 -1
  149. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  150. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  151. package/lib/typescript/module/connection-dots/ConnectionDots.web.d.ts.map +1 -1
  152. package/lib/typescript/module/design-tokens/shadows.d.ts +4 -0
  153. package/lib/typescript/module/design-tokens/shadows.d.ts.map +1 -1
  154. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  155. package/lib/typescript/module/frosted-icon-button/shared.d.ts +10 -0
  156. package/lib/typescript/module/frosted-icon-button/shared.d.ts.map +1 -1
  157. package/lib/typescript/module/index.web.d.ts +2 -2
  158. package/lib/typescript/module/index.web.d.ts.map +1 -1
  159. package/lib/typescript/module/loading/Loading.web.d.ts.map +1 -1
  160. package/lib/typescript/module/loading/SpinnerIcon.web.d.ts.map +1 -1
  161. package/lib/typescript/module/motion/index.web.d.ts +9 -0
  162. package/lib/typescript/module/motion/index.web.d.ts.map +1 -0
  163. package/lib/typescript/module/motion/motion.d.ts +14 -6
  164. package/lib/typescript/module/motion/motion.d.ts.map +1 -1
  165. package/lib/typescript/module/motion/motion.web.d.ts +79 -0
  166. package/lib/typescript/module/motion/motion.web.d.ts.map +1 -0
  167. package/lib/typescript/module/progressive-blur/index.web.d.ts.map +1 -1
  168. package/lib/typescript/module/styles/web-view-style.d.ts +49 -0
  169. package/lib/typescript/module/styles/web-view-style.d.ts.map +1 -1
  170. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  171. package/lib/typescript/module/tab-bar/glyph.d.ts +1 -1
  172. package/lib/typescript/module/tab-bar/glyph.d.ts.map +1 -1
  173. package/lib/typescript/module/tab-bar/glyph.native.d.ts +1 -1
  174. package/lib/typescript/module/tab-bar/glyph.native.d.ts.map +1 -1
  175. package/lib/typescript/module/tab-bar/index.d.ts +1 -0
  176. package/lib/typescript/module/tab-bar/index.d.ts.map +1 -1
  177. package/lib/typescript/module/tab-bar/index.web.d.ts +1 -0
  178. package/lib/typescript/module/tab-bar/index.web.d.ts.map +1 -1
  179. package/lib/typescript/module/tab-bar/shared.d.ts +25 -0
  180. package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
  181. package/lib/typescript/module/tab-bar/surface.web.d.ts.map +1 -1
  182. package/lib/typescript/module/tab-bar/types.d.ts +37 -1
  183. package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
  184. package/lib/typescript/module/tab-bar/use-footprint.d.ts +31 -0
  185. package/lib/typescript/module/tab-bar/use-footprint.d.ts.map +1 -0
  186. package/lib/typescript/module/toast/ToastRow.d.ts.map +1 -1
  187. package/lib/typescript/module/toast/ToastSwipeHandler.d.ts +12 -6
  188. package/lib/typescript/module/toast/ToastSwipeHandler.d.ts.map +1 -1
  189. package/lib/typescript/module/toast/constants.d.ts +26 -8
  190. package/lib/typescript/module/toast/constants.d.ts.map +1 -1
  191. package/lib/typescript/module/toast/position-utils.d.ts +8 -3
  192. package/lib/typescript/module/toast/position-utils.d.ts.map +1 -1
  193. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  194. package/package.json +6 -1
  195. package/src/__tests__/Motion.test.tsx +31 -3
  196. package/src/__tests__/MotionWebFork.test.ts +140 -0
  197. package/src/__tests__/TabBar.test.tsx +82 -2
  198. package/src/__tests__/TabBarAnimatedShorthand.test.ts +135 -0
  199. package/src/__tests__/TabBarFootprint.test.tsx +172 -0
  200. package/src/__tests__/TabBarLongPress.test.tsx +258 -0
  201. package/src/__tests__/TabBarWebFork.test.ts +1 -0
  202. package/src/__tests__/ToastHostWebFork.test.ts +6 -2
  203. package/src/__tests__/Toaster.test.tsx +62 -1
  204. package/src/__tests__/toast-position-utils.test.ts +23 -7
  205. package/src/__tests__/web-css-style.test.ts +139 -0
  206. package/src/bottom-sheet/BottomSheetBase.tsx +11 -7
  207. package/src/connection-dots/ConnectionDots.web.tsx +4 -3
  208. package/src/design-tokens/shadows.ts +5 -1
  209. package/src/dialog/Dialog.tsx +1 -1
  210. package/src/dialog/Dialog.web.tsx +41 -26
  211. package/src/frosted-icon-button/shared.ts +10 -0
  212. package/src/index.web.ts +2 -2
  213. package/src/loading/Loading.web.tsx +7 -5
  214. package/src/loading/SpinnerIcon.web.tsx +12 -9
  215. package/src/motion/index.web.ts +8 -0
  216. package/src/motion/motion.ts +14 -6
  217. package/src/motion/motion.web.ts +99 -0
  218. package/src/progressive-blur/index.web.tsx +4 -3
  219. package/src/styles/web-view-style.ts +50 -0
  220. package/src/tab-bar/TabBarBase.tsx +54 -15
  221. package/src/tab-bar/glyph.native.tsx +5 -2
  222. package/src/tab-bar/glyph.tsx +8 -4
  223. package/src/tab-bar/index.ts +1 -0
  224. package/src/tab-bar/index.web.tsx +1 -0
  225. package/src/tab-bar/shared.ts +38 -0
  226. package/src/tab-bar/surface.web.tsx +4 -3
  227. package/src/tab-bar/types.ts +37 -1
  228. package/src/tab-bar/use-footprint.ts +50 -0
  229. package/src/toast/ToastRow.tsx +8 -3
  230. package/src/toast/ToastSwipeHandler.tsx +30 -12
  231. package/src/toast/constants.ts +28 -0
  232. package/src/toast/position-utils.ts +10 -5
  233. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +9 -8
@@ -0,0 +1,258 @@
1
+ import React from 'react';
2
+ import { act, render } from '@testing-library/react-native';
3
+
4
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
5
+ import { TabBar, TabBarButton } from '../tab-bar';
6
+ import type { TabBarItem } from '../tab-bar/types';
7
+
8
+ /**
9
+ * `onIndexLongPress` — the bar's only reachable long press.
10
+ *
11
+ * `TabBarButtonProps extends PressableProps`, so a consumer's `onLongPress`
12
+ * type-checks and silently does nothing: the bar's `GestureDetector` consumes
13
+ * the touches (that is what makes scrubbing possible), so the inner `Pressable`
14
+ * only ever fires for assistive technology and keyboard activation. A real held
15
+ * finger fails the tap gesture at 400ms, never activates the pan, and dies in
16
+ * `onFinalize`'s early return. The gesture below is the only path in.
17
+ *
18
+ * Like `BottomSheetGesture.test.tsx`, this file replaces the shared
19
+ * gesture-handler mock (which discards every registered callback) with a
20
+ * RECORDING one, so the gestures the bar builds can be inspected and their
21
+ * worklets invoked directly. What that CAN prove: which gestures are composed
22
+ * into the race, the duration windows they are configured with, and that the
23
+ * long-press worklet resolves the same index from the same x as the tap
24
+ * worklet. What it cannot prove is gesture-handler's own arbitration — that a
25
+ * held finger really reaches this handler on a device. The 400ms/500ms split
26
+ * asserted below is what makes that arbitration decidable rather than a race,
27
+ * and the behaviour itself is verified on a real build.
28
+ */
29
+
30
+ interface MockGestureEvent {
31
+ x: number;
32
+ }
33
+
34
+ interface MockGesture {
35
+ kind: string;
36
+ /** Values captured from the builder chain. */
37
+ minDurationMs?: number;
38
+ maxDurationMs?: number;
39
+ /** Worklets captured from the builder chain. */
40
+ start?: (event: MockGestureEvent) => void;
41
+ update?: (event: MockGestureEvent) => void;
42
+ end?: (event: MockGestureEvent, success: boolean) => void;
43
+ /** Members of a composed `Gesture.Race(…)`; empty for a leaf gesture. */
44
+ members: MockGesture[];
45
+ /** Builder chain — every method returns the same object. */
46
+ activeOffsetX: (range: number[]) => MockGesture;
47
+ failOffsetY: (range: number[]) => MockGesture;
48
+ maxDistance: (px: number) => MockGesture;
49
+ maxDuration: (ms: number) => MockGesture;
50
+ minDuration: (ms: number) => MockGesture;
51
+ onStart: (worklet: (event: MockGestureEvent) => void) => MockGesture;
52
+ onUpdate: (worklet: (event: MockGestureEvent) => void) => MockGesture;
53
+ onEnd: (worklet: (event: MockGestureEvent, success: boolean) => void) => MockGesture;
54
+ onFinalize: (worklet: () => void) => MockGesture;
55
+ }
56
+
57
+ // Every gesture built since the last reset. Must be `mock`-prefixed so the
58
+ // hoisted `jest.mock` factory may reference it.
59
+ const mockGestures: MockGesture[] = [];
60
+
61
+ jest.mock('react-native-gesture-handler', () => {
62
+ const build = (kind: string): MockGesture => {
63
+ const gesture: MockGesture = {
64
+ kind,
65
+ members: [],
66
+ activeOffsetX: () => gesture,
67
+ failOffsetY: () => gesture,
68
+ maxDistance: () => gesture,
69
+ maxDuration: (ms) => {
70
+ gesture.maxDurationMs = ms;
71
+ return gesture;
72
+ },
73
+ minDuration: (ms) => {
74
+ gesture.minDurationMs = ms;
75
+ return gesture;
76
+ },
77
+ onStart: (worklet) => {
78
+ gesture.start = worklet;
79
+ return gesture;
80
+ },
81
+ onUpdate: (worklet) => {
82
+ gesture.update = worklet;
83
+ return gesture;
84
+ },
85
+ onEnd: (worklet) => {
86
+ gesture.end = worklet;
87
+ return gesture;
88
+ },
89
+ onFinalize: () => gesture,
90
+ };
91
+ mockGestures.push(gesture);
92
+ return gesture;
93
+ };
94
+
95
+ return {
96
+ Gesture: {
97
+ Pan: () => build('Pan'),
98
+ Tap: () => build('Tap'),
99
+ LongPress: () => build('LongPress'),
100
+ Race: (...members: MockGesture[]) => {
101
+ const race = build('Race');
102
+ race.members.push(...members);
103
+ return race;
104
+ },
105
+ },
106
+ GestureDetector: ({ children }: { children: React.ReactNode }) => children,
107
+ GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => children,
108
+ };
109
+ });
110
+
111
+ const ITEMS: TabBarItem[] = [
112
+ { name: 'home', label: 'Home', icon: null },
113
+ { name: 'search', label: 'Search', icon: null },
114
+ { name: 'you', label: 'You', icon: null },
115
+ ];
116
+
117
+ /**
118
+ * Touch x positions inside the bar, in the 375pt window the react-native mock
119
+ * reports. Named by the tab they land on rather than by the geometry that puts
120
+ * them there: an edge touch belongs to the first/last tab and a centered one to
121
+ * the middle tab whatever the exact item width turns out to be.
122
+ */
123
+ const NEAR_LEFT_EDGE = 8;
124
+ const WINDOW_CENTER = 187;
125
+ const NEAR_RIGHT_EDGE = 340;
126
+
127
+ function renderBar(props: {
128
+ onIndexChange?: (index: number) => void;
129
+ onIndexLongPress?: (index: number) => void;
130
+ }) {
131
+ return render(
132
+ <BloomThemeProvider mode="light" colorPreset="teal">
133
+ <TabBar activeIndex={0} {...props}>
134
+ {ITEMS.map((item, index) => (
135
+ <TabBarButton key={item.name} item={item} index={index} />
136
+ ))}
137
+ </TabBar>
138
+ </BloomThemeProvider>,
139
+ );
140
+ }
141
+
142
+ const gesturesOfKind = (kind: string) => mockGestures.filter((gesture) => gesture.kind === kind);
143
+
144
+ /** The gesture the detector was actually given — the last race constructed. */
145
+ function race(): MockGesture {
146
+ const races = gesturesOfKind('Race');
147
+ const last = races[races.length - 1];
148
+ if (!last) throw new Error('The bar composed no gesture race');
149
+ return last;
150
+ }
151
+
152
+ function member(kind: string): MockGesture {
153
+ const found = race().members.find((gesture) => gesture.kind === kind);
154
+ if (!found) throw new Error(`No ${kind} gesture in the bar's race`);
155
+ return found;
156
+ }
157
+
158
+ /** Hold a finger at `x` until the long press activates. */
159
+ function longPressAt(x: number) {
160
+ act(() => {
161
+ member('LongPress').start?.({ x });
162
+ });
163
+ }
164
+
165
+ /** A tap that succeeded at `x`. */
166
+ function tapAt(x: number) {
167
+ act(() => {
168
+ member('Tap').end?.({ x }, true);
169
+ });
170
+ }
171
+
172
+ describe('TabBar long press', () => {
173
+ beforeEach(() => {
174
+ mockGestures.length = 0;
175
+ });
176
+
177
+ it('composes no long-press gesture when no handler is given', () => {
178
+ // An always-armed long press would cancel the pan for anyone who rests a
179
+ // finger before scrubbing — a regression for every existing consumer.
180
+ renderBar({});
181
+ expect(gesturesOfKind('LongPress')).toHaveLength(0);
182
+ expect(race().members.map((gesture) => gesture.kind)).toEqual(['Pan', 'Tap']);
183
+ });
184
+
185
+ it('adds one to the race, alongside the pan and the tap, when a handler is given', () => {
186
+ renderBar({ onIndexLongPress: jest.fn() });
187
+ expect(race().members.map((gesture) => gesture.kind)).toEqual(['Pan', 'Tap', 'LongPress']);
188
+ });
189
+
190
+ it('arms it after the tap gesture has already given up', () => {
191
+ // The two windows must not overlap, or arming a long press would sometimes
192
+ // cost the bar a tap. The tap gives up at 400ms; the long press activates
193
+ // at 500ms, so at no instant are both candidates.
194
+ renderBar({ onIndexLongPress: jest.fn() });
195
+ expect(member('LongPress').minDurationMs).toBe(500);
196
+ expect(member('Tap').maxDurationMs).toBe(400);
197
+ expect(member('Tap').maxDurationMs ?? 0).toBeLessThan(member('LongPress').minDurationMs ?? 0);
198
+ });
199
+
200
+ it('reports the index of the tab under the finger', () => {
201
+ const onIndexLongPress = jest.fn();
202
+ renderBar({ onIndexLongPress });
203
+
204
+ longPressAt(NEAR_LEFT_EDGE);
205
+ longPressAt(WINDOW_CENTER);
206
+ longPressAt(NEAR_RIGHT_EDGE);
207
+
208
+ expect(onIndexLongPress.mock.calls).toEqual([[0], [1], [2]]);
209
+ });
210
+
211
+ it('resolves the same index a tap at the same point would', () => {
212
+ // Long press, tap and scrub all share one `indexAtX` worklet; this is what
213
+ // keeps a long press from ever acting on a different tab than the one the
214
+ // finger is visibly on.
215
+ const onIndexChange = jest.fn();
216
+ const onIndexLongPress = jest.fn();
217
+ renderBar({ onIndexChange, onIndexLongPress });
218
+
219
+ for (const x of [NEAR_LEFT_EDGE, 100, WINDOW_CENTER, 250, NEAR_RIGHT_EDGE]) {
220
+ tapAt(x);
221
+ longPressAt(x);
222
+ }
223
+
224
+ expect(onIndexLongPress.mock.calls).toEqual(onIndexChange.mock.calls);
225
+ });
226
+
227
+ it('does not select the tab it long-presses', () => {
228
+ // It is a secondary action (an account sheet, say) — navigating as well
229
+ // would take the user off the screen they opened it from.
230
+ const onIndexChange = jest.fn();
231
+ renderBar({ onIndexChange, onIndexLongPress: jest.fn() });
232
+
233
+ longPressAt(NEAR_RIGHT_EDGE);
234
+
235
+ expect(onIndexChange).not.toHaveBeenCalled();
236
+ });
237
+
238
+ it('still selects on a short tap while the long press is armed', () => {
239
+ const onIndexChange = jest.fn();
240
+ const onIndexLongPress = jest.fn();
241
+ renderBar({ onIndexChange, onIndexLongPress });
242
+
243
+ tapAt(NEAR_RIGHT_EDGE);
244
+
245
+ expect(onIndexChange).toHaveBeenCalledTimes(1);
246
+ expect(onIndexChange).toHaveBeenCalledWith(2);
247
+ expect(onIndexLongPress).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it('keeps the scrub gesture in the race', () => {
251
+ // The pan is what makes the highlight scrubbable; a long press must not
252
+ // have displaced it.
253
+ renderBar({ onIndexLongPress: jest.fn() });
254
+ // Its scrub worklet, not merely a pan object: this is the handler that
255
+ // drags the highlight under the finger.
256
+ expect(typeof member('Pan').update).toBe('function');
257
+ });
258
+ });
@@ -49,6 +49,7 @@ const NEUTRAL_AND_WEB_FILES = [
49
49
  'tab-bar/shared.ts',
50
50
  'tab-bar/TabBarBase.tsx',
51
51
  'tab-bar/minimize-context.tsx',
52
+ 'tab-bar/use-footprint.ts',
52
53
  'tab-bar/surface.tsx',
53
54
  'tab-bar/surface-solid.tsx',
54
55
  'tab-bar/surface.web.tsx',
@@ -71,10 +71,14 @@ describe('ToastHost platform split', () => {
71
71
  expect(styleModule).toMatch(
72
72
  /export const WEB_POSITION_FIXED = 'fixed' as ViewStyle\['position'\]/,
73
73
  );
74
- // Exactly one cast in the module, and nothing else exported alongside it.
74
+ // Exactly one cast in the module, and it is the only RUNTIME export.
75
75
  // (Counted against code — the doc comment discusses casts in prose.)
76
+ // The module's other export is the `WebCssStyle` interface, which covers the
77
+ // other half of the same RN/RNW gap and emits nothing — see
78
+ // `web-css-style.test.ts`.
76
79
  expect(code('styles/web-view-style.ts').match(/ as /g)).toHaveLength(1);
77
- expect(styleModule.match(/^export /gm)).toHaveLength(1);
80
+ expect(styleModule.match(/^export const /gm)).toHaveLength(1);
81
+ expect(styleModule.match(/^export (?!const |interface )/gm)).toBeNull();
78
82
  });
79
83
 
80
84
  it('no engine file other than the host reaches for a platform overlay', () => {
@@ -2,8 +2,12 @@ import React from 'react';
2
2
  import { AppState, Text } from 'react-native';
3
3
  import { act, fireEvent, render } from '@testing-library/react-native';
4
4
 
5
+ import { space } from '../styles/tokens';
5
6
  import { BloomThemeProvider } from '../theme/BloomThemeProvider';
6
- import { ENTERING_ANIMATION_DURATION } from '../toast/constants';
7
+ import {
8
+ ENTERING_ANIMATION_DURATION,
9
+ TOAST_MAX_ROW_WIDTH,
10
+ } from '../toast/constants';
7
11
  import { toast, ToastOutlet } from '../toast';
8
12
  import { toastStore } from '../toast/toast-store';
9
13
  import type { ToasterProps, ToastPosition } from '../toast/types';
@@ -137,6 +141,16 @@ const anchorOf = ({ UNSAFE_root }: ReturnType<typeof renderOutlet>) =>
137
141
  return style.position === 'absolute' && style.width === '100%';
138
142
  })[0];
139
143
 
144
+ /**
145
+ * The capped, centred row box — `ToastSwipeHandler`'s wrapper, the only row view
146
+ * carrying the `aria-live` announcement.
147
+ */
148
+ const rowBoxOf = ({ UNSAFE_root }: ReturnType<typeof renderOutlet>) =>
149
+ UNSAFE_root.find(
150
+ (node) =>
151
+ hostName(node) === 'Animated.View' && node.props['aria-live'] !== undefined,
152
+ );
153
+
140
154
  describe('ToastOutlet', () => {
141
155
  beforeEach(() => {
142
156
  jest.useFakeTimers();
@@ -382,6 +396,53 @@ describe('ToastOutlet', () => {
382
396
  expect(toastStore.getSnapshot().toastHeights[id]).toBeUndefined();
383
397
  });
384
398
 
399
+ /**
400
+ * W12 — the row box is capped so a toast does not span a desktop viewport.
401
+ * The cap sits on the swipe wrapper rather than on `ToastContent`'s card so it
402
+ * also covers `toast.custom` JSX and `unstyled` rows, which never render that
403
+ * card.
404
+ */
405
+ describe('row width cap', () => {
406
+ it('caps and centres the row box', () => {
407
+ const rendered = renderOutlet();
408
+ show(() => toast('Not a banner'));
409
+
410
+ expect(flattenStyle(rowBoxOf(rendered).props.style)).toEqual(
411
+ expect.objectContaining({
412
+ width: '100%',
413
+ maxWidth: TOAST_MAX_ROW_WIDTH,
414
+ // Without this the capped row sits against the left edge: a child with
415
+ // a definite cross size is not stretched by the anchor.
416
+ alignSelf: 'center',
417
+ }),
418
+ );
419
+ });
420
+
421
+ it('caps a custom-jsx row too, not just the default card', () => {
422
+ const rendered = renderOutlet();
423
+ show(() => toast.custom(<Text>Fully custom</Text>));
424
+
425
+ expect(flattenStyle(rowBoxOf(rendered).props.style).maxWidth).toBe(
426
+ TOAST_MAX_ROW_WIDTH,
427
+ );
428
+ });
429
+
430
+ it('leaves the card its gutters inside the cap', () => {
431
+ // The cap sizes the ROW, which carries the card's two `space.lg` gutters,
432
+ // so the visible card lands on sonner's 356px reference width.
433
+ expect(TOAST_MAX_ROW_WIDTH - space.lg * 2).toBe(356);
434
+ });
435
+
436
+ it('lets a consumer widen the cap through toastContainerStyle', () => {
437
+ const rendered = renderOutlet({
438
+ toastOptions: { toastContainerStyle: { maxWidth: 600 } },
439
+ });
440
+ show(() => toast('Wider please'));
441
+
442
+ expect(flattenStyle(rowBoxOf(rendered).props.style).maxWidth).toBe(600);
443
+ });
444
+ });
445
+
385
446
  /**
386
447
  * The outlet's `toastOptions` style slots and a per-toast `styles` object are
387
448
  * folded into ONE styles object (`mergeToastStyles`). Upstream threads all
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ESTIMATED_TOAST_HEIGHT,
3
3
  MIN_STACK_SCALE_X,
4
+ TOAST_MAX_ROW_WIDTH,
4
5
  } from '../toast/constants';
5
6
  import {
6
7
  calculateStackScaleX,
@@ -206,7 +207,7 @@ describe('calculateStackScaleX', () => {
206
207
  position = 'bottom-center' as ToastPosition,
207
208
  isExpanded = false,
208
209
  stackGap = 8,
209
- screenWidth = 400,
210
+ rowWidth = 400,
210
211
  }: {
211
212
  index: number;
212
213
  numberOfToasts?: number;
@@ -214,7 +215,7 @@ describe('calculateStackScaleX', () => {
214
215
  position?: ToastPosition;
215
216
  isExpanded?: boolean;
216
217
  stackGap?: number;
217
- screenWidth?: number;
218
+ rowWidth?: number;
218
219
  }) =>
219
220
  calculateStackScaleX({
220
221
  index,
@@ -223,14 +224,14 @@ describe('calculateStackScaleX', () => {
223
224
  position,
224
225
  isExpanded,
225
226
  stackGap,
226
- screenWidth,
227
+ rowWidth,
227
228
  });
228
229
 
229
230
  it.each([
230
231
  ['stacking is off', { index: 0, enableStacking: false }],
231
232
  ['there is only one row', { index: 0, numberOfToasts: 1 }],
232
233
  ['the stack is expanded', { index: 0, isExpanded: true }],
233
- ['the window has not been measured', { index: 0, screenWidth: 0 }],
234
+ ['the row has not been measured', { index: 0, rowWidth: 0 }],
234
235
  ])('does not squeeze when %s', (_label, params) => {
235
236
  expect(scaleAt(params)).toBe(1);
236
237
  });
@@ -249,11 +250,26 @@ describe('calculateStackScaleX', () => {
249
250
 
250
251
  it('never squeezes past MIN_STACK_SCALE_X', () => {
251
252
  expect(
252
- scaleAt({ index: 0, numberOfToasts: 50, stackGap: 40, screenWidth: 200 }),
253
+ scaleAt({ index: 0, numberOfToasts: 50, stackGap: 40, rowWidth: 200 }),
253
254
  ).toBe(MIN_STACK_SCALE_X);
254
255
  });
255
256
 
256
- it('scales the squeeze with the window width', () => {
257
- expect(scaleAt({ index: 1, screenWidth: 800 })).toBe(1 - 16 / 800);
257
+ it('scales the squeeze with the row width', () => {
258
+ expect(scaleAt({ index: 1, rowWidth: 800 })).toBe(1 - 16 / 800);
259
+ });
260
+
261
+ /**
262
+ * The squeeze is measured against the ROW, which is capped, not the window.
263
+ * Feeding it a desktop window width instead is the bug this pins: the buried
264
+ * row would narrow by 4 visible pixels rather than the intended 16.
265
+ */
266
+ it('squeezes a capped row by the same visible amount on a desktop viewport', () => {
267
+ const desktopWindowWidth = 1280;
268
+ expect(scaleAt({ index: 1, rowWidth: TOAST_MAX_ROW_WIDTH })).toBe(
269
+ 1 - 16 / TOAST_MAX_ROW_WIDTH,
270
+ );
271
+ expect(scaleAt({ index: 1, rowWidth: TOAST_MAX_ROW_WIDTH })).toBeLessThan(
272
+ scaleAt({ index: 1, rowWidth: desktopWindowWidth }),
273
+ );
258
274
  });
259
275
  });
@@ -0,0 +1,139 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Sibling of `web-position-fixed.test.ts`, guarding the OTHER half of the RN/RNW
6
+ * type gap.
7
+ *
8
+ * Eleven sites across seven files carried an inline `{ … } as ViewStyle` to smuggle
9
+ * web-only CSS (`animation`, `transition*`, `backdrop-filter`, `mask-image`,
10
+ * `scrollbar-*`, `user-select`) past React Native's `ViewStyle`. That is not a
11
+ * widening, it is SILENCING: `as` only requires the two types to be COMPARABLE, so
12
+ * `{ animatoin: '…' } as ViewStyle` compiles and the typo ships as a style key
13
+ * react-native-web drops on the floor.
14
+ *
15
+ * They now annotate with `WebCssStyle`, which restores excess-property checking
16
+ * AND erases completely — unlike a widening helper function, an annotation emits
17
+ * nothing, so the compiled output is the same as the cast's. Two of the eleven
18
+ * needed no widening at all once checked against RN 0.83, which models
19
+ * `boxShadow` and `cursor` on `ViewStyle` directly.
20
+ *
21
+ * This guard stops an inline cast coming back. It pins the FULL remaining set —
22
+ * per file and per count — rather than an allowlist of filenames, so a new cast
23
+ * inside an already-listed file fails too.
24
+ */
25
+
26
+ const SRC = join(__dirname, '..');
27
+
28
+ function sourceFiles(dir: string): string[] {
29
+ return readdirSync(dir).flatMap((entry) => {
30
+ const full = join(dir, entry);
31
+ if (statSync(full).isDirectory()) {
32
+ return entry === '__tests__' ? [] : sourceFiles(full);
33
+ }
34
+ return /\.tsx?$/.test(entry) ? [full] : [];
35
+ });
36
+ }
37
+
38
+ /** Source with comments stripped — they legitimately discuss the retired spelling. */
39
+ const codeOf = (file: string) =>
40
+ readFileSync(file, 'utf8')
41
+ .replace(/\/\*[\s\S]*?\*\//g, '')
42
+ .replace(/^\s*\/\/.*$/gm, '');
43
+
44
+ const files = sourceFiles(SRC);
45
+
46
+ /**
47
+ * The only `as ViewStyle` occurrences left in `src/`, and why each one is NOT a
48
+ * web-CSS cast. Anything else is a regression.
49
+ */
50
+ const ALLOWED: Record<string, { count: number; reason: string }> = {
51
+ 'styles/web-view-style.ts': {
52
+ count: 1,
53
+ reason:
54
+ "`WEB_POSITION_FIXED` — a VALUE-level cast on a key RN does model, just with a narrower union. It is the one documented crossing point and cannot be an annotation, because `position: 'fixed'` has to be writable inside an ordinary style object.",
55
+ },
56
+ 'skeleton/index.tsx': {
57
+ count: 1,
58
+ reason:
59
+ '`(flattened as ViewStyle)?.width` narrows a `StyleSheet.flatten` result, which is legitimately `ViewStyle | TextStyle | ImageStyle`. Not a web-CSS cast.',
60
+ },
61
+ };
62
+
63
+ describe('web-only CSS styles', () => {
64
+ it('finds source files to scan (guards against a broken walk)', () => {
65
+ expect(files.length).toBeGreaterThan(100);
66
+ });
67
+
68
+ it('has no inline `as ViewStyle` outside the two documented exceptions', () => {
69
+ const found: Record<string, number> = {};
70
+ for (const file of files) {
71
+ const hits = codeOf(file).match(/\bas ViewStyle\b/g);
72
+ if (hits) {
73
+ found[file.replace(`${SRC}/`, '')] = hits.length;
74
+ }
75
+ }
76
+ const expected = Object.fromEntries(
77
+ Object.entries(ALLOWED).map(([file, { count }]) => [file, count]),
78
+ );
79
+ expect(found).toEqual(expected);
80
+ });
81
+
82
+ /**
83
+ * The properties `WebCssStyle` exists for. If one of these shows up in source
84
+ * next to a cast again, the test above catches it; this one catches the subtler
85
+ * regression of re-declaring the same widening locally instead of importing it.
86
+ */
87
+ it('declares every web-only property in one place', () => {
88
+ const module = codeOf(join(SRC, 'styles/web-view-style.ts'));
89
+ for (const property of [
90
+ 'animation',
91
+ 'animationDelay',
92
+ 'transitionProperty',
93
+ 'transitionDuration',
94
+ 'transitionTimingFunction',
95
+ 'backdropFilter',
96
+ 'WebkitBackdropFilter',
97
+ 'maskImage',
98
+ 'WebkitMaskImage',
99
+ 'backgroundImage',
100
+ 'scrollbarWidth',
101
+ 'scrollbarColor',
102
+ 'userSelect',
103
+ ]) {
104
+ expect(module).toContain(`${property}?:`);
105
+ }
106
+ });
107
+
108
+ it('is a type-only import everywhere it is used, so nothing is emitted', () => {
109
+ const importers = files.filter(
110
+ (file) =>
111
+ file !== join(SRC, 'styles/web-view-style.ts') &&
112
+ codeOf(file).includes('WebCssStyle'),
113
+ );
114
+ // The seven forks the sweep converted.
115
+ expect(importers.length).toBeGreaterThanOrEqual(7);
116
+ for (const file of importers) {
117
+ const source = codeOf(file);
118
+ // Either `import type { WebCssStyle }` or an inline `type` specifier.
119
+ expect({
120
+ file: file.replace(`${SRC}/`, ''),
121
+ typeOnly:
122
+ /import type \{[^}]*\bWebCssStyle\b/.test(source) ||
123
+ /\btype WebCssStyle\b/.test(source),
124
+ }).toEqual({ file: file.replace(`${SRC}/`, ''), typeOnly: true });
125
+ }
126
+ });
127
+
128
+ /**
129
+ * RN 0.83 models these on `ViewStyle` itself, so widening them would be cargo
130
+ * cult. Pinned because getting it wrong is invisible: the cast compiles either
131
+ * way and only the reader is misled.
132
+ */
133
+ it('does not widen properties React Native already models', () => {
134
+ const module = codeOf(join(SRC, 'styles/web-view-style.ts'));
135
+ for (const property of ['cursor', 'boxShadow', 'filter', 'pointerEvents']) {
136
+ expect(module).not.toContain(`${property}?:`);
137
+ }
138
+ });
139
+ });
@@ -11,6 +11,7 @@ import {
11
11
  type StyleProp,
12
12
  } from 'react-native';
13
13
  import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler';
14
+ import type { WebCssStyle } from '../styles/web-view-style';
14
15
  import { Z_INDEX } from '../styles/z-index';
15
16
  import Animated, {
16
17
  type AnimatedStyle,
@@ -737,19 +738,22 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
737
738
  </GestureDetector>
738
739
  ) : handleNode;
739
740
 
741
+ // `scrollbar-width` / `scrollbar-color` are web-only CSS RN does not model.
742
+ const webScrollbarStyle: WebCssStyle | undefined =
743
+ Platform.OS === 'web'
744
+ ? {
745
+ scrollbarWidth: 'thin',
746
+ scrollbarColor: `${colors.border} transparent`,
747
+ }
748
+ : undefined;
749
+
740
750
  // Inner content: scrollable wraps in Animated.ScrollView, non-scrollable
741
751
  // renders children directly. In legacy mode the scrollview is also
742
752
  // wrapped in the `nativeGesture` detector for scroll/pan coordination.
743
753
  const scrollViewNode = (
744
754
  <Animated.ScrollView
745
755
  ref={scrollViewRef}
746
- style={[
747
- styles.scrollView,
748
- Platform.OS === 'web' && ({
749
- scrollbarWidth: 'thin',
750
- scrollbarColor: `${colors.border} transparent`,
751
- } as ViewStyle),
752
- ]}
756
+ style={[styles.scrollView, webScrollbarStyle]}
753
757
  contentContainerStyle={dynamicStyles.scrollContent}
754
758
  showsVerticalScrollIndicator={false}
755
759
  keyboardShouldPersistTaps="handled"
@@ -1,6 +1,7 @@
1
1
  import React, { memo, useEffect, useState } from 'react';
2
2
  import { View, type ViewStyle } from 'react-native';
3
3
 
4
+ import type { WebCssStyle } from '../styles/web-view-style';
4
5
  import { useTheme } from '../theme/use-theme';
5
6
  import { ConnectionDotsBase } from './ConnectionDotsBase';
6
7
  import type { ConnectionDotsProps } from './types';
@@ -88,14 +89,14 @@ const ConnectionDotsComponent: React.FC<ConnectionDotsProps> = ({
88
89
  borderRadius: dotSize / 2,
89
90
  backgroundColor: color,
90
91
  };
91
- const animated: ViewStyle = reduce
92
+ const animated: WebCssStyle = reduce
92
93
  ? { opacity: 1 }
93
- : ({
94
+ : {
94
95
  // `animation` / `animationDelay` are real CSS props on web;
95
96
  // react-native-web passes them through to the DOM node.
96
97
  animation: `${WAVE_ANIMATION_NAME} ${WAVE_DURATION_MS}ms linear infinite`,
97
98
  animationDelay: `${(i / count) * WAVE_DURATION_MS}ms`,
98
- } as ViewStyle);
99
+ };
99
100
  return (
100
101
  <View
101
102
  key={i}
@@ -36,7 +36,11 @@ export const SHADOW_BOX: Record<ShadowRole, string> = {
36
36
  * On WEB this returns a `{ boxShadow }` style object (so it can also be applied
37
37
  * inline without Tailwind). The `.native.ts` fork returns RN
38
38
  * `shadowColor/shadowOffset/shadowOpacity/shadowRadius/elevation`.
39
+ *
40
+ * No widening needed: `boxShadow` is a first-class `ViewStyle` key on RN >= 0.76
41
+ * and takes a CSS string, so this is not one of the web-only properties
42
+ * `styles/web-view-style.ts` exists for.
39
43
  */
40
44
  export function bloomShadowStyle(role: ShadowRole): ViewStyle {
41
- return { boxShadow: SHADOW_BOX[role] } as ViewStyle;
45
+ return { boxShadow: SHADOW_BOX[role] };
42
46
  }
@@ -515,7 +515,7 @@ function SideSheet({
515
515
  [side]: anchorInset,
516
516
  width: sideWidth,
517
517
  borderRadius: PANEL_RADIUS,
518
- }) as ViewStyle,
518
+ }),
519
519
  [insetTop, insetBottom, side, anchorInset, sideWidth],
520
520
  );
521
521