@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
@@ -2,8 +2,8 @@
2
2
  * Ported from expo-glass-tabs v0.1.1 — src/glass-tab-bar.tsx
3
3
  * (MIT © 2026 David Mokos). See the top-level NOTICE.
4
4
  *
5
- * NEUTRAL default variant of a tab glyph: renders the item's own `icon` node,
6
- * tinted for this crossfade layer.
5
+ * NEUTRAL default variant of a tab glyph: renders the item's own `icon` node
6
+ * or its `activeIcon` on the active layer — tinted for this crossfade layer.
7
7
  *
8
8
  * `item.icon` is the primary icon API for the whole family — a Bloom icon
9
9
  * element, exactly like `TabsTrigger` and `FrostedIconButton` take. `sfSymbol`
@@ -17,13 +17,17 @@ import { View } from 'react-native';
17
17
  import { applyIconColor } from '../frosted-icon-button/shared';
18
18
  import type { TabBarGlyphProps } from './shared';
19
19
 
20
- export function TabBarGlyph({ item, tint, size }: TabBarGlyphProps) {
20
+ export function TabBarGlyph({ item, tint, size, active }: TabBarGlyphProps) {
21
21
  // The tint is injected as the icon's `fill` FALLBACK — an icon that sets its
22
22
  // own `fill` (a brand mark, say) keeps it, so the crossfade layers simply
23
23
  // render it twice at full opacity and it never flickers.
24
+ //
25
+ // `activeIcon` is what makes the crossfade able to swap NODES: an icon set
26
+ // whose selected state is a different SHAPE (outline → filled) cannot be
27
+ // expressed by tinting one node two ways. Absent, the layer renders `icon`.
24
28
  return (
25
29
  <View style={{ height: size, alignItems: 'center', justifyContent: 'center' }}>
26
- {applyIconColor(item.icon, tint)}
30
+ {applyIconColor(active ? (item.activeIcon ?? item.icon) : item.icon, tint)}
27
31
  </View>
28
32
  );
29
33
  }
@@ -29,4 +29,5 @@ export {
29
29
  useTabBarMinimized,
30
30
  } from './minimize-context';
31
31
  export type { MinimizeState } from './minimize-context';
32
+ export { useTabBarFootprint } from './use-footprint';
32
33
  export type { TabBarButtonProps, TabBarItem, TabBarProps, TabBarTheme } from './types';
@@ -31,4 +31,5 @@ export {
31
31
  useTabBarMinimized,
32
32
  } from './minimize-context';
33
33
  export type { MinimizeState } from './minimize-context';
34
+ export { useTabBarFootprint } from './use-footprint';
34
35
  export type { TabBarButtonProps, TabBarItem, TabBarProps, TabBarTheme } from './types';
@@ -51,6 +51,27 @@ export const BLUR_BLEED = 44;
51
51
  /** Label type size. Small by design — the icon carries the meaning. */
52
52
  export const LABEL_FONT_SIZE = 9.5;
53
53
 
54
+ /**
55
+ * How much of the bottom safe-area inset the bar's own gap absorbs. The pill
56
+ * already floats clear of the content, so sitting the FULL inset below it
57
+ * strands it in the middle of the screen on a home-indicator device.
58
+ */
59
+ const SAFE_AREA_ABSORBED = 16;
60
+ /** Gap floor, for a device (or a browser) reporting no bottom inset at all. */
61
+ const MIN_BOTTOM_GAP = 12;
62
+
63
+ /**
64
+ * Vertical gap between the bottom of the pill and the bottom of the window.
65
+ *
66
+ * The safe-area inset is folded in HERE, which is why anything accounting for
67
+ * the bar in its own layout must go through `useTabBarFootprint` (which derives
68
+ * its number from this same function) rather than adding `insets.bottom` to a
69
+ * hardcoded height — that double-counts the inset.
70
+ */
71
+ export function tabBarBottomGap(bottomInset: number): number {
72
+ return Math.max(bottomInset - SAFE_AREA_ABSORBED, MIN_BOTTOM_GAP);
73
+ }
74
+
54
75
  /**
55
76
  * Slide spring: interruptible by design — rapid tab-hopping retargets with
56
77
  * preserved velocity. Slight under-damping gives the pill a tiny settle, safe
@@ -58,6 +79,15 @@ export const LABEL_FONT_SIZE = 9.5;
58
79
  */
59
80
  export const SLIDE_SPRING = { duration: 420, dampingRatio: 0.82 };
60
81
 
82
+ /**
83
+ * How long a press must be held before `onIndexLongPress` fires.
84
+ *
85
+ * Comfortably above the tap gesture's own 400ms ceiling: the two can never both
86
+ * be candidates at the same instant, so arming the long press cannot cost the
87
+ * bar a tap. Matches the platform long-press feel (iOS ~0.5s).
88
+ */
89
+ export const LONG_PRESS_MIN_DURATION = 500;
90
+
61
91
  // ---------------------------------------------------------------------------
62
92
  // Theme
63
93
  // ---------------------------------------------------------------------------
@@ -156,4 +186,12 @@ export interface TabBarGlyphProps {
156
186
  tint: string;
157
187
  /** Rendered icon size in px ({@link ICON_SIZE}). */
158
188
  size: number;
189
+ /**
190
+ * Which crossfade layer this is: `false` for the inactive one underneath,
191
+ * `true` for the one fading in on top. The active layer renders
192
+ * `item.activeIcon` when the item has one, so a set that expresses selection
193
+ * by shape (outline → filled) crossfades between two different nodes rather
194
+ * than between two tints of the same node.
195
+ */
196
+ active: boolean;
159
197
  }
@@ -19,9 +19,10 @@
19
19
  * Browsers without `backdrop-filter` simply skip it and are left with the
20
20
  * translucent tint — a graceful degradation that needs no `@supports` rule.
21
21
  */
22
- import { StyleSheet, type ViewStyle } from 'react-native';
22
+ import { StyleSheet } from 'react-native';
23
23
  import Animated from 'react-native-reanimated';
24
24
 
25
+ import type { WebCssStyle } from '../styles/web-view-style';
25
26
  import type { TabBarSurfaceProps } from './shared';
26
27
 
27
28
  /**
@@ -35,10 +36,10 @@ const GLASS_FILTER = `blur(${GLASS_BLUR_PX}px) saturate(180%)`;
35
36
 
36
37
  // Web-only CSS, unknown to React Native's `ViewStyle`. Both spellings are set:
37
38
  // Safari still needs the prefixed property for `backdrop-filter`.
38
- const glassFilterStyle = {
39
+ const glassFilterStyle: WebCssStyle = {
39
40
  backdropFilter: GLASS_FILTER,
40
41
  WebkitBackdropFilter: GLASS_FILTER,
41
- } as ViewStyle;
42
+ };
42
43
 
43
44
  export function TabBarSurface({ theme, style }: TabBarSurfaceProps) {
44
45
  return (
@@ -26,9 +26,31 @@ export type TabBarItem = {
26
26
  * type. This is the primary icon API — same convention as `Tabs`/`TabsTrigger`
27
27
  * and `FrostedIconButton`. The bar renders it TWICE (an inactive layer with the
28
28
  * active layer crossfading on top), injecting the layer's tint as the icon's
29
- * `fill` unless the element already sets one.
29
+ * `fill` unless the element already sets one. See {@link TabBarItem.activeIcon}
30
+ * for a set whose selected state is a different shape rather than a tint.
30
31
  */
31
32
  icon: ReactNode;
33
+ /**
34
+ * Optional second node rendered by the ACTIVE crossfade layer instead of
35
+ * `icon`; without it that layer renders `icon` again, which is the original
36
+ * behaviour.
37
+ *
38
+ * For icon sets that express selection by SHAPE rather than by tint — an
39
+ * outline glyph that becomes a filled one, with a different path — a tint
40
+ * crossfade cannot say what the set says, because both layers would draw the
41
+ * same shape in two colors. Supplying the filled variant here makes the
42
+ * existing crossfade swap NODES, so the outline dissolves into the fill.
43
+ *
44
+ * It is also the escape hatch for an icon that ignores the injected tint: the
45
+ * bar tints a glyph by cloning it with a `fill` prop (see `applyIconColor`),
46
+ * so an icon set that paints from a `color` prop instead never lights up, with
47
+ * no error. Pre-color both nodes yourself and the crossfade still works.
48
+ *
49
+ * On iOS an item carrying `sfSymbol` renders the symbol on both layers and
50
+ * ignores this — a symbol is tinted natively, so the tint crossfade is the
51
+ * right expression there.
52
+ */
53
+ activeIcon?: ReactNode;
32
54
  /**
33
55
  * Optional SF Symbol name, used INSTEAD of `icon` on iOS only. A pure
34
56
  * enhancement handled solely in `glyph.native.tsx`; the neutral and web
@@ -63,6 +85,20 @@ export type TabBarProps = ViewProps & {
63
85
  * or by keyboard/assistive-technology activation of a `TabBarButton`.
64
86
  */
65
87
  onIndexChange?: (index: number) => void;
88
+ /**
89
+ * Called with the index under the finger when a tab is pressed and HELD.
90
+ *
91
+ * This is the only way to reach a long press on the bar: its gesture detector
92
+ * consumes the touches (that is what makes scrubbing possible), so a button's
93
+ * own `onLongPress` never fires for a real finger. The gesture is added to the
94
+ * bar's gesture race only when this prop is supplied — holding still for half
95
+ * a second on a bar without it keeps behaving exactly as before, and can still
96
+ * start a scrub.
97
+ *
98
+ * Long-pressing does NOT move the highlight or select the tab; it is a
99
+ * secondary action (an account switcher on the profile tab, say).
100
+ */
101
+ onIndexLongPress?: (index: number) => void;
66
102
  /** Partial override of the theme resolved from Bloom's color tokens. */
67
103
  theme?: Partial<TabBarTheme>;
68
104
  /**
@@ -0,0 +1,50 @@
1
+ /**
2
+ * How much room the floating bar physically takes at the bottom of the window.
3
+ *
4
+ * The bar is `position: absolute` over the content, so nothing accounts for it
5
+ * automatically — every screen under a tab layout has to keep its own content
6
+ * out from under the pill. That number is built from the bar's private geometry
7
+ * (its expanded height and the gap it holds off the window's bottom edge),
8
+ * which is exactly why it is exported as a hook: hardcoding it in an app
9
+ * silently drifts the moment the bar changes by a pixel.
10
+ *
11
+ * Platform-neutral, like the rest of the family — `react-native-safe-area-context`
12
+ * is already a required Bloom peer and is what the bar itself measures with.
13
+ */
14
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
15
+
16
+ import { EXPANDED_HEIGHT, tabBarBottomGap } from './shared';
17
+
18
+ /**
19
+ * The vertical space the tab bar OCCUPIES above the bottom of the window, in
20
+ * px: its own height plus its own bottom offset, and nothing else.
21
+ *
22
+ * It is deliberately the raw footprint, with no clearance folded in. Clearance
23
+ * is a consumer decision and it differs per call site — a scroll view wants the
24
+ * footprint plus a breathing gap so the last row clears the pill, while a
25
+ * floating action button anchored by `bottom` already supplies its own gap and
26
+ * wants the footprint bare. Baking a margin in here would place that FAB too
27
+ * high and make one word mean two things inside a single file. Add your own:
28
+ *
29
+ * ```tsx
30
+ * const footprint = useTabBarFootprint();
31
+ *
32
+ * <Animated.ScrollView contentContainerStyle={{ paddingBottom: footprint + 12 }} />
33
+ * <Fab style={{ position: 'absolute', right: 16, bottom: footprint }} />
34
+ * ```
35
+ *
36
+ * The value ALREADY INCLUDES the bottom safe-area inset — the bar folds the
37
+ * inset into its own gap, so this is measured from the true window edge, not
38
+ * from the safe area. Never write `footprint + insets.bottom`: that counts the
39
+ * home indicator twice and strands a visible band of dead space under every
40
+ * list (114px accounted for a 76px bar on an iOS device or PWA). Whatever you
41
+ * add on top is your own clearance — never the inset a second time.
42
+ *
43
+ * It is the EXPANDED height, deliberately. The bar minimizes while scrolling
44
+ * down and re-expands on the way up, so measuring the minimized height would
45
+ * hide the end of a list the moment the user scrolled back to it.
46
+ */
47
+ export function useTabBarFootprint(): number {
48
+ const insets = useSafeAreaInsets();
49
+ return tabBarBottomGap(insets.bottom) + EXPANDED_HEIGHT;
50
+ }
@@ -55,6 +55,7 @@ import {
55
55
  CLOSE_BUTTON_HIT_AREA,
56
56
  ENTERING_ANIMATION_DURATION,
57
57
  STACKING_ANIMATION_DURATION,
58
+ TOAST_MAX_ROW_WIDTH,
58
59
  toastDefaults,
59
60
  } from './constants';
60
61
  import { useDynamicToastContext, useToastContext } from './context';
@@ -224,6 +225,8 @@ export const ToastRow = React.forwardRef<ToastRef, ToastRowProps>(
224
225
  });
