@oxyhq/bloom 0.51.1 → 0.52.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 (80) hide show
  1. package/lib/commonjs/frosted-icon-button/shared.js +10 -0
  2. package/lib/commonjs/frosted-icon-button/shared.js.map +1 -1
  3. package/lib/commonjs/tab-bar/TabBarBase.js +30 -5
  4. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  5. package/lib/commonjs/tab-bar/glyph.js +9 -4
  6. package/lib/commonjs/tab-bar/glyph.js.map +1 -1
  7. package/lib/commonjs/tab-bar/glyph.native.js +7 -2
  8. package/lib/commonjs/tab-bar/glyph.native.js.map +1 -1
  9. package/lib/commonjs/tab-bar/index.js +7 -0
  10. package/lib/commonjs/tab-bar/index.js.map +1 -1
  11. package/lib/commonjs/tab-bar/index.web.js +7 -0
  12. package/lib/commonjs/tab-bar/index.web.js.map +1 -1
  13. package/lib/commonjs/tab-bar/shared.js +32 -1
  14. package/lib/commonjs/tab-bar/shared.js.map +1 -1
  15. package/lib/commonjs/tab-bar/use-reserved-space.js +50 -0
  16. package/lib/commonjs/tab-bar/use-reserved-space.js.map +1 -0
  17. package/lib/module/frosted-icon-button/shared.js +10 -0
  18. package/lib/module/frosted-icon-button/shared.js.map +1 -1
  19. package/lib/module/tab-bar/TabBarBase.js +31 -6
  20. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  21. package/lib/module/tab-bar/glyph.js +9 -4
  22. package/lib/module/tab-bar/glyph.js.map +1 -1
  23. package/lib/module/tab-bar/glyph.native.js +7 -2
  24. package/lib/module/tab-bar/glyph.native.js.map +1 -1
  25. package/lib/module/tab-bar/index.js +1 -0
  26. package/lib/module/tab-bar/index.js.map +1 -1
  27. package/lib/module/tab-bar/index.web.js +1 -0
  28. package/lib/module/tab-bar/index.web.js.map +1 -1
  29. package/lib/module/tab-bar/shared.js +30 -0
  30. package/lib/module/tab-bar/shared.js.map +1 -1
  31. package/lib/module/tab-bar/use-reserved-space.js +46 -0
  32. package/lib/module/tab-bar/use-reserved-space.js.map +1 -0
  33. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +10 -0
  34. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/tab-bar/glyph.d.ts +1 -1
  37. package/lib/typescript/commonjs/tab-bar/glyph.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts +1 -1
  39. package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/tab-bar/index.d.ts +1 -0
  41. package/lib/typescript/commonjs/tab-bar/index.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/tab-bar/index.web.d.ts +1 -0
  43. package/lib/typescript/commonjs/tab-bar/index.web.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/tab-bar/shared.d.ts +25 -0
  45. package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/tab-bar/types.d.ts +37 -1
  47. package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts +25 -0
  49. package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts.map +1 -0
  50. package/lib/typescript/module/frosted-icon-button/shared.d.ts +10 -0
  51. package/lib/typescript/module/frosted-icon-button/shared.d.ts.map +1 -1
  52. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  53. package/lib/typescript/module/tab-bar/glyph.d.ts +1 -1
  54. package/lib/typescript/module/tab-bar/glyph.d.ts.map +1 -1
  55. package/lib/typescript/module/tab-bar/glyph.native.d.ts +1 -1
  56. package/lib/typescript/module/tab-bar/glyph.native.d.ts.map +1 -1
  57. package/lib/typescript/module/tab-bar/index.d.ts +1 -0
  58. package/lib/typescript/module/tab-bar/index.d.ts.map +1 -1
  59. package/lib/typescript/module/tab-bar/index.web.d.ts +1 -0
  60. package/lib/typescript/module/tab-bar/index.web.d.ts.map +1 -1
  61. package/lib/typescript/module/tab-bar/shared.d.ts +25 -0
  62. package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
  63. package/lib/typescript/module/tab-bar/types.d.ts +37 -1
  64. package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
  65. package/lib/typescript/module/tab-bar/use-reserved-space.d.ts +25 -0
  66. package/lib/typescript/module/tab-bar/use-reserved-space.d.ts.map +1 -0
  67. package/package.json +1 -1
  68. package/src/__tests__/TabBar.test.tsx +82 -2
  69. package/src/__tests__/TabBarLongPress.test.tsx +258 -0
  70. package/src/__tests__/TabBarReservedSpace.test.tsx +150 -0
  71. package/src/__tests__/TabBarWebFork.test.ts +1 -0
  72. package/src/frosted-icon-button/shared.ts +10 -0
  73. package/src/tab-bar/TabBarBase.tsx +39 -5
  74. package/src/tab-bar/glyph.native.tsx +5 -2
  75. package/src/tab-bar/glyph.tsx +8 -4
  76. package/src/tab-bar/index.ts +1 -0
  77. package/src/tab-bar/index.web.tsx +1 -0
  78. package/src/tab-bar/shared.ts +38 -0
  79. package/src/tab-bar/types.ts +37 -1
  80. package/src/tab-bar/use-reserved-space.ts +44 -0
