@oxyhq/bloom 0.52.0 → 0.53.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 (205) 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/index.js +66 -66
  10. package/lib/commonjs/index.js.map +1 -1
  11. package/lib/commonjs/index.web.js +90 -90
  12. package/lib/commonjs/index.web.js.map +1 -1
  13. package/lib/commonjs/loading/Loading.web.js.map +1 -1
  14. package/lib/commonjs/loading/SpinnerIcon.web.js +10 -6
  15. package/lib/commonjs/loading/SpinnerIcon.web.js.map +1 -1
  16. package/lib/commonjs/motion/index.js +1 -1
  17. package/lib/commonjs/motion/index.web.js +32 -0
  18. package/lib/commonjs/motion/index.web.js.map +1 -0
  19. package/lib/commonjs/motion/motion.js +10 -2
  20. package/lib/commonjs/motion/motion.js.map +1 -1
  21. package/lib/commonjs/motion/motion.web.js +137 -0
  22. package/lib/commonjs/motion/motion.web.js.map +1 -0
  23. package/lib/commonjs/progressive-blur/index.web.js.map +1 -1
  24. package/lib/commonjs/styles/web-view-style.js +33 -0
  25. package/lib/commonjs/styles/web-view-style.js.map +1 -1
  26. package/lib/commonjs/tab-bar/TabBarBase.js +18 -4
  27. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  28. package/lib/commonjs/tab-bar/index.js +5 -5
  29. package/lib/commonjs/tab-bar/index.js.map +1 -1
  30. package/lib/commonjs/tab-bar/index.web.js +5 -5
  31. package/lib/commonjs/tab-bar/index.web.js.map +1 -1
  32. package/lib/commonjs/tab-bar/shared.js +3 -3
  33. package/lib/commonjs/tab-bar/surface.web.js.map +1 -1
  34. package/lib/commonjs/tab-bar/use-footprint.js +56 -0
  35. package/lib/commonjs/tab-bar/use-footprint.js.map +1 -0
  36. package/lib/commonjs/toast/ToastRow.js +7 -3
  37. package/lib/commonjs/toast/ToastRow.js.map +1 -1
  38. package/lib/commonjs/toast/ToastSwipeHandler.js +29 -13
  39. package/lib/commonjs/toast/ToastSwipeHandler.js.map +1 -1
  40. package/lib/commonjs/toast/constants.js +29 -1
  41. package/lib/commonjs/toast/constants.js.map +1 -1
  42. package/lib/commonjs/toast/position-utils.js +9 -4
  43. package/lib/commonjs/toast/position-utils.js.map +1 -1
  44. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +12 -9
  45. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  46. package/lib/module/bottom-sheet/BottomSheetBase.js +7 -4
  47. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  48. package/lib/module/connection-dots/ConnectionDots.web.js.map +1 -1
  49. package/lib/module/design-tokens/shadows.js +4 -0
  50. package/lib/module/design-tokens/shadows.js.map +1 -1
  51. package/lib/module/dialog/Dialog.js.map +1 -1
  52. package/lib/module/dialog/Dialog.web.js +26 -10
  53. package/lib/module/dialog/Dialog.web.js.map +1 -1
  54. package/lib/module/index.js +1 -1
  55. package/lib/module/index.js.map +1 -1
  56. package/lib/module/index.web.js +1 -1
  57. package/lib/module/index.web.js.map +1 -1
  58. package/lib/module/loading/Loading.web.js.map +1 -1
  59. package/lib/module/loading/SpinnerIcon.web.js +10 -6
  60. package/lib/module/loading/SpinnerIcon.web.js.map +1 -1
  61. package/lib/module/motion/index.js +1 -1
  62. package/lib/module/motion/index.js.map +1 -1
  63. package/lib/module/motion/index.web.js +10 -0
  64. package/lib/module/motion/index.web.js.map +1 -0
  65. package/lib/module/motion/motion.js +10 -2
  66. package/lib/module/motion/motion.js.map +1 -1
  67. package/lib/module/motion/motion.web.js +133 -0
  68. package/lib/module/motion/motion.web.js.map +1 -0
  69. package/lib/module/progressive-blur/index.web.js.map +1 -1
  70. package/lib/module/styles/web-view-style.js +33 -0
  71. package/lib/module/styles/web-view-style.js.map +1 -1
  72. package/lib/module/tab-bar/TabBarBase.js +18 -4
  73. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  74. package/lib/module/tab-bar/index.js +1 -1
  75. package/lib/module/tab-bar/index.js.map +1 -1
  76. package/lib/module/tab-bar/index.web.js +1 -1
  77. package/lib/module/tab-bar/index.web.js.map +1 -1
  78. package/lib/module/tab-bar/shared.js +3 -3
  79. package/lib/module/tab-bar/surface.web.js.map +1 -1
  80. package/lib/module/tab-bar/use-footprint.js +52 -0
  81. package/lib/module/tab-bar/use-footprint.js.map +1 -0
  82. package/lib/module/toast/ToastRow.js +8 -4
  83. package/lib/module/toast/ToastRow.js.map +1 -1
  84. package/lib/module/toast/ToastSwipeHandler.js +29 -13
  85. package/lib/module/toast/ToastSwipeHandler.js.map +1 -1
  86. package/lib/module/toast/constants.js +28 -1
  87. package/lib/module/toast/constants.js.map +1 -1
  88. package/lib/module/toast/position-utils.js +9 -4
  89. package/lib/module/toast/position-utils.js.map +1 -1
  90. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +12 -9
  91. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  92. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/connection-dots/ConnectionDots.web.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/design-tokens/shadows.d.ts +4 -0
  95. package/lib/typescript/commonjs/design-tokens/shadows.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/index.web.d.ts +2 -2
  98. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/loading/Loading.web.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/loading/SpinnerIcon.web.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/motion/index.web.d.ts +9 -0
  102. package/lib/typescript/commonjs/motion/index.web.d.ts.map +1 -0
  103. package/lib/typescript/commonjs/motion/motion.d.ts +14 -6
  104. package/lib/typescript/commonjs/motion/motion.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/motion/motion.web.d.ts +79 -0
  106. package/lib/typescript/commonjs/motion/motion.web.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/progressive-blur/index.web.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/styles/web-view-style.d.ts +49 -0
  109. package/lib/typescript/commonjs/styles/web-view-style.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/tab-bar/index.d.ts +1 -1
  112. package/lib/typescript/commonjs/tab-bar/index.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/tab-bar/index.web.d.ts +1 -1
  114. package/lib/typescript/commonjs/tab-bar/index.web.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/tab-bar/shared.d.ts +3 -3
  116. package/lib/typescript/commonjs/tab-bar/surface.web.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/tab-bar/use-footprint.d.ts +31 -0
  118. package/lib/typescript/commonjs/tab-bar/use-footprint.d.ts.map +1 -0
  119. package/lib/typescript/commonjs/toast/ToastRow.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts +12 -6
  121. package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/toast/constants.d.ts +26 -8
  123. package/lib/typescript/commonjs/toast/constants.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/toast/position-utils.d.ts +8 -3
  125. package/lib/typescript/commonjs/toast/position-utils.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  127. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  128. package/lib/typescript/module/connection-dots/ConnectionDots.web.d.ts.map +1 -1
  129. package/lib/typescript/module/design-tokens/shadows.d.ts +4 -0
  130. package/lib/typescript/module/design-tokens/shadows.d.ts.map +1 -1
  131. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  132. package/lib/typescript/module/index.web.d.ts +2 -2
  133. package/lib/typescript/module/index.web.d.ts.map +1 -1
  134. package/lib/typescript/module/loading/Loading.web.d.ts.map +1 -1
  135. package/lib/typescript/module/loading/SpinnerIcon.web.d.ts.map +1 -1
  136. package/lib/typescript/module/motion/index.web.d.ts +9 -0
  137. package/lib/typescript/module/motion/index.web.d.ts.map +1 -0
  138. package/lib/typescript/module/motion/motion.d.ts +14 -6
  139. package/lib/typescript/module/motion/motion.d.ts.map +1 -1
  140. package/lib/typescript/module/motion/motion.web.d.ts +79 -0
  141. package/lib/typescript/module/motion/motion.web.d.ts.map +1 -0
  142. package/lib/typescript/module/progressive-blur/index.web.d.ts.map +1 -1
  143. package/lib/typescript/module/styles/web-view-style.d.ts +49 -0
  144. package/lib/typescript/module/styles/web-view-style.d.ts.map +1 -1
  145. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  146. package/lib/typescript/module/tab-bar/index.d.ts +1 -1
  147. package/lib/typescript/module/tab-bar/index.d.ts.map +1 -1
  148. package/lib/typescript/module/tab-bar/index.web.d.ts +1 -1
  149. package/lib/typescript/module/tab-bar/index.web.d.ts.map +1 -1
  150. package/lib/typescript/module/tab-bar/shared.d.ts +3 -3
  151. package/lib/typescript/module/tab-bar/surface.web.d.ts.map +1 -1
  152. package/lib/typescript/module/tab-bar/use-footprint.d.ts +31 -0
  153. package/lib/typescript/module/tab-bar/use-footprint.d.ts.map +1 -0
  154. package/lib/typescript/module/toast/ToastRow.d.ts.map +1 -1
  155. package/lib/typescript/module/toast/ToastSwipeHandler.d.ts +12 -6
  156. package/lib/typescript/module/toast/ToastSwipeHandler.d.ts.map +1 -1
  157. package/lib/typescript/module/toast/constants.d.ts +26 -8
  158. package/lib/typescript/module/toast/constants.d.ts.map +1 -1
  159. package/lib/typescript/module/toast/position-utils.d.ts +8 -3
  160. package/lib/typescript/module/toast/position-utils.d.ts.map +1 -1
  161. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  162. package/package.json +6 -1
  163. package/src/__tests__/Motion.test.tsx +31 -3
  164. package/src/__tests__/MotionWebFork.test.ts +140 -0
  165. package/src/__tests__/TabBarAnimatedShorthand.test.ts +135 -0
  166. package/src/__tests__/TabBarFootprint.test.tsx +172 -0
  167. package/src/__tests__/TabBarWebFork.test.ts +1 -1
  168. package/src/__tests__/ToastHostWebFork.test.ts +6 -2
  169. package/src/__tests__/Toaster.test.tsx +62 -1
  170. package/src/__tests__/toast-position-utils.test.ts +23 -7
  171. package/src/__tests__/web-css-style.test.ts +139 -0
  172. package/src/bottom-sheet/BottomSheetBase.tsx +11 -7
  173. package/src/connection-dots/ConnectionDots.web.tsx +4 -3
  174. package/src/design-tokens/shadows.ts +5 -1
  175. package/src/dialog/Dialog.tsx +1 -1
  176. package/src/dialog/Dialog.web.tsx +41 -26
  177. package/src/index.web.ts +2 -2
  178. package/src/loading/Loading.web.tsx +7 -5
  179. package/src/loading/SpinnerIcon.web.tsx +12 -9
  180. package/src/motion/index.web.ts +8 -0
  181. package/src/motion/motion.ts +14 -6
  182. package/src/motion/motion.web.ts +99 -0
  183. package/src/progressive-blur/index.web.tsx +4 -3
  184. package/src/styles/web-view-style.ts +50 -0
  185. package/src/tab-bar/TabBarBase.tsx +17 -12
  186. package/src/tab-bar/index.ts +1 -1
  187. package/src/tab-bar/index.web.tsx +1 -1
  188. package/src/tab-bar/shared.ts +3 -3
  189. package/src/tab-bar/surface.web.tsx +4 -3
  190. package/src/tab-bar/use-footprint.ts +50 -0
  191. package/src/toast/ToastRow.tsx +8 -3
  192. package/src/toast/ToastSwipeHandler.tsx +30 -12
  193. package/src/toast/constants.ts +28 -0
  194. package/src/toast/position-utils.ts +10 -5
  195. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +9 -8
  196. package/lib/commonjs/tab-bar/use-reserved-space.js +0 -50
  197. package/lib/commonjs/tab-bar/use-reserved-space.js.map +0 -1
  198. package/lib/module/tab-bar/use-reserved-space.js +0 -46
  199. package/lib/module/tab-bar/use-reserved-space.js.map +0 -1
  200. package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts +0 -25
  201. package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts.map +0 -1
  202. package/lib/typescript/module/tab-bar/use-reserved-space.d.ts +0 -25
  203. package/lib/typescript/module/tab-bar/use-reserved-space.d.ts.map +0 -1
  204. package/src/__tests__/TabBarReservedSpace.test.tsx +0 -150
  205. package/src/tab-bar/use-reserved-space.ts +0 -44
