@oxyhq/bloom 0.79.1 → 0.80.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 (52) hide show
  1. package/lib/commonjs/scroll/index.web.js +52 -18
  2. package/lib/commonjs/scroll/index.web.js.map +1 -1
  3. package/lib/commonjs/scroll/scrollable.web.js +9 -3
  4. package/lib/commonjs/scroll/scrollable.web.js.map +1 -1
  5. package/lib/commonjs/tabs/Tabs.js +263 -63
  6. package/lib/commonjs/tabs/Tabs.js.map +1 -1
  7. package/lib/commonjs/tabs/expo-router/RouterTabs.js +215 -0
  8. package/lib/commonjs/tabs/expo-router/RouterTabs.js.map +1 -0
  9. package/lib/commonjs/tabs/expo-router/index.js +13 -0
  10. package/lib/commonjs/tabs/expo-router/index.js.map +1 -0
  11. package/lib/module/scroll/index.web.js +52 -18
  12. package/lib/module/scroll/index.web.js.map +1 -1
  13. package/lib/module/scroll/scrollable.web.js +9 -3
  14. package/lib/module/scroll/scrollable.web.js.map +1 -1
  15. package/lib/module/tabs/Tabs.js +271 -65
  16. package/lib/module/tabs/Tabs.js.map +1 -1
  17. package/lib/module/tabs/expo-router/RouterTabs.js +210 -0
  18. package/lib/module/tabs/expo-router/RouterTabs.js.map +1 -0
  19. package/lib/module/tabs/expo-router/index.js +10 -0
  20. package/lib/module/tabs/expo-router/index.js.map +1 -0
  21. package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts +18 -5
  23. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/tabs/Tabs.d.ts +41 -1
  25. package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts +80 -0
  27. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  28. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts +9 -0
  29. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts.map +1 -0
  30. package/lib/typescript/commonjs/tabs/types.d.ts +48 -4
  31. package/lib/typescript/commonjs/tabs/types.d.ts.map +1 -1
  32. package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
  33. package/lib/typescript/module/scroll/scrollable.web.d.ts +18 -5
  34. package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -1
  35. package/lib/typescript/module/tabs/Tabs.d.ts +41 -1
  36. package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
  37. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts +80 -0
  38. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  39. package/lib/typescript/module/tabs/expo-router/index.d.ts +9 -0
  40. package/lib/typescript/module/tabs/expo-router/index.d.ts.map +1 -0
  41. package/lib/typescript/module/tabs/types.d.ts +48 -4
  42. package/lib/typescript/module/tabs/types.d.ts.map +1 -1
  43. package/package.json +15 -1
  44. package/src/__tests__/Tabs.test.tsx +187 -0
  45. package/src/__tests__/optional-peer-imports.test.ts +5 -0
  46. package/src/__tests__/scroll-web.test.tsx +255 -2
  47. package/src/scroll/index.web.tsx +55 -19
  48. package/src/scroll/scrollable.web.ts +30 -8
  49. package/src/tabs/Tabs.tsx +352 -77
  50. package/src/tabs/expo-router/RouterTabs.tsx +268 -0
  51. package/src/tabs/expo-router/index.ts +8 -0
  52. package/src/tabs/types.ts +48 -4
