@oxyhq/bloom 1.9.2 → 1.10.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 (123) hide show
  1. package/docs/layout.mdx +79 -0
  2. package/lib/commonjs/bottom-sheet/BottomSheetBase.js +14 -4
  3. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
  4. package/lib/commonjs/fab/Fab.js +12 -3
  5. package/lib/commonjs/fab/Fab.js.map +1 -1
  6. package/lib/commonjs/fab/Fab.web.js +12 -4
  7. package/lib/commonjs/fab/Fab.web.js.map +1 -1
  8. package/lib/commonjs/index.js +137 -101
  9. package/lib/commonjs/index.js.map +1 -1
  10. package/lib/commonjs/index.web.js +137 -101
  11. package/lib/commonjs/index.web.js.map +1 -1
  12. package/lib/commonjs/layout/bottom-edge.js +148 -0
  13. package/lib/commonjs/layout/bottom-edge.js.map +1 -0
  14. package/lib/commonjs/layout/edge.js +67 -0
  15. package/lib/commonjs/layout/edge.js.map +1 -0
  16. package/lib/commonjs/layout/index.js +38 -0
  17. package/lib/commonjs/layout/index.js.map +1 -0
  18. package/lib/commonjs/provider/BloomProvider.js +5 -2
  19. package/lib/commonjs/provider/BloomProvider.js.map +1 -1
  20. package/lib/commonjs/tab-bar/TabBarBase.js +11 -1
  21. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  22. package/lib/commonjs/tab-bar/shared.js +0 -22
  23. package/lib/commonjs/tab-bar/shared.js.map +1 -1
  24. package/lib/commonjs/tab-bar/use-footprint.js +17 -8
  25. package/lib/commonjs/tab-bar/use-footprint.js.map +1 -1
  26. package/lib/commonjs/toast/Positioner.js +6 -1
  27. package/lib/commonjs/toast/Positioner.js.map +1 -1
  28. package/lib/commonjs/toast/container-geometry.js +19 -13
  29. package/lib/commonjs/toast/container-geometry.js.map +1 -1
  30. package/lib/module/bottom-sheet/BottomSheetBase.js +14 -4
  31. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  32. package/lib/module/fab/Fab.js +12 -3
  33. package/lib/module/fab/Fab.js.map +1 -1
  34. package/lib/module/fab/Fab.web.js +12 -4
  35. package/lib/module/fab/Fab.web.js.map +1 -1
  36. package/lib/module/index.js +1 -0
  37. package/lib/module/index.js.map +1 -1
  38. package/lib/module/index.web.js +1 -0
  39. package/lib/module/index.web.js.map +1 -1
  40. package/lib/module/layout/bottom-edge.js +141 -0
  41. package/lib/module/layout/bottom-edge.js.map +1 -0
  42. package/lib/module/layout/edge.js +62 -0
  43. package/lib/module/layout/edge.js.map +1 -0
  44. package/lib/module/layout/index.js +11 -0
  45. package/lib/module/layout/index.js.map +1 -0
  46. package/lib/module/provider/BloomProvider.js +5 -2
  47. package/lib/module/provider/BloomProvider.js.map +1 -1
  48. package/lib/module/tab-bar/TabBarBase.js +12 -2
  49. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  50. package/lib/module/tab-bar/shared.js +0 -21
  51. package/lib/module/tab-bar/shared.js.map +1 -1
  52. package/lib/module/tab-bar/use-footprint.js +18 -9
  53. package/lib/module/tab-bar/use-footprint.js.map +1 -1
  54. package/lib/module/toast/Positioner.js +6 -1
  55. package/lib/module/toast/Positioner.js.map +1 -1
  56. package/lib/module/toast/container-geometry.js +20 -13
  57. package/lib/module/toast/container-geometry.js.map +1 -1
  58. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/index.d.ts +1 -0
  62. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/index.web.d.ts +1 -0
  64. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/layout/bottom-edge.d.ts +77 -0
  66. package/lib/typescript/commonjs/layout/bottom-edge.d.ts.map +1 -0
  67. package/lib/typescript/commonjs/layout/edge.d.ts +56 -0
  68. package/lib/typescript/commonjs/layout/edge.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/layout/index.d.ts +9 -0
  70. package/lib/typescript/commonjs/layout/index.d.ts.map +1 -0
  71. package/lib/typescript/commonjs/provider/BloomProvider.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/tab-bar/shared.d.ts +0 -9
  74. package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/tab-bar/use-footprint.d.ts +11 -7
  76. package/lib/typescript/commonjs/tab-bar/use-footprint.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/toast/Positioner.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/toast/container-geometry.d.ts +14 -5
  79. package/lib/typescript/commonjs/toast/container-geometry.d.ts.map +1 -1
  80. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  81. package/lib/typescript/module/fab/Fab.d.ts.map +1 -1
  82. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
  83. package/lib/typescript/module/index.d.ts +1 -0
  84. package/lib/typescript/module/index.d.ts.map +1 -1
  85. package/lib/typescript/module/index.web.d.ts +1 -0
  86. package/lib/typescript/module/index.web.d.ts.map +1 -1
  87. package/lib/typescript/module/layout/bottom-edge.d.ts +77 -0
  88. package/lib/typescript/module/layout/bottom-edge.d.ts.map +1 -0
  89. package/lib/typescript/module/layout/edge.d.ts +56 -0
  90. package/lib/typescript/module/layout/edge.d.ts.map +1 -0
  91. package/lib/typescript/module/layout/index.d.ts +9 -0
  92. package/lib/typescript/module/layout/index.d.ts.map +1 -0
  93. package/lib/typescript/module/provider/BloomProvider.d.ts.map +1 -1
  94. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  95. package/lib/typescript/module/tab-bar/shared.d.ts +0 -9
  96. package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
  97. package/lib/typescript/module/tab-bar/use-footprint.d.ts +11 -7
  98. package/lib/typescript/module/tab-bar/use-footprint.d.ts.map +1 -1
  99. package/lib/typescript/module/toast/Positioner.d.ts.map +1 -1
  100. package/lib/typescript/module/toast/container-geometry.d.ts +14 -5
  101. package/lib/typescript/module/toast/container-geometry.d.ts.map +1 -1
  102. package/package.json +15 -1
  103. package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
  104. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  105. package/src/__tests__/support/press-host.ts +30 -0
  106. package/src/__tests__/support/rendered-style.ts +99 -0
  107. package/src/__tests__/support/unread-hook-fixture.ts +33 -0
  108. package/src/bottom-sheet/BottomSheetBase.tsx +14 -4
  109. package/src/fab/Fab.tsx +16 -3
  110. package/src/fab/Fab.web.tsx +16 -4
  111. package/src/index.ts +7 -0
  112. package/src/index.web.ts +7 -0
  113. package/src/layout/bottom-edge.tsx +162 -0
  114. package/src/layout/edge.ts +59 -0
  115. package/src/layout/index.ts +8 -0
  116. package/src/provider/BloomProvider.tsx +11 -1
  117. package/src/tab-bar/TabBarBase.tsx +11 -2
  118. package/src/tab-bar/shared.ts +0 -20
  119. package/src/tab-bar/use-footprint.ts +18 -9
  120. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +7682 -0
  121. package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
  122. package/src/toast/Positioner.tsx +5 -0
  123. package/src/toast/container-geometry.ts +20 -8