225
226
 
226
227
  const { width: screenWidth } = useWindowDimensions();
228
+ // The row is `width: '100%'` up to the cap, so this is its real width.
229
+ const rowWidth = Math.min(screenWidth, TOAST_MAX_ROW_WIDTH);
227
230
 
228
231
  const stackScaleX = useAnimatedTarget(
229
232
  calculateStackScaleX({
@@ -233,7 +236,7 @@ export const ToastRow = React.forwardRef<ToastRef, ToastRowProps>(
233
236
  position,
234
237
  isExpanded,
235
238
  stackGap,
236
- screenWidth,
239
+ rowWidth,
237
240
  }),
238
241
  { duration: STACKING_ANIMATION_DURATION, easing: easeOutQuartFn },
239
242
  );
@@ -356,7 +359,9 @@ export const ToastRow = React.forwardRef<ToastRef, ToastRowProps>(
356
359
  if (enableStacking && numberOfToasts > 1 && position !== 'center') {
357
360
  // On Android the RNGH tap wins the race against the nested Pressable,
358
361
  // so a press in the close-button strip has to dismiss explicitly.
359
- if (x > screenWidth - CLOSE_BUTTON_HIT_AREA) {
362
+ // `x` is relative to the row, which is capped — measuring the strip
363
+ // from the window edge would put it outside the row on a wide viewport.
364
+ if (x > rowWidth - CLOSE_BUTTON_HIT_AREA) {
360
365
  if (isExpanded && closeButton && dismissible) {
361
366
  onDismiss(id);
362
367
  }
@@ -370,7 +375,7 @@ export const ToastRow = React.forwardRef<ToastRef, ToastRowProps>(
370
375
  enableStacking,
371
376
  numberOfToasts,
372
377
  position,
373
- screenWidth,
378
+ rowWidth,
374
379
  isExpanded,
375
380
  closeButton,
376
381
  dismissible,
@@ -2,12 +2,18 @@
2
2
  * Derived from sonner-native v0.26.4 — src/gestures.tsx
3
3
  * (MIT © Gunnar Torfi Steinarsson). See the top-level NOTICE.
4
4
  *
5
- * W2 — the animated style reads `translate`, `direction`, `position`,
6
- * `windowWidth` and `isAndroid`; ALL of them are in the dependency array. On web
7
- * without the worklets babel plugin reanimated re-runs the mapper on its deps
8
- * rather than on auto-tracked reads, so a missing dep freezes the swipe transform
9
- * at its first frame (upstream omits `position` and `isAndroid`, so a toast that
10
- * changes position mid-life swipes the wrong way).
5
+ * W2 — the animated style reads `translate`, `direction`, `position`, `rowWidth`
6
+ * and `isAndroid`; ALL of them are in the dependency array. On web without the
7
+ * worklets babel plugin reanimated re-runs the mapper on its deps rather than on
8
+ * auto-tracked reads, so a missing dep freezes the swipe transform at its first
9
+ * frame (upstream omits `position` and `isAndroid`, so a toast that changes
10
+ * position mid-life swipes the wrong way).
11
+ *
12
+ * W12 — this wrapper is THE row box, so it is where `TOAST_MAX_ROW_WIDTH` caps
13
+ * and centres the toast. Capping here rather than on `ToastContent`'s card
14
+ * covers `toast.custom` JSX and `unstyled` rows too, which never reach that
15
+ * card. Every distance the gesture measures is a fraction of the capped
16
+ * `rowWidth`, not of the window — see the constant.
11
17
  */
12
18
  import * as React from 'react';
13
19
  import {
@@ -28,10 +34,11 @@ import Animated, {
28
34
  } from 'react-native-reanimated';
29
35
 
30
36
  import { easeInOutCircFn } from './animations';
37
+ import { TOAST_MAX_ROW_WIDTH } from './constants';
31
38
  import { useToastContext } from './context';
32
39
  import type { ToastPosition, ToastProps } from './types';
33
40
 
34
- /** Fraction of the screen a horizontal swipe must cross to dismiss. */
41
+ /** Fraction of the row a horizontal swipe must cross to dismiss. */
35
42
  const HORIZONTAL_DISMISS_FRACTION = 0.25;
36
43
  /** Below this the gesture reads as a tap, so the row springs back. */
37
44
  const TAP_SLOP = 16;
@@ -66,6 +73,8 @@ export const ToastSwipeHandler: React.FC<
66
73
  onPress,
67
74
  }) => {
68
75
  const { width: windowWidth } = useWindowDimensions();
76
+ // The row is `width: '100%'` up to the cap, so this is its real width.
77
+ const rowWidth = Math.min(windowWidth, TOAST_MAX_ROW_WIDTH);
69
78
  const translate = useSharedValue(0);
70
79
  const { swipeToDismissDirection: direction, position: positionCtx } =
71
80
  useToastContext();
@@ -137,7 +146,7 @@ export const ToastSwipeHandler: React.FC<
137
146
  if (direction === 'left') {
138
147
  if (Math.abs(translate.value) < TAP_SLOP) {
139
148
  springBack();
140
- } else if (translate.value < -windowWidth * HORIZONTAL_DISMISS_FRACTION) {
149
+ } else if (translate.value < -rowWidth * HORIZONTAL_DISMISS_FRACTION) {
141
150
  dismiss();
142
151
  } else {
143
152
  springBack();
@@ -175,11 +184,11 @@ export const ToastSwipeHandler: React.FC<
175
184
  ? 1
176
185
  : interpolate(
177
186
  translate.value,
178
- [0, direction === 'left' ? -windowWidth : -VERTICAL_FADE_DISTANCE],
187
+ [0, direction === 'left' ? -rowWidth : -VERTICAL_FADE_DISTANCE],
179
188
  [1, 0],
180
189
  ),
181
190
  };
182
- }, [direction, translate, windowWidth, position, isAndroid]);
191
+ }, [direction, translate, rowWidth, position, isAndroid]);
183
192
 
184
193
  return (
185
194
  <GestureDetector gesture={Gesture.Race(tap, pan)}>
@@ -187,7 +196,7 @@ export const ToastSwipeHandler: React.FC<
187
196
  style={[
188
197
  animatedStyle,
189
198
  unstyled ? undefined : styles.centered,
190
- styles.fullWidth,
199
+ styles.rowBox,
191
200
  style,
192
201
  ]}
193
202
  // W9 — on web `LinearTransition`'s easing degrades to CSS `ease` because
@@ -205,7 +214,16 @@ export const ToastSwipeHandler: React.FC<
205
214
 
206
215
  const styles = StyleSheet.create({
207
216
  centered: { justifyContent: 'center' },
208
- fullWidth: { width: '100%' },
217
+ /**
218
+ * `alignSelf` is load-bearing: the parent anchor stretches its children, but a
219
+ * child with a definite cross size (`width: '100%'` capped by `maxWidth`) is
220
+ * not stretched — it would sit against the left edge without this.
221
+ */
222
+ rowBox: {
223
+ width: '100%',
224
+ maxWidth: TOAST_MAX_ROW_WIDTH,
225
+ alignSelf: 'center',
226
+ },
209
227
  });
210
228
 
211
229
  /**
@@ -6,6 +6,7 @@
6
6
  * `toast-store.ts` never has to import a Reanimated-dependent module: the store
7
7
  * stays pure JS and its suite needs zero React Native mocks.
8
8
  */
9
+ import { space } from '../styles/tokens';
9
10
  import type {
10
11
  AutoWiggle,
11
12
  ToastPosition,
@@ -21,6 +22,33 @@ export const OUTSIDE_PRESS_PADDING = 20;
21
22
  /** However deep a stack gets, a buried row never narrows past this scale. */
22
23
  export const MIN_STACK_SCALE_X = 0.8;
23
24
 
25
+ /**
26
+ * W12 — HOW WIDE A TOAST ROW IS ALLOWED TO GET.
27
+ *
28
+ * sonner-native has no cap: the row is `width: '100%'` and the card inside it is
29
+ * inset by `marginHorizontal: space.lg`, so the card is always
30
+ * screen-width-minus-32. That is correct for the platform it was written for and
31
+ * wrong everywhere else — on a 1280px desktop viewport it produces a ~1248px
32
+ * card, which reads as a page banner rather than a toast. sonner (web) instead
33
+ * fixes the card at `--width: 356px`.
34
+ *
35
+ * The cap therefore sizes the ROW BOX, which carries the card's two `space.lg`
36
+ * gutters, so the visible card lands on sonner's 356px on any viewport wide
37
+ * enough to reach it. `maxWidth` needs no platform fork because it degrades by
38
+ * itself: below a 388px viewport the row is still screen-width and the card is
39
+ * still screen-width-minus-32, exactly as before, so phone portrait — the case
40
+ * the mobile sizing was right for — is untouched. Above it (desktop, tablet,
41
+ * phone landscape) the row caps and centres.
42
+ *
43
+ * `ToastSwipeHandler`'s dismiss threshold and `calculateStackScaleX`'s depth cue
44
+ * are fractions of this row width, NOT of the window: at 1280px a
45
+ * window-relative threshold would demand a 320px drag from a 388px card, and a
46
+ * window-relative squeeze would shrink a buried row by 4px instead of 16px.
47
+ * A consumer who overrides the cap through `toastOptions.toastContainerStyle`
48
+ * moves the card only; both of those keep measuring against this default.
49
+ */
50
+ export const TOAST_MAX_ROW_WIDTH = 356 + space.lg * 2;
51
+
24
52
  /** Enter animation length; also the auto-close timer's head start and the overlay teardown delay. */
25
53
  export const ENTERING_ANIMATION_DURATION = 300;
26
54
  export const STACKING_ANIMATION_DURATION = 600;
@@ -34,7 +34,12 @@ export const getOrderedToastIds = (
34
34
  *
35
35
  * Scale rather than width: changing the real width would rewrap the text. Returns
36
36
  * 1 (no squeeze) for a single row, an expanded stack, stacking turned off, or
37
- * before the window width is known.
37
+ * before the row width is known.
38
+ *
39
+ * `rowWidth` is the row's OWN width, not the window's. Upstream can use the
40
+ * screen width because its row is always full-bleed; Bloom's is capped at
41
+ * `TOAST_MAX_ROW_WIDTH`, and dividing a fixed `stackGap` inset by a 1280px window
42
+ * would shrink a buried row by 4 visible pixels instead of the intended 16.
38
43
  */
39
44
  export const calculateStackScaleX = ({
40
45
  index,
@@ -43,7 +48,7 @@ export const calculateStackScaleX = ({
43
48
  position,
44
49
  isExpanded,
45
50
  stackGap,
46
- screenWidth,
51
+ rowWidth,
47
52
  }: {
48
53
  index: number;
49
54
  numberOfToasts: number;
@@ -51,16 +56,16 @@ export const calculateStackScaleX = ({
51
56
  position: ToastPosition;
52
57
  isExpanded: boolean;
53
58
  stackGap: number;
54
- screenWidth: number;
59
+ rowWidth: number;
55
60
  }): number => {
56
- if (!enableStacking || numberOfToasts <= 1 || isExpanded || screenWidth <= 0) {
61
+ if (!enableStacking || numberOfToasts <= 1 || isExpanded || rowWidth <= 0) {
57
62
  return 1;
58
63
  }
59
64
 
60
65
  const distanceFromFront =
61
66
  position === 'top-center' ? index : numberOfToasts - index - 1;
62
67
  const narrowAmount = stackGap * distanceFromFront * 2;
63
- return Math.max(MIN_STACK_SCALE_X, 1 - narrowAmount / screenWidth);
68
+ return Math.max(MIN_STACK_SCALE_X, 1 - narrowAmount / rowWidth);
64
69
  };
65
70
 
66
71
  const heightOf = (
@@ -36,7 +36,7 @@ import {
36
36
  ArrowOutOfBox_Stroke2_Corner0_Rounded,
37
37
  } from '../icons';
38
38
  import { Z_INDEX } from '../styles/z-index';
39
- import { WEB_POSITION_FIXED } from '../styles/web-view-style';
39
+ import { WEB_POSITION_FIXED, type WebCssStyle } from '../styles/web-view-style';
40
40
  import {
41
41
  getAspectRatio,
42
42
  fetchAspectRatio,
@@ -76,14 +76,15 @@ const AnimatedImage = Animated.createAnimatedComponent(Image);
76
76
 
77
77
  // Web-only interaction hints (no-ops on native): the zoom surfaces are
78
78
  // tap-to-dismiss (`cursor: pointer`) and must not select text or drag the
79
- // image. `Platform.select` keeps them off native; the `as ViewStyle` cast
80
- // mirrors Bloom's other web-only style casts (e.g. Dialog.web's `animation`).
81
- const webPointerStyle = Platform.select({
82
- web: { userSelect: 'none', cursor: 'pointer' } as ViewStyle,
83
- default: undefined,
84
- });
79
+ // image. `Platform.select` keeps them off native. `cursor` is a real
80
+ // `ViewStyle` key on RN 0.83; `userSelect` is declared on `TextStyle` only,
81
+ // which is what `WebCssStyle` covers.
82
+ const WEB_POINTER: WebCssStyle = { userSelect: 'none', cursor: 'pointer' };
83
+ const WEB_USER_SELECT_NONE: WebCssStyle = { userSelect: 'none' };
84
+
85
+ const webPointerStyle = Platform.select({ web: WEB_POINTER, default: undefined });
85
86
  const webUserSelectNoneStyle = Platform.select({
86
- web: { userSelect: 'none' } as ViewStyle,
87
+ web: WEB_USER_SELECT_NONE,
87
88
  default: undefined,
88
89
  });
89
90