@@ -0,0 +1,135 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+
4
+ /**
5
+ * No reanimated mapper may return a React Native style SHORTHAND that
6
+ * react-native-web does not expand.
7
+ *
8
+ * Reanimated's web path hands a mapper's result to RNW's `createReactDOMStyle`
9
+ * and then assigns each surviving key straight onto the DOM node's `style`. That
10
+ * function expands a fixed set of shorthands (`marginInline`, `paddingInline`,
11
+ * `borderWidth`, `borderRadius`, `insetInline`, …) but NOT React Native's own
12
+ * `marginHorizontal` / `marginVertical` / `paddingHorizontal` / `paddingVertical`
13
+ * — verified absent from the entire file in RNW 0.21.2. An unexpanded shorthand
14
+ * becomes `element.style.marginHorizontal = '34px'`, which is not a CSS property,
15
+ * so the browser drops it WITHOUT any error.
16
+ *
17
+ * This shipped once: `barStyle` animated `marginHorizontal` (copied verbatim from
18
+ * upstream expo-glass-tabs, which only ever ran on native). On web the bar kept
19
+ * its full width while `highlightStyle` still subtracted `MINIMIZED_INSET` from
20
+ * `barWidth`, so the sliding highlight sat ~13.6px narrower than the tab beneath
21
+ * it at five tabs — visible only once the bar minimized.
22
+ *
23
+ * STATIC styles are fine and are deliberately not scanned: `StyleSheet.create`
24
+ * takes RNW's own StyleSheet path, which does handle the shorthand. The trap is
25
+ * specific to values produced inside a mapper.
26
+ *
27
+ * Neither tsc nor jest can see this: the shorthand is a valid RN style key, so it
28
+ * type-checks, and the reanimated mock never touches the DOM. Source analysis is
29
+ * the only automatable guard.
30
+ */
31
+
32
+ const SRC = join(__dirname, '..');
33
+ const ROOTS = ['tab-bar', 'progressive-blur'] as const;
34
+ const MAPPERS = ['useAnimatedStyle', 'useDerivedValue', 'useAnimatedProps'] as const;
35
+
36
+ /** RN shorthands with no entry in RNW's `STYLE_SHORT_FORM_EXPANSIONS`. */
37
+ const UNEXPANDED_SHORTHANDS = [
38
+ 'marginHorizontal',
39
+ 'marginVertical',
40
+ 'paddingHorizontal',
41
+ 'paddingVertical',
42
+ ] as const;
43
+
44
+ function sourceFiles(root: string): string[] {
45
+ const out: string[] = [];
46
+ const walk = (dir: string) => {
47
+ for (const entry of readdirSync(dir)) {
48
+ const full = join(dir, entry);
49
+ if (statSync(full).isDirectory()) {
50
+ walk(full);
51
+ continue;
52
+ }
53
+ if (!/\.tsx?$/.test(entry)) continue;
54
+ if (/\.(test|spec|stories)\.tsx?$/.test(entry)) continue;
55
+ out.push(full);
56
+ }
57
+ };
58
+ walk(join(SRC, root));
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * The body of every mapper call in `source`, matched by brace depth from the
64
+ * mapper's opening paren so nested objects and callbacks are included whole.
65
+ */
66
+ function mapperBodies(source: string): string[] {
67
+ const bodies: string[] = [];
68
+ for (const mapper of MAPPERS) {
69
+ let from = 0;
70
+ for (;;) {
71
+ const start = source.indexOf(`${mapper}(`, from);
72
+ if (start === -1) break;
73
+ let depth = 0;
74
+ let index = source.indexOf('(', start);
75
+ const open = index;
76
+ for (; index < source.length; index += 1) {
77
+ const char = source[index];
78
+ if (char === '(') depth += 1;
79
+ else if (char === ')') {
80
+ depth -= 1;
81
+ if (depth === 0) break;
82
+ }
83
+ }
84
+ bodies.push(source.slice(open, index));
85
+ from = index === -1 ? start + mapper.length : index;
86
+ }
87
+ }
88
+ return bodies;
89
+ }
90
+
91
+ describe('tab-bar animated styles', () => {
92
+ const files = ROOTS.flatMap(sourceFiles);
93
+
94
+ // Self-check: a broken walk must not pass vacuously.
95
+ it('walks the real sources', () => {
96
+ expect(files.length).toBeGreaterThanOrEqual(10);
97
+ const withMappers = files.filter((file) => mapperBodies(readFileSync(file, 'utf8')).length > 0);
98
+ expect(withMappers.length).toBeGreaterThanOrEqual(2);
99
+ });
100
+
101
+ it('extracts a mapper body that really contains its returned keys', () => {
102
+ const base = files.find((file) => file.endsWith('TabBarBase.tsx'));
103
+ expect(base).toBeDefined();
104
+ const bodies = mapperBodies(readFileSync(base as string, 'utf8'));
105
+ expect(bodies.length).toBeGreaterThanOrEqual(6);
106
+ // `highlightStyle` is the mapper that computes the capsule geometry.
107
+ expect(bodies.some((body) => body.includes('borderRadius'))).toBe(true);
108
+ });
109
+
110
+ it.each(UNEXPANDED_SHORTHANDS)('never returns %s from a mapper', (shorthand) => {
111
+ const offenders: string[] = [];
112
+ for (const file of files) {
113
+ for (const body of mapperBodies(readFileSync(file, 'utf8'))) {
114
+ // Strip comments so the explanatory notes naming the shorthand do not
115
+ // trip the guard that exists because of them.
116
+ const code = body.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
117
+ if (new RegExp(`\\b${shorthand}\\s*:`).test(code)) {
118
+ offenders.push(relative(SRC, file));
119
+ }
120
+ }
121
+ }
122
+ expect(offenders).toEqual([]);
123
+ });
124
+
125
+ it('animates the minimized inset as both long-form margins', () => {
126
+ const base = readFileSync(
127
+ files.find((file) => file.endsWith('TabBarBase.tsx')) as string,
128
+ 'utf8',
129
+ );
130
+ const barStyle = mapperBodies(base).find((body) => body.includes('MINIMIZED_INSET'));
131
+ expect(barStyle).toBeDefined();
132
+ expect(barStyle).toMatch(/marginLeft:/);
133
+ expect(barStyle).toMatch(/marginRight:/);
134
+ });
135
+ });
@@ -0,0 +1,172 @@
1
+ import React from 'react';
2
+ import type { ReactTestInstance } from 'react-test-renderer';
3
+ import { render } from '@testing-library/react-native';
4
+
5
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
6
+ import { TabBar, TabBarButton, useTabBarFootprint } from '../tab-bar';
7
+ import type { TabBarItem } from '../tab-bar/types';
8
+
9
+ /**
10
+ * `useTabBarFootprint()` — the space the floating bar physically occupies above
11
+ * the bottom of the window, and nothing more.
12
+ *
13
+ * The bar is absolutely positioned, so nothing accounts for it automatically,
14
+ * and the geometry it is built from (expanded height, bottom gap) is private.
15
+ * Without the hook a consumer hardcodes both — which drifts silently the first
16
+ * time the bar changes by a pixel — and, worse, tends to add `insets.bottom` on
17
+ * top, because that is what laying anything out against the bottom edge
18
+ * normally requires. The bar already folds the inset into its own gap, so that
19
+ * double-counts the home indicator.
20
+ *
21
+ * It is the RAW footprint: no clearance is folded in, because clearance differs
22
+ * per call site (a list wants a gap above the pill, a FAB anchored by `bottom`
23
+ * supplies its own) and only the consumer knows which. That is asserted here
24
+ * rather than merely documented — a margin quietly added to the hook would move
25
+ * every consumer's FAB.
26
+ *
27
+ * The suite asserts four separate things: the absolute pixel values (so a change
28
+ * to the constants must be deliberate), that the number is the bar and only the
29
+ * bar, the inset relationship (so the double-count can never creep back in), and
30
+ * equality with the bar's own rendered geometry (so it cannot drift from where
31
+ * the bar actually sits).
32
+ */
33
+
34
+ // Mutated per test; must be `mock`-prefixed for the hoisted factory.
35
+ const mockInsets = { top: 0, right: 0, bottom: 0, left: 0 };
36
+
37
+ jest.mock('react-native-safe-area-context', () => ({
38
+ ...jest.requireActual('react-native-safe-area-context'),
39
+ useSafeAreaInsets: () => mockInsets,
40
+ }));
41
+
42
+ const ITEMS: TabBarItem[] = [
43
+ { name: 'home', label: 'Home', icon: null },
44
+ { name: 'search', label: 'Search', icon: null },
45
+ ];
46
+
47
+ /** A device with a home indicator (iPhone-class bottom inset). */
48
+ const HOME_INDICATOR_INSET = 34;
49
+
50
+ function FootprintProbe({ onValue }: { onValue: (space: number) => void }) {
51
+ onValue(useTabBarFootprint());
52
+ return null;
53
+ }
54
+
55
+ function withTheme(ui: React.ReactElement) {
56
+ return (
57
+ <BloomThemeProvider mode="light" colorPreset="teal">
58
+ {ui}
59
+ </BloomThemeProvider>
60
+ );
61
+ }
62
+
63
+ /** The footprint the hook reports on a device whose bottom inset is `inset`. */
64
+ function footprintFor(inset: number): number {
65
+ mockInsets.bottom = inset;
66
+ let space: number | undefined;
67
+ render(withTheme(<FootprintProbe onValue={(value) => (space = value)} />));
68
+ if (space === undefined) throw new Error('FootprintProbe never ran');
69
+ return space;
70
+ }
71
+
72
+ function flattenStyle(style: unknown): Record<string, unknown> {
73
+ const out: Record<string, unknown> = {};
74
+ const visit = (value: unknown): void => {
75
+ if (Array.isArray(value)) value.forEach(visit);
76
+ else if (value && typeof value === 'object') Object.assign(out, value);
77
+ };
78
+ visit(style);
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * The flattened style of every styled node in the tree.
84
+ *
85
+ * Restricted to HOST nodes: the RN mock renders each component as a composite
86
+ * wrapping a host element of the same name, and both carry the same `style`, so
87
+ * counting all of them would double every match.
88
+ */
89
+ function styledNodes(root: ReactTestInstance): Record<string, unknown>[] {
90
+ return root
91
+ .findAll((node) => typeof node.type === 'string' && node.props?.style !== undefined)
92
+ .map((node) => flattenStyle(node.props.style));
93
+ }
94
+
95
+ describe('useTabBarFootprint', () => {
96
+ beforeEach(() => {
97
+ mockInsets.bottom = 0;
98
+ });
99
+
100
+ it('measures the bar plus its gap where there is no bottom inset', () => {
101
+ // 58pt bar + the 12pt floor the gap never goes below.
102
+ expect(footprintFor(0)).toBe(70);
103
+ });
104
+
105
+ it('measures more on a device with a home indicator', () => {
106
+ expect(footprintFor(HOME_INDICATOR_INSET)).toBe(76);
107
+ });
108
+
109
+ it('adds no clearance of its own — the number is the bar and only the bar', () => {
110
+ // Clearance belongs to the call site: a list wants a gap above the pill, a
111
+ // FAB anchored by `bottom` supplies its own. A margin folded in here would
112
+ // lift every consumer's FAB off its intended position, and would make
113
+ // "footprint" mean two different things in one file.
114
+ expect(footprintFor(0)).toBe(58 + 12);
115
+ expect(footprintFor(HOME_INDICATOR_INSET)).toBe(58 + (HOME_INDICATOR_INSET - 16));
116
+ });
117
+
118
+ it('already includes the inset — adding insets.bottom would double-count it', () => {
119
+ // The bar absorbs 16pt of the inset into its own gap, so the footprint grows
120
+ // by the REMAINDER, never by the whole inset. A consumer writing
121
+ // `footprint + insets.bottom` would account 110pt for a 76pt bar.
122
+ const grown = footprintFor(HOME_INDICATOR_INSET) - footprintFor(0);
123
+ expect(grown).toBe(6);
124
+ expect(grown).toBeLessThan(HOME_INDICATOR_INSET);
125
+ });
126
+
127
+ it('does not shrink below the floor for a small inset', () => {
128
+ // An inset under what the gap absorbs must not pull the bar down toward the
129
+ // window edge — the gap is clamped, and the footprint with it.
130
+ expect(footprintFor(8)).toBe(footprintFor(0));
131
+ });
132
+
133
+ it('equals the space the rendered bar actually occupies', () => {
134
+ // The invariant that makes the hook worth exporting: its number is the bar's
135
+ // own geometry (the gap it holds off the window edge plus its expanded
136
+ // height), not a second copy that can drift.
137
+ mockInsets.bottom = HOME_INDICATOR_INSET;
138
+ let footprint: number | undefined;
139
+ const { UNSAFE_root } = render(
140
+ withTheme(
141
+ <>
142
+ <FootprintProbe onValue={(value) => (footprint = value)} />
143
+ <TabBar activeIndex={0}>
144
+ {ITEMS.map((item, index) => (
145
+ <TabBarButton key={item.name} item={item} index={index} />
146
+ ))}
147
+ </TabBar>
148
+ </>,
149
+ ),
150
+ );
151
+
152
+ const styles = styledNodes(UNSAFE_root);
153
+ // The wrapper holding the pill off the bottom edge — the only node with a
154
+ // bottom margin.
155
+ const gaps = styles.filter((style) => typeof style.marginBottom === 'number');
156
+ expect(gaps).toHaveLength(1);
157
+ // The pill itself: the animated capsule sizes BOTH its height and its
158
+ // horizontal inset, which nothing else in the bar does. The inset is written
159
+ // as the two long-form margins — `marginHorizontal` would be dropped
160
+ // silently on web, since RNW's `createReactDOMStyle` does not expand it (see
161
+ // `TabBarAnimatedShorthand.test.ts`).
162
+ const pills = styles.filter(
163
+ (style) =>
164
+ typeof style.height === 'number' &&
165
+ typeof style.marginLeft === 'number' &&
166
+ typeof style.marginRight === 'number',
167
+ );
168
+ expect(pills).toHaveLength(1);
169
+
170
+ expect(footprint).toBe(Number(gaps[0]?.marginBottom) + Number(pills[0]?.height));
171
+ });
172
+ });
@@ -49,7 +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-reserved-space.ts',
52
+ 'tab-bar/use-footprint.ts',
53
53
  'tab-bar/surface.tsx',
54
54
  'tab-bar/surface-solid.tsx',
55
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"