@@ -0,0 +1,56 @@
1
+ /**
2
+ * How far a floating surface sits from a window edge.
3
+ *
4
+ * ## The rule this file encodes
5
+ *
6
+ * A floating surface CLEARS THE OS'S RESERVED BAND IN FULL. The safe-area inset
7
+ * is the space the platform has reserved for its own affordance — the iOS home
8
+ * indicator, the Android gesture handle — and a surface that sits inside it is
9
+ * sitting on top of a system control.
10
+ *
11
+ * Bloom used to answer this question three times, differently:
12
+ *
13
+ * - a detached `BottomSheet` `insets.bottom + 16`
14
+ * - the toast stack `bottom > 0 ? bottom + 8 : 16`
15
+ * - the floating tab bar `max(bottom - 16, 12)`
16
+ *
17
+ * The tab bar was the only one that SUBTRACTED, and subtracting is what put it
18
+ * on the Android gesture handle: a gesture-navigation device reserves ~24dp, the
19
+ * bar absorbed 16 of it, hit its own 12dp floor, and landed inside the band with
20
+ * the handle drawn through it. The other two were never reachable by that bug
21
+ * because they add.
22
+ *
23
+ * One function now answers it for all three, and the additive `gap` reproduces
24
+ * the sheet and the toast exactly — only the tab bar moves.
25
+ */
26
+ /**
27
+ * Breathing room from a window edge where the platform reserves nothing at all
28
+ * (web, a browser with no visual viewport inset, an Android device with the
29
+ * gesture bar hidden).
30
+ *
31
+ * It is also the FLOOR for every surface: a small-but-nonzero inset must never
32
+ * pull a surface closer to the edge than a device reporting no inset would.
33
+ */
34
+ export declare const EDGE_GAP = 16;
35
+ /**
36
+ * The distance a floating surface holds off a window edge, given that edge's
37
+ * safe-area inset.
38
+ *
39
+ * `gap` is the surface's own breathing room ON TOP of the reserved band, not an
40
+ * allowance to spend inside it — a bigger `gap` floats the surface further from
41
+ * the affordance, never closer. Pass `0` for a surface that should hug the safe
42
+ * area (the tab bar), a positive number for one that should stand clear of it (a
43
+ * detached sheet, a toast stack).
44
+ *
45
+ * ```ts
46
+ * windowEdgeGap(insets.bottom) // 34pt iOS · 24dp Android · 16 web
47
+ * windowEdgeGap(insets.bottom, 16) // 50pt iOS · 40dp Android · 16 web
48
+ * ```
49
+ *
50
+ * Anything laying out AROUND such a surface wants its total footprint (this gap
51
+ * plus the surface's own height) and must get it from the surface — never by
52
+ * adding `insets.bottom` to a hardcoded height, which double-counts the inset
53
+ * because the inset is already folded in here.
54
+ */
55
+ export declare function windowEdgeGap(inset: number, gap?: number): number;
56
+ //# sourceMappingURL=edge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edge.d.ts","sourceRoot":"","sources":["../../../../src/layout/edge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,KAAK,CAAC;AAE3B;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAI,GAAG,MAAM,CAE5D"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Window-edge geometry, shared by every floating surface.
3
+ *
4
+ * `edge` answers "how far from the edge"; `bottom-edge` answers "what is already
5
+ * parked there". A surface that floats at the bottom needs both.
6
+ */
7
+ export { EDGE_GAP, windowEdgeGap } from './edge';
8
+ export { BottomEdgeProvider, useBottomEdgeInset, useClaimBottomEdge } from './bottom-edge';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/layout/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AACjD,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"BloomProvider.d.ts","sourceRoot":"","sources":["../../../../src/provider/BloomProvider.tsx"],"names":[],"mappings":"AA0CA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAElD,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,aAAa,EACb,OAAc,EACd,GAAG,UAAU,EACd,EAAE,kBAAkB,2CA2BpB;yBAhCe,aAAa"}
1
+ {"version":3,"file":"BloomProvider.d.ts","sourceRoot":"","sources":["../../../../src/provider/BloomProvider.tsx"],"names":[],"mappings":"AA2CA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAElD,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,aAAa,EACb,OAAc,EACd,GAAG,UAAU,EACd,EAAE,kBAAkB,2CAoCpB;yBAzCe,aAAa"}
@@ -1 +1 @@
1
- {"version":3,"file":"TabBarBase.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/TabBarBase.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAOL,KAAK,aAAa,EACnB,MAAM,OAAO,CAAC;AAiBf,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAEtE,OAAO,EAkBL,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACxB,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAe,MAAM,SAAS,CAAC;AA+iB3E;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,aAAa,CAAC,kBAAkB,CAAC,EAC1C,IAAI,EAAE,aAAa,CAAC,oBAAoB,CAAC;YAElB,WAAW;;EAGnC;AAED,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,aAAa,CAAC,gBAAgB,CAAC;YAC1C,iBAAiB;;EAG/C"}
1
+ {"version":3,"file":"TabBarBase.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/TabBarBase.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAOL,KAAK,aAAa,EACnB,MAAM,OAAO,CAAC;AAmBf,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAEtE,OAAO,EAiBL,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACxB,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAe,MAAM,SAAS,CAAC;AAujB3E;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,aAAa,CAAC,kBAAkB,CAAC,EAC1C,IAAI,EAAE,aAAa,CAAC,oBAAoB,CAAC;YAElB,WAAW;;EAGnC;AAED,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,aAAa,CAAC,gBAAgB,CAAC;YAC1C,iBAAiB;;EAG/C"}
@@ -26,15 +26,6 @@ export declare const HIGHLIGHT_MINIMIZED: number;
26
26
  export declare const BLUR_BLEED = 44;