@@ -0,0 +1,80 @@
1
+ /**
2
+ * expo-router bindings for the in-screen tab strip.
3
+ *
4
+ * This subpath (`@oxyhq/bloom/tabs/expo-router`) is the ONLY place in the tabs
5
+ * feature allowed to import `expo-router`, which is why it is a separate entry:
6
+ * the core `@oxyhq/bloom/tabs` stays usable in an app with a different router,
7
+ * or none, and the optional peer is never pulled into a bundle that does not
8
+ * ask for it. Same split as `tab-bar` / `tab-bar/expo-router` and
9
+ * `scroll` / `scroll/expo-router`.
10
+ *
11
+ * Everything routing-shaped lives here on purpose: which tab the route means,
12
+ * what a committed swipe navigates to, and which links to warm. The strip
13
+ * itself stays presentational and knows only about measured geometry.
14
+ */
15
+ import { type ReactNode } from 'react';
16
+ import { type Href } from 'expo-router';
17
+ import type { TabsProps } from '../types';
18
+ export interface RouterTabItem {
19
+ /** Stable id for the tab. Used as a React key and a callback argument. */
20
+ value: string;
21
+ /** Tab label text. */
22
+ label: string;
23
+ /** The route this tab shows. Must be the tab's EXACT path. */
24
+ href: Href;
25
+ /** Icon rendered before the label. */
26
+ icon?: ReactNode;
27
+ /** Optional tally beside the label; `0` or omitted renders nothing. */
28
+ count?: number;
29
+ disabled?: boolean;
30
+ }
31
+ export type RouterTabsProps = Omit<TabsProps, 'value' | 'onValueChange' | 'hasSelection' | 'children'> & {
32
+ items: RouterTabItem[];
33
+ /**
34
+ * Called instead of navigating when the ALREADY-active tab is pressed.
35
+ *
36
+ * Without it a re-tap would push a second history entry for the route the
37
+ * user is already on, so Back would appear to do nothing. Re-tapping the
38
+ * active tab conventionally means "refresh" or "scroll to top", and that is
39
+ * the caller's to define.
40
+ */
41
+ onReselect?: (value: string) => void;
42
+ /**
43
+ * Enable the horizontal swipe between neighbouring tabs. Defaults to `true`.
44
+ *
45
+ * The gesture is INDICATOR-LED: the underline tracks the finger toward the
46
+ * neighbour and commits on release past a threshold, while the content stays
47
+ * where it is. That asymmetry is deliberate — the underline has somewhere
48
+ * real to go and can complete its journey, whereas under a `<Slot/>` there is
49
+ * no neighbouring screen mounted to bring in, so translating the content
50
+ * would only open a blank gutter on the trailing edge.
51
+ */
52
+ swipeEnabled?: boolean;
53
+ };
54
+ /**
55
+ * A tab strip whose selection IS the route.
56
+ *
57
+ * Render it in the LAYOUT that owns the tabs, above the `<Slot/>` that renders
58
+ * them — not inside the tab screens themselves. That placement is the whole
59
+ * point, and it is load-bearing twice over: a strip inside the screens is
60
+ * unmounted and rebuilt by every navigation, so its underline has nothing to
61
+ * animate from and the chrome blanks while the incoming route's chunk loads;
62
+ * and a GESTURE inside the screens would be destroyed by the very navigation it
63
+ * commits, mid-release.
64
+ *
65
+ * ```tsx
66
+ * // app/(app)/[username]/_layout.tsx
67
+ * <RouterTabs
68
+ * items={[
69
+ * { value: 'posts', label: 'Posts', href: `/${username}` },
70
+ * { value: 'replies', label: 'Replies', href: `/${username}/replies` },
71
+ * ]}
72
+ * />
73
+ * <Slot />
74
+ * ```
75
+ */
76
+ export declare function RouterTabs({ items, onReselect, swipeEnabled, ...tabsProps }: RouterTabsProps): import("react/jsx-runtime").JSX.Element;
77
+ export declare namespace RouterTabs {
78
+ var displayName: string;
79
+ }
80
+ //# sourceMappingURL=RouterTabs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RouterTabs.d.ts","sourceRoot":"","sources":["../../../../../src/tabs/expo-router/RouterTabs.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAA2C,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAGhF,OAAO,EAAuB,KAAK,IAAI,EAAE,MAAM,aAAa,CAAC;AAG7D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAE1C,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,KAAK,EAAE,MAAM,CAAC;IACd,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,8DAA8D;IAC9D,IAAI,EAAE,IAAI,CAAC;IACX,sCAAsC;IACtC,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,uEAAuE;IACvE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,MAAM,eAAe,GAAG,IAAI,CAChC,SAAS,EAGT,OAAO,GAAG,eAAe,GAAG,cAAc,GAAG,UAAU,CACxD,GAAG;IACF,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,CAAC;AAuCF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,UAAU,EACV,YAAmB,EACnB,GAAG,SAAS,EACb,EAAE,eAAe,2CAwIjB;yBA7Ie,UAAU"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Entry for `@oxyhq/bloom/tabs/expo-router`. The component lives in a sibling
3
+ * module because this file is the published entry path and must stay stable;
4
+ * the split also keeps the router binding in one place, which is what the
5
+ * optional-peer allowlist asserts about.
6
+ */
7
+ export { RouterTabs } from './RouterTabs';
8
+ export type { RouterTabItem, RouterTabsProps } from './RouterTabs';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/tabs/expo-router/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC"}
@@ -1,10 +1,38 @@
1
1
  import type { StyleProp, ViewStyle, TextStyle } from 'react-native';