@@ -8,5 +8,6 @@ export declare const TabBarButton: {
8
8
  };
9
9
  export { MINIMIZE_SPRING, setMinimized, TabBarMinimizeProvider, useMinimizeOnScroll, useMinimizeState, useTabBarMinimized, } from './minimize-context';
10
10
  export type { MinimizeState } from './minimize-context';
11
+ export { useTabBarReservedSpace } from './use-reserved-space';
11
12
  export type { TabBarButtonProps, TabBarItem, TabBarProps, TabBarTheme } from './types';
12
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/index.ts"],"names":[],"mappings":"AAmBA,eAAO,MAAM,MAAM;;;CAA+C,CAAC;AACnE,eAAO,MAAM,YAAY;;;CAAkC,CAAC;AAE5D,OAAO,EACL,eAAe,EACf,YAAY,EACZ,sBAAsB,EACtB,mBAAmB,EACnB,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxD,YAAY,EAAE,iBAAiB,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/index.ts"],"names":[],"mappings":"AAmBA,eAAO,MAAM,MAAM;;;CAA+C,CAAC;AACnE,eAAO,MAAM,YAAY;;;CAAkC,CAAC;AAE5D,OAAO,EACL,eAAe,EACf,YAAY,EACZ,sBAAsB,EACtB,mBAAmB,EACnB,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,YAAY,EAAE,iBAAiB,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
@@ -8,5 +8,6 @@ export declare const TabBarButton: {
8
8
  };
9
9
  export { MINIMIZE_SPRING, setMinimized, TabBarMinimizeProvider, useMinimizeOnScroll, useMinimizeState, useTabBarMinimized, } from './minimize-context';
10
10
  export type { MinimizeState } from './minimize-context';
11
+ export { useTabBarReservedSpace } from './use-reserved-space';
11
12
  export type { TabBarButtonProps, TabBarItem, TabBarProps, TabBarTheme } from './types';
12
13
  //# sourceMappingURL=index.web.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.web.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/index.web.tsx"],"names":[],"mappings":"AAqBA,eAAO,MAAM,MAAM;;;CAA+C,CAAC;AACnE,eAAO,MAAM,YAAY;;;CAAkC,CAAC;AAE5D,OAAO,EACL,eAAe,EACf,YAAY,EACZ,sBAAsB,EACtB,mBAAmB,EACnB,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxD,YAAY,EAAE,iBAAiB,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.web.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/index.web.tsx"],"names":[],"mappings":"AAqBA,eAAO,MAAM,MAAM;;;CAA+C,CAAC;AACnE,eAAO,MAAM,YAAY;;;CAAkC,CAAC;AAE5D,OAAO,EACL,eAAe,EACf,YAAY,EACZ,sBAAsB,EACtB,mBAAmB,EACnB,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,YAAY,EAAE,iBAAiB,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