27
27
  /** Label type size. Small by design — the icon carries the meaning. */
28
28
  export declare const LABEL_FONT_SIZE = 9.5;
29
- /**
30
- * Vertical gap between the bottom of the pill and the bottom of the window.
31
- *
32
- * The safe-area inset is folded in HERE, which is why anything accounting for
33
- * the bar in its own layout must go through `useTabBarFootprint` (which derives
34
- * its number from this same function) rather than adding `insets.bottom` to a
35
- * hardcoded height — that double-counts the inset.
36
- */
37
- export declare function tabBarBottomGap(bottomInset: number): number;
38
29
  /**
39
30
  * Slide spring: interruptible by design — rapid tab-hopping retargets with
40
31
  * preserved velocity. Slight under-damping gives the pill a tiny settle, safe
@@ -1 +1 @@
1
- {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/shared.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAG7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAWvD,eAAO,MAAM,eAAe,KAAK,CAAC;AAClC,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,4EAA4E;AAC5E,eAAO,MAAM,eAAe,KAAK,CAAC;AAClC,qEAAqE;AACrE,eAAO,MAAM,UAAU,KAAK,CAAC;AAC7B,8DAA8D;AAC9D,eAAO,MAAM,SAAS,IAAI,CAAC;AAC3B,eAAO,MAAM,YAAY,KAAK,CAAC;AAC/B,eAAO,MAAM,SAAS,KAAK,CAAC;AAC5B;;;GAGG;AACH,eAAO,MAAM,QAAQ,IAAI,CAAC;AAC1B,eAAO,MAAM,WAAW,QAA0B,CAAC;AACnD,eAAO,MAAM,UAAU,IAAI,CAAC;AAC5B,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,QAA2C,CAAC;AAC3E,eAAO,MAAM,mBAAmB,QAA6B,CAAC;AAC9D,iEAAiE;AACjE,eAAO,MAAM,UAAU,KAAK,CAAC;AAC7B,uEAAuE;AACvE,eAAO,MAAM,eAAe,MAAM,CAAC;AAWnC;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED;;;;GAIG;AACH,eAAO,MAAM,YAAY;;;CAAwC,CAAC;AAElE;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc;;CAAoB,CAAC;AAEhD;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAM3C;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,WAAW,EACnB,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAC/B,WAAW,CAQb;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CAe5E;AAaD,MAAM,WAAW,kBAAkB;IACjC,yEAAyE;IACzE,KAAK,EAAE,WAAW,CAAC;IACnB;;;;;;;;;;;;OAYG;IACH,KAAK,EAAE,SAAS,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC;CAC5C;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,UAAU,CAAC;IACjB,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,MAAM,EAAE,OAAO,CAAC;CACjB"}
1
+ {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/shared.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAG7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAWvD,eAAO,MAAM,eAAe,KAAK,CAAC;AAClC,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,4EAA4E;AAC5E,eAAO,MAAM,eAAe,KAAK,CAAC;AAClC,qEAAqE;AACrE,eAAO,MAAM,UAAU,KAAK,CAAC;AAC7B,8DAA8D;AAC9D,eAAO,MAAM,SAAS,IAAI,CAAC;AAC3B,eAAO,MAAM,YAAY,KAAK,CAAC;AAC/B,eAAO,MAAM,SAAS,KAAK,CAAC;AAC5B;;;GAGG;AACH,eAAO,MAAM,QAAQ,IAAI,CAAC;AAC1B,eAAO,MAAM,WAAW,QAA0B,CAAC;AACnD,eAAO,MAAM,UAAU,IAAI,CAAC;AAC5B,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,QAA2C,CAAC;AAC3E,eAAO,MAAM,mBAAmB,QAA6B,CAAC;AAC9D,iEAAiE;AACjE,eAAO,MAAM,UAAU,KAAK,CAAC;AAC7B,uEAAuE;AACvE,eAAO,MAAM,eAAe,MAAM,CAAC;AAGnC;;;;GAIG;AACH,eAAO,MAAM,YAAY;;;CAAwC,CAAC;AAElE;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc;;CAAoB,CAAC;AAEhD;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAM3C;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,WAAW,EACnB,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAC/B,WAAW,CAQb;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CAe5E;AAaD,MAAM,WAAW,kBAAkB;IACjC,yEAAyE;IACzE,KAAK,EAAE,WAAW,CAAC;IACnB;;;;;;;;;;;;OAYG;IACH,KAAK,EAAE,SAAS,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC;CAC5C;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,UAAU,CAAC;IACjB,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,MAAM,EAAE,OAAO,CAAC;CACjB"}
@@ -13,15 +13,19 @@
13
13
  * const footprint = useTabBarFootprint();
14
14
  *
15
15
  * <Animated.ScrollView contentContainerStyle={{ paddingBottom: footprint + 12 }} />
16
- * <Fab style={{ position: 'absolute', right: 16, bottom: footprint }} />
17
16
  * ```
18
17
  *
19
- * The value ALREADY INCLUDES the bottom safe-area inset — the bar folds the
20
- * inset into its own gap, so this is measured from the true window edge, not
21
- * from the safe area. Never write `footprint + insets.bottom`: that counts the
22
- * home indicator twice and strands a visible band of dead space under every
23
- * list (114px accounted for a 76px bar on an iOS device or PWA). Whatever you
24
- * add on top is your own clearance — never the inset a second time.
18
+ * A FAB no longer needs this hook at all `Fab` reads the bottom edge's
19
+ * occupancy itself (see `layout/bottom-edge`), which is what the bar publishes
20
+ * this exact number to. Reach for the hook when laying out something that is not
21
+ * a Bloom surface.
22
+ *
23
+ * The value ALREADY INCLUDES the bottom safe-area inset `windowEdgeGap` clears
24
+ * the OS's reserved band in full — so this is measured from the true window
25
+ * edge, not from the safe area. Never write `footprint + insets.bottom`: that
26
+ * counts the home indicator twice and strands a visible band of dead space under
27
+ * every list. Whatever you add on top is your own clearance, never the inset a
28
+ * second time.
25
29
  *
26
30
  * It is the EXPANDED height, deliberately. The bar minimizes while scrolling
27
31
  * down and re-expands on the way up, so measuring the minimized height would
@@ -1 +1 @@
1
- {"version":3,"file":"use-footprint.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/use-footprint.ts"],"names":[],"mappings":"AAiBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAG3C"}
1
+ {"version":3,"file":"use-footprint.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/use-footprint.ts"],"names":[],"mappings":"AAkBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAO3C"}
@@ -1 +1 @@
1
- {"version":3,"file":"Positioner.d.ts","sourceRoot":"","sources":["../../../../src/toast/Positioner.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAa/B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAwB5C,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC,EAAE,CAC/B,KAAK,CAAC,iBAAiB,CAAC,IAAI,CAAC,YAAY,EAAE,UAAU,GAAG,OAAO,CAAC,CAAC,CA4DlE,CAAC"}
1
+ {"version":3,"file":"Positioner.d.ts","sourceRoot":"","sources":["../../../../src/toast/Positioner.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAc/B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAwB5C,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC,EAAE,CAC/B,KAAK,CAAC,iBAAiB,CAAC,IAAI,CAAC,YAAY,EAAE,UAAU,GAAG,OAAO,CAAC,CAAC,CAgElE,CAAC"}
@@ -43,19 +43,28 @@ import type { ToastPosition } from './types';
43
43
  */