2
2
  export type TabsVariant = 'underline' | 'filled' | 'outlined';
3
3
  export interface TabsProps {
4
- /** The currently selected tab value. */
5
- value: string;
6
- /** Called when a tab is selected. */
7
- onValueChange: (value: string) => void;
4
+ /**
5
+ * The currently selected tab value — the CONTROLLED path, where the bar owns
6
+ * the underline and reports presses through {@link TabsProps.onValueChange}.
7
+ *
8
+ * OMIT it for the focus-driven path, where each trigger supplies its own
9
+ * {@link TabsTriggerProps.isFocused} and performs its own navigation. That is
10
+ * how `@oxyhq/bloom/tabs/expo-router` keeps the underline correct through
11
+ * deep links, browser Back and back gestures — none of which pass through a
12
+ * press handler. Providing both would put two writers on one shared value.
13
+ */
14
+ value?: string;
15
+ /** Called when a tab is selected. Controlled path only. */
16
+ onValueChange?: (value: string) => void;
17
+ /**
18
+ * Focus-driven path only: whether ANY trigger is currently focused.
19
+ *
20
+ * `false` fades the underline out where it stands. This is not the same as
21
+ * "the selection moved", and it is not hypothetical: a strip rendered by a
22
+ * LAYOUT keeps rendering while the layout's other, non-tab children are
23
+ * showing — a profile's `/followers` sits beside its tab routes under one
24
+ * layout — and without this the underline stays parked on whichever tab was
25
+ * last visited, asserting a selection that is not there.
26
+ *
27
+ * Fading is the whole response; the underline is deliberately NOT moved,
28
+ * because there is no position that means "nowhere" and travelling to a
29
+ * sentinel would drag it across every tab on the way out.
30
+ *
31
+ * `RouterTabs` sets it, so an app author never passes it by hand. Defaults to
32
+ * `true`, which is correct for the controlled path (a `value` naming no
33
+ * trigger simply leaves the underline where it was, as it always has).
34
+ */
35
+ hasSelection?: boolean;
8
36
  /** Visual variant for the tab bar. */
9
37
  variant?: TabsVariant;
10
38
  /**
@@ -26,8 +54,24 @@ export interface TabsTriggerProps {
26
54
  label: string;
27
55
  /** Icon rendered before the label. */
28
56
  icon?: React.ReactNode;
57
+ /**
58
+ * Optional tally rendered as a small muted number beside the label. Omitted
59
+ * (or `0`) renders nothing. The underline tracks the trigger's measured
60
+ * width, so a count never changes where it sits.
61
+ */
62
+ count?: number;
63
+ /**
64
+ * Whether the ROUTER considers this tab focused — the focus-driven path.
65
+ * Supplying it makes this trigger, not the bar, the writer of the shared
66
+ * underline, and suppresses {@link TabsProps.onValueChange} on press because
67
+ * navigation is then the caller's job. Leave it `undefined` on the controlled
68
+ * path.
69
+ */
70
+ isFocused?: boolean;
29
71
  /** Whether this tab is disabled. */
30
72
  disabled?: boolean;
73
+ /** Extra press handler. Runs on both paths, before any selection is reported. */
74
+ onPress?: () => void;
31
75
  style?: StyleProp<ViewStyle>;
32
76
  textStyle?: StyleProp<TextStyle>;
33
77
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/tabs/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEpE,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,QAAQ,GAAG,UAAU,CAAC;AAE9D,MAAM,WAAW,SAAS;IACxB,wCAAwC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,qCAAqC;IACrC,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,sCAAsC;IACtC,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,qDAAqD;IACrD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,KAAK,EAAE,MAAM,CAAC;IACd,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,qBAAqB;IACrB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/tabs/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEpE,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,QAAQ,GAAG,UAAU,CAAC;AAE9D,MAAM,WAAW,SAAS;IACxB;;;;;;;;;OASG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2DAA2D;IAC3D,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;;;;;;;;;;;;;;OAiBG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,sCAAsC;IACtC,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,qDAAqD;IACrD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,KAAK,EAAE,MAAM,CAAC;IACd,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,qBAAqB;IACrB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "0.79.1",
3
+ "version": "0.80.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",
@@ -1154,6 +1154,20 @@
1154
1154
  "default": "./lib/commonjs/tabs/index.js"
1155
1155
  }