@@ -26,6 +26,15 @@ 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 reserving layout
33
+ * space for the bar must go through `useTabBarReservedSpace` (which derives its
34
+ * 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;
29
38
  /**
30
39
  * Slide spring: interruptible by design — rapid tab-hopping retargets with
31
40
  * preserved velocity. Slight under-damping gives the pill a tiny settle, safe
@@ -35,6 +44,14 @@ export declare const SLIDE_SPRING: {
35
44
  duration: number;
36
45
  dampingRatio: number;
37
46
  };
47
+ /**
48
+ * How long a press must be held before `onIndexLongPress` fires.
49
+ *
50
+ * Comfortably above the tap gesture's own 400ms ceiling: the two can never both
51
+ * be candidates at the same instant, so arming the long press cannot cost the
52
+ * bar a tap. Matches the platform long-press feel (iOS ~0.5s).
53
+ */
54
+ export declare const LONG_PRESS_MIN_DURATION = 500;
38
55
  /**
39
56
  * Resolve the bar's five colors from Bloom's theme tokens, with an optional
40
57
  * partial override on top.
@@ -89,5 +106,13 @@ export interface TabBarGlyphProps {
89
106
  tint: string;
90
107
  /** Rendered icon size in px ({@link ICON_SIZE}). */
91
108
  size: number;
109
+ /**
110
+ * Which crossfade layer this is: `false` for the inactive one underneath,
111
+ * `true` for the one fading in on top. The active layer renders
112
+ * `item.activeIcon` when the item has one, so a set that expresses selection
113
+ * by shape (outline → filled) crossfades between two different nodes rather
114
+ * than between two tints of the same node.
115
+ */
116
+ active: boolean;
92
117
  }
93
118
  //# sourceMappingURL=shared.d.ts.map