44
44
  export declare const getContainerStyle: () => ViewStyle;
45
45
  /**
46
- * How far the stack sits from its screen edge. A falsy `offset` means "derive from
47
- * the safe area": `inset + 8`, or 16 when there is no inset.
46
+ * How far the stack sits from its screen edge.
48
47
  *
49
- * `Positioner` applies this over ONE of `getContainerStyle`'s four pinned edges;
50
- * `calculateOutsidePressableArea` reads the same numbers as a plain edge distance.
48
+ * The gap is the shared `windowEdgeGap` rule with the stack's own breathing room
49
+ * on top, so the stack clears the OS's reserved band the same way a detached
50
+ * sheet and the tab bar do. `offset` overrides that gap outright — it is the
51
+ * consumer saying where the stack goes.
52
+ *
53
+ * `bottomEdgeInset` is separate from both, and additive to either: it is not a
54
+ * gap preference but an OBSTRUCTION — whatever floating surface has already
55
+ * claimed the bottom edge (see `layout/bottom-edge`). A floating tab bar occupies
56
+ * ~82dp on a gesture-navigation Android device while this stack sat at 32dp, so
57
+ * every bottom toast rendered on top of the bar until the stack started reading
58
+ * it. An explicit `offset` does not excuse the stack from clearing it.
51
59
  */