1156
1156
  },
1157
+ "./tabs/expo-router": {
1158
+ "react-native": {
1159
+ "types": "./lib/typescript/module/tabs/expo-router/index.d.ts",
1160
+ "default": "./src/tabs/expo-router/index.ts"
1161
+ },
1162
+ "import": {
1163
+ "types": "./lib/typescript/module/tabs/expo-router/index.d.ts",
1164
+ "default": "./lib/module/tabs/expo-router/index.js"
1165
+ },
1166
+ "require": {
1167
+ "types": "./lib/typescript/commonjs/tabs/expo-router/index.d.ts",
1168
+ "default": "./lib/commonjs/tabs/expo-router/index.js"
1169
+ }
1170
+ },
1157
1171
  "./checkbox": {
1158
1172
  "react-native": {
1159
1173
  "types": "./lib/typescript/module/checkbox/index.d.ts",
@@ -107,4 +107,191 @@ describe('Tabs', () => {
107
107
  triggerFor(getByText('Second')).props.accessibilityState.selected,
108
108
  ).toBe(true);
109
109
  });
110
+
111
+ /**
112
+ * The shape a real consumer outside this repo ships, spelled out so a later
113
+ * refactor of the controlled path cannot break it silently. OxyPay's wallet
114
+ * screen (`packages/frontend/app/(tabs)/index.tsx`) renders exactly this: a
115
+ * controlled `value` + `onValueChange`, `variant="underline"`, a `style`
116
+ * override zeroing the bottom border, and label-only triggers.
117
+ *
118
+ * A Bloom bump reaches it, and it has no test of its own here — so this is
119
+ * the only thing standing between that screen and a silent regression.
120
+ */
121
+ it('supports the controlled call shape a published consumer uses', () => {
122
+ const onValueChange = jest.fn();
123
+ const { getByText, getByTestId } = renderWithTheme(
124
+ <Tabs
125
+ value="overview"
126
+ onValueChange={onValueChange}
127
+ variant="underline"
128
+ style={{ borderBottomWidth: 0 }}
129
+ testID="tabs"
130
+ >
131
+ <TabsTrigger value="overview" label="Overview" />
132
+ <TabsTrigger value="activity" label="Activity" />
133
+ </Tabs>,
134
+ );
135
+
136
+ expect(getByTestId('tabs-indicator')).toBeTruthy();
137
+ expect(triggerFor(getByText('Overview')).props.accessibilityState.selected).toBe(true);
138
+ fireEvent.press(getByText('Activity'));
139
+ expect(onValueChange).toHaveBeenCalledWith('activity');
140
+ });
141
+
142
+ describe('focus-driven path (router adapter)', () => {
143
+ it('takes selection from isFocused rather than the bar value', () => {
144
+ // No `value` at all — the router owns the selection.
145
+ const { getByText } = renderWithTheme(
146
+ <Tabs testID="tabs">
147
+ <TabsTrigger value="a" label="First" isFocused={false} />
148
+ <TabsTrigger value="b" label="Second" isFocused />
149
+ </Tabs>,
150
+ );
151
+ expect(triggerFor(getByText('First')).props.accessibilityState.selected).toBe(false);
152
+ expect(triggerFor(getByText('Second')).props.accessibilityState.selected).toBe(true);
153
+ });
154
+
155
+ it('does NOT report a selection on press, because the caller navigates', () => {
156
+ // Two writers on one underline is the bug this prevents: the router
157
+ // moves it via `isFocused`, so the bar must not also move it on press.
158
+ const onValueChange = jest.fn();
159
+ const onPress = jest.fn();
160
+ const { getByText } = renderWithTheme(
161
+ <Tabs onValueChange={onValueChange} testID="tabs">
162
+ <TabsTrigger value="a" label="First" isFocused />
163
+ <TabsTrigger value="b" label="Second" isFocused={false} onPress={onPress} />
164
+ </Tabs>,
165
+ );
166
+ fireEvent.press(getByText('Second'));
167
+ expect(onPress).toHaveBeenCalled();
168
+ expect(onValueChange).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it('still reports a selection on press when isFocused is absent', () => {
172
+ // The discriminator is `isFocused` being supplied AT ALL, not its value —
173
+ // so a controlled bar keeps working exactly as before.
174
+ const onValueChange = jest.fn();
175
+ const { getByText } = renderWithTheme(<Bar value="a" onValueChange={onValueChange} />);
176
+ fireEvent.press(getByText('Second'));
177
+ expect(onValueChange).toHaveBeenCalledWith('b');
178
+ });
179
+ });
180
+
181
+ describe('counts', () => {
182
+ it('renders a count beside the label and folds it into the a11y label', () => {
183
+ const { getByText, queryByText } = renderWithTheme(
184
+ <Tabs value="a" onValueChange={() => {}} testID="tabs">
185
+ <TabsTrigger value="a" label="First" count={3} />
186
+ <TabsTrigger value="b" label="Second" count={0} />
187
+ </Tabs>,
188
+ );
189
+ expect(getByText('3')).toBeTruthy();
190
+ // A zero count renders nothing at all rather than a literal "0".
191
+ expect(queryByText('0')).toBeNull();
192
+ expect(triggerFor(getByText('First')).props.accessibilityLabel).toBe('First, 3');
193
+ expect(triggerFor(getByText('Second')).props.accessibilityLabel).toBe('Second');
194
+ });
195
+
196
+ it('caps a large tally so it cannot stretch the tab', () => {
197
+ const { getByText } = renderWithTheme(
198
+ <Tabs value="a" onValueChange={() => {}} testID="tabs">
199
+ <TabsTrigger value="a" label="First" count={100} />
200
+ <TabsTrigger value="b" label="Second" count={99} />
201
+ </Tabs>,
202
+ );
203
+ expect(getByText('99+')).toBeTruthy();
204
+ expect(getByText('99')).toBeTruthy();
205
+ });
206
+ });
207
+
208
+ /**
209
+ * `hasSelection` — a strip that is rendered while NOTHING it lists is showing.
210
+ *
211
+ * Not hypothetical, and the reason it exists: a routed strip lives in the
212
+ * LAYOUT, and a layout renders its non-tab children too. A profile's
213
+ * `/followers` sits beside its tab routes under one layout, so on that route
214
+ * every trigger reports `isFocused={false}` and, without this, the underline
215
+ * stays parked on whichever tab was visited last — asserting a selection that
216
+ * is not there.
217
+ *
218
+ * The assertion is on OPACITY rather than position on purpose: fading is the
219
+ * whole response, and the underline must NOT travel to a sentinel, which
220
+ * would drag it across every tab on the way out.
221
+ */
222
+ describe('no selection', () => {
223
+ /**
224
+ * Drive the strip and read the underline back.
225
+ *
226
+ * The re-render after each step is load-bearing, not ceremony: the
227
+ * reanimated jest mock evaluates `useAnimatedStyle`'s mapper at RENDER time
228
+ * (`useAnimatedStyle: (fn) => fn()`), so a shared value written from a
229
+ * layout callback is not visible in the rendered style until something
230
+ * renders again. Without it every assertion here reads the mount frame and
231
+ * "no selection" and "selection" are indistinguishable.
232
+ */
233
+ function mount(props: Partial<React.ComponentProps<typeof Bar>> = {}) {
234
+ const utils = renderWithTheme(<Bar value="a" {...props} />);
235
+ const renderOnce = (next: Partial<React.ComponentProps<typeof Bar>>) =>
236
+ utils.rerender(
237
+ <BloomThemeProvider mode="light" colorPreset="teal">
238
+ <Bar value="a" {...props} {...next} />
239
+ </BloomThemeProvider>,
240
+ );
241
+ // Twice, for the same reason the mock forces a re-render at all: a prop
242
+ // change is applied by an EFFECT, which runs after the render that
243
+ // carried it, so the first pass still renders the previous value. The
244
+ // second pass has identical props, so no effect re-runs and nothing is
245
+ // written twice — it only re-evaluates the mapper.
246
+ const render = (next: Partial<React.ComponentProps<typeof Bar>>) => {
247
+ renderOnce(next);
248
+ renderOnce(next);
249
+ };
250
+ return {
251
+ ...utils,
252
+ settle: () => render({}),
253
+ update: render,
254
+ measureFirstTrigger() {
255
+ let node: ReactTestInstance | null = triggerFor(utils.getByText('First'));
256
+ while (node && node.props?.onLayout === undefined) node = node.parent;
257
+ if (!node) throw new Error('no onLayout ancestor found for the trigger');
258
+ fireEvent(node, 'layout', { nativeEvent: { layout: { x: 0, width: 80 } } });
259
+ },
260
+ opacity: () =>
261
+ flattenStyle(utils.getByTestId('tabs-indicator').props.style).opacity,
262
+ };
263
+ }
264
+
265
+ it('starts hidden so an unmeasured strip never flashes an underline at the origin', () => {
266
+ expect(mount().opacity()).toBe(0);
267
+ });
268
+
269
+ it('shows the underline once the active trigger has been measured', () => {
270
+ const bar = mount();
271
+ bar.measureFirstTrigger();
272
+ bar.settle();
273
+ expect(bar.opacity()).toBe(1);
274
+ });
275
+
276
+ it('fades the underline out when hasSelection is false', () => {
277
+ const bar = mount();
278
+ bar.measureFirstTrigger();
279
+ bar.settle();
280
+ expect(bar.opacity()).toBe(1);
281
+
282
+ bar.update({ hasSelection: false });
283
+ expect(bar.opacity()).toBe(0);
284
+ });
285
+ });
286
+
287
+ it('names the component when a trigger is rendered outside a Tabs', () => {
288
+ // Without a provider the old code read a DEFAULT context and silently did
289
+ // nothing — a strip that renders but never moves. Failing loudly is the
290
+ // point; the message has to name the component to be actionable.
291
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
292
+ expect(() =>
293
+ renderWithTheme(<TabsTrigger value="a" label="First" />),
294
+ ).toThrow(/TabsTrigger must be used within a Tabs/);
295
+ spy.mockRestore();
296
+ });
110
297
  });
@@ -82,6 +82,11 @@ const ALLOWED: { peer: string; file: string; why: string }[] = [
82
82
  file: 'scroll/expo-router/index.ts',
83
83
  why: 'the whole point of this file is to BE the expo-router binding — it is reachable only from `@oxyhq/bloom/scroll/expo-router` and from BloomProvider, both of which are expo-router-only by construction; the scroll core itself no longer imports a router (see AGENTS.md "App Root Provider")',
84
84
  },
85
+ {
86
+ peer: 'expo-router',
87
+ file: 'tabs/expo-router/RouterTabs.tsx',
88
+ why: 'same shape as the scroll binding above — this file exists to BE the expo-router binding for the tab strip and is reachable only from `@oxyhq/bloom/tabs/expo-router`; the tabs core imports no router, so a consumer on a different router (or none) still gets `@oxyhq/bloom/tabs`',
89
+ },
85
90
  ];
86
91
 
87
92
  /**