@@ -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;AAEnC;;;;GAIG;AACH,eAAO,MAAM,YAAY;;;CAAwC,CAAC;AAMlE;;;;;;;;;;;;;;;;;;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;CACd"}
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;;;;;;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"}
@@ -25,9 +25,31 @@ export type TabBarItem = {
25
25
  * type. This is the primary icon API — same convention as `Tabs`/`TabsTrigger`
26
26
  * and `FrostedIconButton`. The bar renders it TWICE (an inactive layer with the
27
27
  * active layer crossfading on top), injecting the layer's tint as the icon's
28
- * `fill` unless the element already sets one.
28
+ * `fill` unless the element already sets one. See {@link TabBarItem.activeIcon}
29
+ * for a set whose selected state is a different shape rather than a tint.
29
30
  */
30
31
  icon: ReactNode;
32
+ /**
33
+ * Optional second node rendered by the ACTIVE crossfade layer instead of
34
+ * `icon`; without it that layer renders `icon` again, which is the original
35
+ * behaviour.
36
+ *
37
+ * For icon sets that express selection by SHAPE rather than by tint — an
38
+ * outline glyph that becomes a filled one, with a different path — a tint
39
+ * crossfade cannot say what the set says, because both layers would draw the
40
+ * same shape in two colors. Supplying the filled variant here makes the
41
+ * existing crossfade swap NODES, so the outline dissolves into the fill.
42
+ *
43
+ * It is also the escape hatch for an icon that ignores the injected tint: the
44
+ * bar tints a glyph by cloning it with a `fill` prop (see `applyIconColor`),
45
+ * so an icon set that paints from a `color` prop instead never lights up, with
46
+ * no error. Pre-color both nodes yourself and the crossfade still works.
47
+ *
48
+ * On iOS an item carrying `sfSymbol` renders the symbol on both layers and
49
+ * ignores this — a symbol is tinted natively, so the tint crossfade is the
50
+ * right expression there.
51
+ */
52
+ activeIcon?: ReactNode;
31
53
  /**
32
54
  * Optional SF Symbol name, used INSTEAD of `icon` on iOS only. A pure
33
55
  * enhancement handled solely in `glyph.native.tsx`; the neutral and web
@@ -60,6 +82,20 @@ export type TabBarProps = ViewProps & {
60
82
  * or by keyboard/assistive-technology activation of a `TabBarButton`.
61
83
  */
62
84
  onIndexChange?: (index: number) => void;
85
+ /**
86
+ * Called with the index under the finger when a tab is pressed and HELD.
87
+ *
88
+ * This is the only way to reach a long press on the bar: its gesture detector
89
+ * consumes the touches (that is what makes scrubbing possible), so a button's
90
+ * own `onLongPress` never fires for a real finger. The gesture is added to the
91
+ * bar's gesture race only when this prop is supplied — holding still for half
92
+ * a second on a bar without it keeps behaving exactly as before, and can still
93
+ * start a scrub.
94
+ *
95
+ * Long-pressing does NOT move the highlight or select the tab; it is a
96
+ * secondary action (an account switcher on the profile tab, say).
97
+ */
98
+ onIndexLongPress?: (index: number) => void;
63
99
  /** Partial override of the theme resolved from Bloom's color tokens. */
64
100
  theme?: Partial<TabBarTheme>;
65
101
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9D,MAAM,MAAM,UAAU,GAAG;IACvB,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;OASG;IACH,IAAI,EAAE,SAAS,CAAC;IAChB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,0DAA0D;IAC1D,UAAU,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG;IACpC;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAC7B;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG,cAAc,GAAG;IAC/C,IAAI,EAAE,UAAU,CAAC;IACjB,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;OASG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9D,MAAM,MAAM,UAAU,GAAG;IACvB,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;;OAUG;IACH,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,0DAA0D;IAC1D,UAAU,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG;IACpC;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;;;;;;;;;OAYG;IACH,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAC7B;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG,cAAc,GAAG;IAC/C,IAAI,EAAE,UAAU,CAAC;IACjB,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;OASG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The vertical space the tab bar occupies above the bottom of the window, in
3
+ * px. Use it directly as the bottom padding (or bottom offset) of anything that
4
+ * would otherwise sit under the bar:
5
+ *
6
+ * ```tsx
7
+ * const reserved = useTabBarReservedSpace();
8
+ *
9
+ * <Animated.ScrollView contentContainerStyle={{ paddingBottom: reserved }} />
10
+ * <Fab style={{ position: 'absolute', right: 16, bottom: reserved + 12 }} />
11
+ * ```
12
+ *
13
+ * The value ALREADY INCLUDES the bottom safe-area inset — the bar folds the
14
+ * inset into its own bottom gap, so it is measured from the true window edge,
15
+ * not from the safe area. Never write `reserved + insets.bottom`: that counts
16
+ * the home indicator twice and strands a visible band of dead space under every
17
+ * list (a 76px bar reserving 110px on an iOS device or PWA). It is the whole
18
+ * number; nothing needs adding to it.
19
+ *
20
+ * It is the EXPANDED height, deliberately. The bar minimizes while scrolling
21
+ * down and re-expands on the way up, so reserving the minimized height would
22
+ * hide the end of a list the moment the user scrolled back to it.
23
+ */
24
+ export declare function useTabBarReservedSpace(): number;
25
+ //# sourceMappingURL=use-reserved-space.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-reserved-space.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/use-reserved-space.ts"],"names":[],"mappings":"AAiBA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,CAG/C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "0.51.1",
3
+ "version": "0.52.0",
4
4
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -46,6 +46,17 @@ const ITEMS: TabBarItem[] = [
46
46
  { name: 'you', label: 'You', icon: <BareIcon id="you-icon" /> },
47
47
  ];
48
48
 
49
+ /**
50
+ * An item whose selected state is a different SHAPE, not a different tint —
51
+ * the icon-set convention (outline vs filled) that `activeIcon` exists for.
52
+ */
53
+ const SHAPE_ITEM: TabBarItem = {
54
+ name: 'home',
55
+ label: 'Home',
56
+ icon: <BareIcon id="outline-icon" />,
57
+ activeIcon: <BareIcon id="filled-icon" />,
58
+ };
59
+
49
60
  /** Captures the live `ThemeColors` so assertions compare against real tokens. */
50
61
  function ThemeProbe({ onColors }: { onColors: (colors: ThemeColors) => void }) {
51
62
  onColors(useTheme().colors);
@@ -259,6 +270,75 @@ describe('TabBar', () => {
259
270
  expect(flattenStyle(labelFor(UNSAFE_root, 'Home').props.style).color).toBe(theme.inactiveTint);
260
271
  });
261
272
 
273
+ describe('activeIcon', () => {
274
+ function ShapeBar() {
275
+ return (
276
+ <TabBar activeIndex={0}>
277
+ <TabBarButton item={SHAPE_ITEM} index={0} />
278
+ </TabBar>
279
+ );
280
+ }
281
+
282
+ it('renders the item icon underneath and activeIcon on the crossfade layer', () => {
283
+ // A tint crossfade cannot express an outline-to-filled swap: both layers
284
+ // would draw the same path in two colors. The layers must render two
285
+ // different NODES.
286
+ const { getAllByTestId } = renderWithTheme(<ShapeBar />);
287
+ expect(getAllByTestId('outline-icon')).toHaveLength(1);
288
+ expect(getAllByTestId('filled-icon')).toHaveLength(1);
289
+ });
290
+
291
+ it('puts activeIcon on the ACTIVE layer, not the inactive one', () => {
292
+ // The tint each node was cloned with is what identifies its layer — the
293
+ // crossfading layer is the one painted with `activeTint`.
294
+ const theme = readTheme();
295
+ const { getAllByTestId } = renderWithTheme(<ShapeBar />);
296
+ expect(getAllByTestId('filled-icon')[0]?.props.fill).toBe(theme.activeTint);
297
+ expect(getAllByTestId('outline-icon')[0]?.props.fill).toBe(theme.inactiveTint);
298
+ });
299
+
300
+ it('falls back to icon on both layers when the item has no activeIcon', () => {
301
+ // Full backward compatibility: an item that only carries `icon` behaves
302
+ // exactly as it did before `activeIcon` existed.
303
+ const theme = readTheme();
304
+ const { getAllByTestId } = renderWithTheme(<Bar activeIndex={0} />);
305
+ const fills = getAllByTestId('home-icon').map((node) => node.props.fill);
306
+ expect(fills).toHaveLength(2);
307
+ expect(fills).toEqual(expect.arrayContaining([theme.inactiveTint, theme.activeTint]));
308
+ });
309
+
310
+ it('is honoured by the native glyph too, per crossfade layer', () => {
311
+ const theme = readTheme();
312
+ const active = renderWithTheme(
313
+ <NativeTabBarGlyph item={SHAPE_ITEM} tint={theme.activeTint} size={21} active />,
314
+ );
315
+ expect(active.queryByTestId('filled-icon')).toBeTruthy();
316
+ expect(active.queryByTestId('outline-icon')).toBeNull();
317
+
318
+ const inactive = renderWithTheme(
319
+ <NativeTabBarGlyph item={SHAPE_ITEM} tint={theme.inactiveTint} size={21} active={false} />,
320
+ );
321
+ expect(inactive.queryByTestId('outline-icon')).toBeTruthy();
322
+ expect(inactive.queryByTestId('filled-icon')).toBeNull();
323
+ });
324
+
325
+ it('keeps the SF Symbol path on iOS, which is tinted natively', () => {
326
+ // A symbol IS tinted per layer by the system, so the tint crossfade is
327
+ // still the right expression there and `activeIcon` stays unused.
328
+ const theme = readTheme();
329
+ const { UNSAFE_root, queryByTestId } = renderWithTheme(
330
+ <NativeTabBarGlyph
331
+ item={{ ...SHAPE_ITEM, sfSymbol: 'house.fill' }}
332
+ tint={theme.activeTint}
333
+ size={21}
334
+ active
335
+ />,
336
+ );
337
+ expect(hosts(UNSAFE_root, 'SymbolView')).toHaveLength(1);
338
+ expect(queryByTestId('filled-icon')).toBeNull();
339
+ });
340
+ });
341
+
262
342
  describe('theming', () => {
263
343
  it('resolves its defaults from Bloom tokens, never a hardcoded palette', () => {
264
344
  const theme = readTheme();
@@ -322,7 +402,7 @@ describe('TabBar', () => {
322
402
  const theme = readTheme();
323
403
  const item: TabBarItem = { ...(ITEMS[0] as TabBarItem), sfSymbol: 'house.fill' };
324
404
  const { UNSAFE_root } = renderWithTheme(
325
- <NativeTabBarGlyph item={item} tint={theme.activeTint} size={21} />,
405
+ <NativeTabBarGlyph item={item} tint={theme.activeTint} size={21} active />,
326
406
  );
327
407
  const symbol = hosts(UNSAFE_root, 'SymbolView')[0];
328
408
  expect(symbol?.props.name).toBe('house.fill');
@@ -333,7 +413,7 @@ describe('TabBar', () => {
333
413
  it('falls back to the item icon when no SF Symbol is given', () => {
334
414
  const theme = readTheme();
335
415
  const { queryByTestId, UNSAFE_root } = renderWithTheme(
336
- <NativeTabBarGlyph item={ITEMS[0] as TabBarItem} tint={theme.activeTint} size={21} />,
416
+ <NativeTabBarGlyph item={ITEMS[0] as TabBarItem} tint={theme.activeTint} size={21} active />,
337
417
  );
338
418
  expect(hosts(UNSAFE_root, 'SymbolView')).toHaveLength(0);
339
419
  expect(queryByTestId('home-icon')).toBeTruthy();
@@ -0,0 +1,258 @@
1
+ import React from 'react';
2
+ import { act, render } from '@testing-library/react-native';
3
+
4
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
5
+ import { TabBar, TabBarButton } from '../tab-bar';
6
+ import type { TabBarItem } from '../tab-bar/types';
7
+
8
+ /**
9
+ * `onIndexLongPress` — the bar's only reachable long press.
10
+ *
11
+ * `TabBarButtonProps extends PressableProps`, so a consumer's `onLongPress`
12
+ * type-checks and silently does nothing: the bar's `GestureDetector` consumes
13
+ * the touches (that is what makes scrubbing possible), so the inner `Pressable`
14
+ * only ever fires for assistive technology and keyboard activation. A real held
15
+ * finger fails the tap gesture at 400ms, never activates the pan, and dies in
16
+ * `onFinalize`'s early return. The gesture below is the only path in.
17
+ *
18
+ * Like `BottomSheetGesture.test.tsx`, this file replaces the shared
19
+ * gesture-handler mock (which discards every registered callback) with a
20
+ * RECORDING one, so the gestures the bar builds can be inspected and their
21
+ * worklets invoked directly. What that CAN prove: which gestures are composed
22
+ * into the race, the duration windows they are configured with, and that the
23
+ * long-press worklet resolves the same index from the same x as the tap
24
+ * worklet. What it cannot prove is gesture-handler's own arbitration — that a
25
+ * held finger really reaches this handler on a device. The 400ms/500ms split
26
+ * asserted below is what makes that arbitration decidable rather than a race,
27
+ * and the behaviour itself is verified on a real build.
28
+ */
29
+
30
+ interface MockGestureEvent {
31
+ x: number;
32
+ }
33
+
34
+ interface MockGesture {
35
+ kind: string;
36
+ /** Values captured from the builder chain. */
37
+ minDurationMs?: number;
38
+ maxDurationMs?: number;
39
+ /** Worklets captured from the builder chain. */
40
+ start?: (event: MockGestureEvent) => void;
41
+ update?: (event: MockGestureEvent) => void;
42
+ end?: (event: MockGestureEvent, success: boolean) => void;
43
+ /** Members of a composed `Gesture.Race(…)`; empty for a leaf gesture. */
44
+ members: MockGesture[];
45
+ /** Builder chain — every method returns the same object. */
46
+ activeOffsetX: (range: number[]) => MockGesture;
47
+ failOffsetY: (range: number[]) => MockGesture;
48
+ maxDistance: (px: number) => MockGesture;
49
+ maxDuration: (ms: number) => MockGesture;
50
+ minDuration: (ms: number) => MockGesture;
51
+ onStart: (worklet: (event: MockGestureEvent) => void) => MockGesture;
52
+ onUpdate: (worklet: (event: MockGestureEvent) => void) => MockGesture;
53
+ onEnd: (worklet: (event: MockGestureEvent, success: boolean) => void) => MockGesture;
54
+ onFinalize: (worklet: () => void) => MockGesture;
55
+ }
56
+
57
+ // Every gesture built since the last reset. Must be `mock`-prefixed so the
58
+ // hoisted `jest.mock` factory may reference it.
59
+ const mockGestures: MockGesture[] = [];
60
+
61
+ jest.mock('react-native-gesture-handler', () => {
62
+ const build = (kind: string): MockGesture => {
63
+ const gesture: MockGesture = {
64
+ kind,
65
+ members: [],
66
+ activeOffsetX: () => gesture,
67
+ failOffsetY: () => gesture,
68
+ maxDistance: () => gesture,
69
+ maxDuration: (ms) => {
70
+ gesture.maxDurationMs = ms;
71
+ return gesture;
72
+ },
73
+ minDuration: (ms) => {
74
+ gesture.minDurationMs = ms;
75
+ return gesture;
76
+ },
77
+ onStart: (worklet) => {
78
+ gesture.start = worklet;
79
+ return gesture;
80
+ },
81
+ onUpdate: (worklet) => {
82
+ gesture.update = worklet;
83
+ return gesture;
84
+ },
85
+ onEnd: (worklet) => {
86
+ gesture.end = worklet;
87
+ return gesture;
88
+ },
89
+ onFinalize: () => gesture,
90
+ };
91
+ mockGestures.push(gesture);
92
+ return gesture;
93
+ };
94
+
95
+ return {
96
+ Gesture: {
97
+ Pan: () => build('Pan'),
98
+ Tap: () => build('Tap'),
99
+ LongPress: () => build('LongPress'),
100
+ Race: (...members: MockGesture[]) => {
101
+ const race = build('Race');
102
+ race.members.push(...members);
103
+ return race;
104
+ },
105
+ },
106
+ GestureDetector: ({ children }: { children: React.ReactNode }) => children,
107
+ GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => children,
108
+ };
109
+ });
110
+
111
+ const ITEMS: TabBarItem[] = [
112
+ { name: 'home', label: 'Home', icon: null },
113
+ { name: 'search', label: 'Search', icon: null },
114
+ { name: 'you', label: 'You', icon: null },
115
+ ];
116
+
117
+ /**
118
+ * Touch x positions inside the bar, in the 375pt window the react-native mock
119
+ * reports. Named by the tab they land on rather than by the geometry that puts
120
+ * them there: an edge touch belongs to the first/last tab and a centered one to
121
+ * the middle tab whatever the exact item width turns out to be.
122
+ */
123
+ const NEAR_LEFT_EDGE = 8;
124
+ const WINDOW_CENTER = 187;
125
+ const NEAR_RIGHT_EDGE = 340;
126
+
127
+ function renderBar(props: {
128
+ onIndexChange?: (index: number) => void;
129
+ onIndexLongPress?: (index: number) => void;
130
+ }) {
131
+ return render(
132
+ <BloomThemeProvider mode="light" colorPreset="teal">
133
+ <TabBar activeIndex={0} {...props}>
134
+ {ITEMS.map((item, index) => (
135
+ <TabBarButton key={item.name} item={item} index={index} />
136
+ ))}
137
+ </TabBar>
138
+ </BloomThemeProvider>,
139
+ );
140
+ }
141
+
142
+ const gesturesOfKind = (kind: string) => mockGestures.filter((gesture) => gesture.kind === kind);
143
+
144
+ /** The gesture the detector was actually given — the last race constructed. */
145
+ function race(): MockGesture {
146
+ const races = gesturesOfKind('Race');
147
+ const last = races[races.length - 1];
148
+ if (!last) throw new Error('The bar composed no gesture race');
149
+ return last;
150
+ }
151
+
152
+ function member(kind: string): MockGesture {
153
+ const found = race().members.find((gesture) => gesture.kind === kind);
154
+ if (!found) throw new Error(`No ${kind} gesture in the bar's race`);
155
+ return found;
156
+ }
157
+
158
+ /** Hold a finger at `x` until the long press activates. */
159
+ function longPressAt(x: number) {
160
+ act(() => {
161
+ member('LongPress').start?.({ x });
162
+ });
163
+ }
164
+
165
+ /** A tap that succeeded at `x`. */
166
+ function tapAt(x: number) {
167
+ act(() => {
168
+ member('Tap').end?.({ x }, true);
169
+ });
170
+ }
171
+
172
+ describe('TabBar long press', () => {
173
+ beforeEach(() => {
174
+ mockGestures.length = 0;
175
+ });
176
+
177
+ it('composes no long-press gesture when no handler is given', () => {
178
+ // An always-armed long press would cancel the pan for anyone who rests a
179
+ // finger before scrubbing — a regression for every existing consumer.
180
+ renderBar({});
181
+ expect(gesturesOfKind('LongPress')).toHaveLength(0);
182
+ expect(race().members.map((gesture) => gesture.kind)).toEqual(['Pan', 'Tap']);
183
+ });
184
+
185
+ it('adds one to the race, alongside the pan and the tap, when a handler is given', () => {
186
+ renderBar({ onIndexLongPress: jest.fn() });
187
+ expect(race().members.map((gesture) => gesture.kind)).toEqual(['Pan', 'Tap', 'LongPress']);
188
+ });
189
+
190
+ it('arms it after the tap gesture has already given up', () => {
191
+ // The two windows must not overlap, or arming a long press would sometimes
192
+ // cost the bar a tap. The tap gives up at 400ms; the long press activates
193
+ // at 500ms, so at no instant are both candidates.
194
+ renderBar({ onIndexLongPress: jest.fn() });
195
+ expect(member('LongPress').minDurationMs).toBe(500);
196
+ expect(member('Tap').maxDurationMs).toBe(400);
197
+ expect(member('Tap').maxDurationMs ?? 0).toBeLessThan(member('LongPress').minDurationMs ?? 0);
198
+ });
199
+
200
+ it('reports the index of the tab under the finger', () => {
201
+ const onIndexLongPress = jest.fn();
202
+ renderBar({ onIndexLongPress });
203
+
204
+ longPressAt(NEAR_LEFT_EDGE);
205
+ longPressAt(WINDOW_CENTER);
206
+ longPressAt(NEAR_RIGHT_EDGE);
207
+
208
+ expect(onIndexLongPress.mock.calls).toEqual([[0], [1], [2]]);
209
+ });
210
+
211
+ it('resolves the same index a tap at the same point would', () => {
212
+ // Long press, tap and scrub all share one `indexAtX` worklet; this is what
213
+ // keeps a long press from ever acting on a different tab than the one the
214
+ // finger is visibly on.
215
+ const onIndexChange = jest.fn();
216
+ const onIndexLongPress = jest.fn();
217
+ renderBar({ onIndexChange, onIndexLongPress });
218
+
219
+ for (const x of [NEAR_LEFT_EDGE, 100, WINDOW_CENTER, 250, NEAR_RIGHT_EDGE]) {
220
+ tapAt(x);
221
+ longPressAt(x);
222
+ }
223
+
224
+ expect(onIndexLongPress.mock.calls).toEqual(onIndexChange.mock.calls);
225
+ });
226
+
227
+ it('does not select the tab it long-presses', () => {
228
+ // It is a secondary action (an account sheet, say) — navigating as well
229
+ // would take the user off the screen they opened it from.
230
+ const onIndexChange = jest.fn();
231
+ renderBar({ onIndexChange, onIndexLongPress: jest.fn() });
232
+
233
+ longPressAt(NEAR_RIGHT_EDGE);
234
+
235
+ expect(onIndexChange).not.toHaveBeenCalled();
236
+ });
237
+
238
+ it('still selects on a short tap while the long press is armed', () => {
239
+ const onIndexChange = jest.fn();
240
+ const onIndexLongPress = jest.fn();
241
+ renderBar({ onIndexChange, onIndexLongPress });
242
+
243
+ tapAt(NEAR_RIGHT_EDGE);
244
+
245
+ expect(onIndexChange).toHaveBeenCalledTimes(1);
246
+ expect(onIndexChange).toHaveBeenCalledWith(2);
247
+ expect(onIndexLongPress).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it('keeps the scrub gesture in the race', () => {
251
+ // The pan is what makes the highlight scrubbable; a long press must not
252
+ // have displaced it.
253
+ renderBar({ onIndexLongPress: jest.fn() });
254
+ // Its scrub worklet, not merely a pan object: this is the handler that
255
+ // drags the highlight under the finger.
256
+ expect(typeof member('Pan').update).toBe('function');
257
+ });
258
+ });