52
- export declare const getInsetValues: ({ position, offset, safeAreaInsets, }: {
60
+ export declare const getInsetValues: ({ position, offset, safeAreaInsets, bottomEdgeInset, }: {
53
61
  position: ToastPosition;
54
62
  offset?: number;
55
63
  safeAreaInsets?: {
56
64
  top: number;
57
65
  bottom: number;
58
66
  };
67
+ bottomEdgeInset?: number;
59
68
  }) => {
60
69
  top?: number;
61
70
  bottom?: number;
@@ -1 +1 @@
1
- {"version":3,"file":"container-geometry.d.ts","sourceRoot":"","sources":["../../../../src/toast/container-geometry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,iBAAiB,QAAO,SASnC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,GAAI,uCAI5B;IACD,QAAQ,EAAE,aAAa,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAClD,KAAG;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAclC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GAAI,8DAM3C;IACD,QAAQ,EAAE,aAAa,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC,MAAM,GAAG,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAChD,KAAG,SA2CH,CAAC"}
1
+ {"version":3,"file":"container-geometry.d.ts","sourceRoot":"","sources":["../../../../src/toast/container-geometry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAM9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,iBAAiB,QAAO,SASnC,CAAC;AAEH;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,GAAI,wDAK5B;IACD,QAAQ,EAAE,aAAa,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACjD,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,KAAG;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAYlC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GAAI,8DAM3C;IACD,QAAQ,EAAE,aAAa,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC,MAAM,GAAG,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAChD,KAAG,SA2CH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "1.9.2",
3
+ "version": "1.10.0",
4
4
  "packageManager": "bun@1.3.14",
5
5
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
6
6
  "main": "lib/commonjs/index.js",
@@ -258,6 +258,20 @@
258
258
  "default": "./lib/commonjs/button/index.js"
259
259
  }
260
260
  },
261
+ "./layout": {
262
+ "react-native": {
263
+ "types": "./lib/typescript/module/layout/index.d.ts",
264
+ "default": "./src/layout/index.ts"
265
+ },
266
+ "import": {
267
+ "types": "./lib/typescript/module/layout/index.d.ts",
268
+ "default": "./lib/module/layout/index.js"
269
+ },
270
+ "require": {
271
+ "types": "./lib/typescript/commonjs/layout/index.d.ts",
272
+ "default": "./lib/commonjs/layout/index.js"
273
+ }
274
+ },
261
275
  "./fab": {
262
276
  "react-native": {
263
277
  "types": "./lib/typescript/module/fab/index.d.ts",
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A barrel that DELIBERATELY offers one name from two declarations, so that
3
+ * `barrel-name-collisions.test.ts` can prove its detector fires. It reproduces
4
+ * the shipped `Item` shape: a star export carrying `Item` from `item/`, and an
5
+ * explicit re-export binding that same name to a different component.
6
+ *
7
+ * Nothing imports this at runtime. bob's `exclude` keeps it out of `lib/`, and
8
+ * `files: ["src", …]` does ship it inside the tarball's `src/` like the other
9
+ * test files there — but no entry point reaches it, so no bundler links it, and
10
+ * `package.json#exports` never names it.
11
+ *
12
+ * The `Card` lines below are the other half of the
13
+ * control: one name offered twice from ONE declaration, which the detector must
14
+ * stay quiet about — otherwise its cheapest fix would be deleting a legitimate
15
+ * redundant re-export.
16
+ */
17
+ export * from '../../item';
18
+ export { Card as Item } from '../../card';
19
+ export { Card } from '../../card';
20
+ export * from '../../card';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A spec-shaped stand-in for constructed stylesheets, for the jsdom suites.
3
+ *
4
+ * jsdom 26 ships the `CSSStyleSheet` CONSTRUCTOR but neither `replaceSync` nor
5
+ * `document.adoptedStyleSheets`, so every jsdom test takes Bloom's `<style>`
6
+ * fallback. That makes the whole CSP-safe path — the reason
7
+ * `styles/adopt-style-sheet.ts` exists — invisible to jest unless a test
8
+ * installs the API itself. Without this, a mutation that deleted the adoption
9
+ * branch entirely would leave the suite green.
10
+ *
11
+ * Not collected as a suite: jest's `testMatch` wants `*.test.ts` / `*.spec.ts`.
12
+ */
13
+
14
+ export interface FakeStyleSheet {
15
+ /** The CSS most recently handed to `replaceSync`. */
16
+ cssText: string;
17
+ /** How many times the sheet has been re-parsed, so a test can prove it wasn't. */
18
+ replaceSyncCalls: number;
19
+ replaceSync(css: string): void;
20
+ }
21
+
22
+ export interface ConstructedStyleSheetsHarness {
23
+ /** The document's adopted sheets, read live (the code reassigns the array). */
24
+ adopted(): readonly FakeStyleSheet[];
25
+ /** Drop the API again, restoring jsdom's own `CSSStyleSheet`. */
26
+ uninstall(): void;
27
+ }
28
+
29
+ export function installConstructedStyleSheets(): ConstructedStyleSheetsHarness {
30
+ class FakeCSSStyleSheet implements FakeStyleSheet {
31
+ cssText = '';
32
+ replaceSyncCalls = 0;
33
+
34
+ replaceSync(css: string): void {
35
+ this.cssText = css;
36
+ this.replaceSyncCalls += 1;
37
+ }
38
+ }
39
+
40
+ const previousConstructor = Object.getOwnPropertyDescriptor(
41
+ globalThis,
42
+ 'CSSStyleSheet',
43
+ );
44
+
45
+ Object.defineProperty(globalThis, 'CSSStyleSheet', {
46
+ value: FakeCSSStyleSheet,
47
+ writable: true,
48
+ configurable: true,
49
+ });
50
+ Object.defineProperty(document, 'adoptedStyleSheets', {
51
+ value: [],
52
+ writable: true,
53
+ configurable: true,
54
+ });
55
+
56
+ return {
57
+ adopted: () =>
58
+ document.adoptedStyleSheets as unknown as readonly FakeStyleSheet[],
59
+ uninstall: () => {
60
+ Reflect.deleteProperty(document, 'adoptedStyleSheets');
61
+ if (previousConstructor) {
62
+ Object.defineProperty(globalThis, 'CSSStyleSheet', previousConstructor);
63
+ } else {
64
+ Reflect.deleteProperty(globalThis, 'CSSStyleSheet');
65
+ }
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pressing a component's OWN host node, and proving the component is what
3
+ * installed the handler being measured.
4
+ *
5
+ * `fireEvent.press` walks UP from the element it is handed to the nearest
6
+ * ancestor carrying an `onPress` prop, and it does not stop at host nodes — a
7
+ * COMPOSITE counts, because `getEventHandler` only reads `element.props`. So
8
+ * `<Thing onPress={fn}>` written in a test's own JSX catches the press itself.
9
+ * Measured on `ProfileCard`: with the handler deleted from the component the
10
+ * card rendered as an inert `View`, and the test still reported exactly one
11
+ * call. Green, and measuring nothing. Nine suites had the same shape.
12
+ *
13
+ * Asserting the host node's own `onPress` FIRST closes both halves:
14
+ *
15
+ * - a component that installs no handler at all fails here, and
16
+ * - a component that installs one which drops the caller's fails on the call
17
+ * count, because `fireEvent` now finds a handler AT the node and never walks
18
+ * past it to the test's own JSX.
19
+ *
20
+ * `host` must therefore be the node the component itself made pressable — the
21
+ * one carrying its `testID` / `accessibilityLabel` — not a `Text` deep inside
22
+ * it, or the walk-up is back and so is the hole.
23
+ */
24
+ import { fireEvent } from '@testing-library/react-native';
25
+ import type { ReactTestInstance } from 'react-test-renderer';
26
+
27
+ export function pressHost(host: ReactTestInstance): void {
28
+ expect(typeof host.props.onPress).toBe('function');
29
+ fireEvent.press(host);
30
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Reading what actually landed on a rendered node — shared by every suite that
3
+ * asserts the "className lands on the node the parent lays out" rule.
4
+ *
5
+ * Three components had the same two-node defect (`Button`, then `Fab` and
6
+ * `FrostedIconButton`), and asserting it needs the same two things each time:
7
+ * the HOST tree (composites do not appear in `toJSON()`, so a wrapper is only
8
+ * visible there) and a deep flatten of the `style` prop, because react-native-css
9
+ * merges a caller's class in as a `{ $$css: true, className }` descriptor
10
+ * ALONGSIDE the style objects rather than in a prop of its own.
11
+ */
12
+ import type { ReactTestRendererJSON } from 'react-test-renderer';
13
+
14
+ export type StyleEntry = Record<string, unknown>;
15
+
16
+ export interface HostNode {
17
+ type: string;
18
+ props: Record<string, unknown>;
19
+ children: Array<HostNode | string> | null;
20
+ }
21
+
22
+ /**
23
+ * Deep-flatten an RN `style` prop (nested arrays, `false`/`null` holes) into the
24
+ * list of style objects that actually reached the node.
25
+ */
26
+ export function styleEntries(style: unknown, out: StyleEntry[] = []): StyleEntry[] {
27
+ if (!style || typeof style !== 'object') return out;
28
+ if (Array.isArray(style)) {
29
+ for (const entry of style) styleEntries(entry, out);
30
+ return out;
31
+ }
32
+ out.push(style as StyleEntry);
33
+ return out;
34
+ }
35
+
36
+ /** Every style key/value that landed on a node, later entries winning. */
37
+ export function resolvedStyle(style: unknown): StyleEntry {
38
+ return Object.assign({}, ...styleEntries(style)) as StyleEntry;
39
+ }
40
+
41
+ /** The class tokens react-native-css accepted for a node, in arrival order. */
42
+ export function classNamesOn(style: unknown): string[] {
43
+ return styleEntries(style)
44
+ .filter((entry) => entry.$$css === true && typeof entry.className === 'string')
45
+ .map((entry) => String(entry.className));
46
+ }
47
+
48
+ export function isHostNode(value: unknown): value is HostNode {
49
+ return (
50
+ typeof value === 'object' && value !== null && typeof (value as HostNode).type === 'string'
51
+ );
52
+ }
53
+
54
+ /** Walk the HOST tree (composites are absent from `toJSON()`) for a testID. */
55
+ export function findHost(
56
+ node: ReactTestRendererJSON | ReactTestRendererJSON[] | unknown,
57
+ testID: string,
58
+ ): HostNode | null {
59
+ if (Array.isArray(node)) {
60
+ for (const child of node) {
61
+ const hit = findHost(child, testID);
62
+ if (hit) return hit;
63
+ }
64
+ return null;
65
+ }
66
+ if (!isHostNode(node)) return null;
67
+ if (node.props.testID === testID) return node;
68
+ return findHost(node.children, testID);
69
+ }
70
+
71
+ /**
72
+ * Every host node in a rendered tree, in document order.
73
+ *
74
+ * The reason to walk `toJSON()` rather than `UNSAFE_root.findAll` is that the
75
+ * latter yields COMPOSITE instances too, so a `memo(fn)` component contributes
76
+ * two matches for one rendered element and every count is silently doubled —
77
+ * which reads as "the component rendered the thing twice", not as an artefact
78
+ * of the query.
79
+ */
80
+ export function hostNodes(tree: unknown, out: HostNode[] = []): HostNode[] {
81
+ if (Array.isArray(tree)) {
82
+ for (const child of tree) hostNodes(child, out);
83
+ return out;
84
+ }
85
+ if (!isHostNode(tree)) return out;
86
+ out.push(tree);
87
+ return hostNodes(tree.children, out);
88
+ }
89
+
90
+ /**
91
+ * The host nodes a parent laid out. Callers assert the LENGTH themselves —
92
+ * "exactly one" is the property, and a helper that returned only the first would
93
+ * hide the second.
94
+ */
95
+ export function renderedChildren(tree: unknown, hostTestID: string): HostNode[] {
96
+ const host = findHost(tree, hostTestID);
97
+ if (host === null) throw new Error(`no host rendered for testID "${hostTestID}"`);
98
+ return (host.children ?? []).filter(isHostNode);
99
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A module that DELIBERATELY throws away one of the values a hook hands back, so
3
+ * `hook-subscriptions-are-read.test.ts` can prove its detector fires. It carries
4
+ * both halves of the control: `unreadFlag` is bound and never read, which the
5
+ * detector must report, and `readFlag` is bound and read, which it must stay
6
+ * quiet about — otherwise the cheapest way to green the gate would be deleting a
7
+ * binding that is doing its job.
8
+ *
9
+ * Nothing imports this at runtime. bob's `exclude` keeps it out of `lib/`, and
10
+ * `files: ["src", …]` does ship it inside the tarball's `src/` like the other
11
+ * 137 test files there — but no entry point reaches it, so no bundler links it.
12
+ * It is a plain `.ts` with no React import because the
13
+ * detector reads source, never types: a call named `use…` bound to a name is all
14
+ * the shape there is.
15
+ *
16
+ * The destructuring RENAMES, which is not decoration — it reproduces the shipped
17
+ * shape (`const { state: pressed, onIn: onPressIn } = useInteractionState()`) and
18
+ * it is what keeps the control honest. A hook declared in the same file as its
19
+ * caller mentions its own property names in its return type and its return
20
+ * object, and the detector counts every occurrence of a spelling as a possible
21
+ * read, so an unrenamed fixture would be silently unreportable for a reason no
22
+ * real call site has.
23
+ */
24
+
25
+ /** Stands in for a real subscription — a hook by name, with two return values. */
26
+ function useFixtureInteractionState(): { first: boolean; second: boolean } {
27
+ return { first: false, second: false };
28
+ }
29
+
30
+ export function fixtureComponentBody(): boolean {
31
+ const { first: readFlag, second: unreadFlag } = useFixtureInteractionState();
32
+ return readFlag;
33
+ }
@@ -27,6 +27,7 @@ import Animated, {
27
27
  withTiming,
28
28
  } from 'react-native-reanimated';
29
29
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
30
+ import { EDGE_GAP, windowEdgeGap } from '../layout/edge';
30
31
  import { Backdrop, BACKDROP_DIM_OPACITY } from '../overlay';
31
32
  import { useTheme } from '../theme/use-theme';
32
33
  import type { BottomSheetRef, BottomSheetProps, BottomSheetShellProps, BottomSheetBaseProps } from './types';
@@ -526,21 +527,30 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
526
527
  };
527
528
  }, [translateY, screenHeightSV, keyboardHeight]);
528
529
 
530
+ // The shared window-edge rule, resolved on the JS thread: `windowEdgeGap` is
531
+ // not a worklet, so the worklets below capture the number rather than call it.
532
+ //
533
+ // A detached sheet deliberately does NOT read `useBottomEdgeInset()`. It is a
534
+ // modal surface rendered in the portal ABOVE whatever owns the bottom edge and
535
+ // it covers that surface — offsetting it by a tab bar it is already hiding
536
+ // would float it off the bottom of the screen for nothing.
537
+ const detachedGap = detached ? windowEdgeGap(insets.bottom, EDGE_GAP) : 0;
538
+
529
539
  const sheetHeightStyle = useAnimatedStyle(() => ({
530
- maxHeight: screenHeightSV.value - keyboardHeight.value - insets.top - (detached ? insets.bottom + 16 : 0),
531
- }), [insets.top, insets.bottom, detached, screenHeightSV, keyboardHeight]);
540
+ maxHeight: screenHeightSV.value - keyboardHeight.value - insets.top - detachedGap,
541
+ }), [insets.top, detachedGap, screenHeightSV, keyboardHeight]);
532
542
 
533
543
  const sheetMarginStyle = useAnimatedStyle(() => {
534
544
  // Only add margin when detached, otherwise extend behind safe area
535
545
  if (detached) {
536
546
  return {
537
- marginBottom: keyboardHeight.value > 0 ? 16 : insets.bottom + 16,
547
+ marginBottom: keyboardHeight.value > 0 ? EDGE_GAP : detachedGap,
538
548
  };
539
549
  }
540
550
  return {
541
551
  marginBottom: 0,
542
552
  };
543
- }, [insets.bottom, detached, keyboardHeight]);
553
+ }, [detachedGap, detached, keyboardHeight]);
544
554
 
545
555
  const handleBackdropPress = useCallback(() => {
546
556
  // Always animate close on backdrop press
package/src/fab/Fab.tsx CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from 'react-native';
12
12
  import { styled } from 'react-native-css';
13
13
 
14
+ import { useBottomEdgeInset } from '../layout/bottom-edge';
14
15
  import { useTheme } from '../theme/use-theme';
15
16
  import { animation, borderRadius } from '../styles/tokens';
16
17
  import { bloomShadowStyle } from '../design-tokens/shadows';
@@ -65,12 +66,23 @@ const HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const;
65
66
  * the nearest positioned ancestor (the consumer's column container), NOT the
66
67
  * screen — so it never escapes a constrained layout. `static` returns no
67
68
  * positioning (consumer-controlled).
69
+ *
70
+ * `bottomEdgeInset` applies to the BOTTOM axis only, and only for a bottom
71
+ * placement. It is not a gap preference — `offset` is that — but the height of
72
+ * whatever floating surface has already claimed the bottom edge, so the FAB
73
+ * lands above a floating tab bar instead of behind it. A z-index cannot fix that
74
+ * pairing: the bar's host is the last sibling of the app shell and paints over
75
+ * every descendant, so the FAB has to be somewhere else, not merely on top.
68
76
  */
69
- function placementStyle(placement: FabPlacement, offset: number): ViewStyle {
77
+ function placementStyle(
78
+ placement: FabPlacement,
79
+ offset: number,
80
+ bottomEdgeInset: number,
81
+ ): ViewStyle {
70
82
  if (placement === 'static') return {};
71
83
  const style: ViewStyle = { position: 'absolute' };
72
84
  if (placement === 'bottom-right' || placement === 'bottom-left') {
73
- style.bottom = offset;
85
+ style.bottom = offset + bottomEdgeInset;
74
86
  } else {
75
87
  style.top = offset;
76
88
  }
@@ -145,6 +157,7 @@ const FabComponent: React.FC<FabProps> = ({
145
157
  testID,
146
158
  zIndex = DEFAULT_Z_INDEX,
147
159
  }) => {
160
+ const bottomEdgeInset = useBottomEdgeInset();
148
161
  const theme = useTheme();
149
162
  const sizeConfig = useMemo(() => resolveSize(size), [size]);
150
163
  const isExtended = label != null && label.length > 0;
@@ -213,7 +226,7 @@ const FabComponent: React.FC<FabProps> = ({
213
226
  <AnimatedPressable
214
227
  className={className}
215
228
  style={[
216
- placementStyle(placement, offset),
229
+ placementStyle(placement, offset, bottomEdgeInset),
217
230
  { zIndex },
218
231
  containerStyle,
219
232
  disabled && { opacity: 0.5 },
@@ -7,6 +7,7 @@ import React, {
7
7
  type MouseEvent,
8
8
  } from 'react';
9
9
 
10
+ import { useBottomEdgeInset } from '../layout/bottom-edge';
10
11
  import { useTheme } from '../theme/use-theme';
11
12
  import { animation, borderRadius } from '../styles/tokens';
12
13
  import { pressedSurface } from '../theme/press-colors';
@@ -121,14 +122,24 @@ const BLOOM_FAB_CSS = interactiveWebCss({
121
122
  * Horizontal placement uses `align-self` (sticky elements can't be pushed by
122
123
  * `left`/`right` the way absolute ones are) plus an inline-edge margin for the
123
124
  * `offset`. The consumer column MUST be a flex column that fills the available
124
- * height, and the FAB MUST be its last child — documented in the usage story.
125
+ * height, and the FAB MUST be its last child — documented in the usage story. *
126
+ * `bottomEdgeInset` applies to the BOTTOM axis only. It is not a gap preference
127
+ * — `offset` is that — but the height of whatever floating surface has already
128
+ * claimed the bottom edge, so the FAB lands above a floating tab bar instead of
129
+ * behind it. A z-index cannot fix that pairing: the bar's host is the last
130
+ * sibling of the app shell and paints over every descendant, so the FAB has to be
131
+ * somewhere else, not merely on top.
125
132
  */
126
- function placementStyle(placement: FabPlacement, offset: number): CSSProperties {
133
+ function placementStyle(
134
+ placement: FabPlacement,
135
+ offset: number,
136
+ bottomEdgeInset: number,
137
+ ): CSSProperties {
127
138
  if (placement === 'static') return {};
128
139
  const style: CSSProperties = { position: 'sticky' };
129
140
  const isBottom = placement === 'bottom-right' || placement === 'bottom-left';
130
141
  if (isBottom) {
131
- style.bottom = offset;
142
+ style.bottom = offset + bottomEdgeInset;
132
143
  // Push the FAB to the bottom of a (flex-column) container even when the
133
144
  // content is too short to scroll, so it never floats mid-column.
134
145
  style.marginTop = 'auto';
@@ -186,6 +197,7 @@ const FabWebComponent: React.FC<FabProps> = ({
186
197
  type = 'button',
187
198
  }) => {
188
199
  useInteractiveWebCss(STYLE_ID, BLOOM_FAB_CSS);
200
+ const bottomEdgeInset = useBottomEdgeInset();
189
201
  const theme = useTheme();
190
202
  const reactId = useId();
191
203
  const resolvedId = id ?? `bloom-fab-${reactId}`;
@@ -220,7 +232,7 @@ const FabWebComponent: React.FC<FabProps> = ({
220
232
  variantColors.foreground,
221
233
  ),
222
234
  ['--bloom-fab-press-scale' as string]: animation.pressScale,
223
- ...placementStyle(placement, offset),
235
+ ...placementStyle(placement, offset, bottomEdgeInset),
224
236
  };
225
237
  if (isExtended) {
226
238
  const pad = sizeConfig.diameter <= 44 ? 14 : 20;
package/src/index.ts CHANGED
@@ -153,6 +153,13 @@ export type {
153
153
  SurfacePromptOptions,
154
154
  } from './surfaces';
155
155
  export * from './button';
156
+ export {
157
+ EDGE_GAP,
158
+ windowEdgeGap,
159
+ BottomEdgeProvider,
160
+ useBottomEdgeInset,
161
+ useClaimBottomEdge,
162
+ } from './layout';
156
163
  export { Fab } from './fab';
157
164
  export type { FabProps, FabVariant, FabSize, FabPlacement } from './fab';
158
165
  export { FrostedIconButton } from './frosted-icon-button';