@oxyhq/bloom 0.24.0 → 0.25.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 (43) hide show
  1. package/lib/commonjs/link-preview/LinkPreviewCard.js +9 -1
  2. package/lib/commonjs/link-preview/LinkPreviewCard.js.map +1 -1
  3. package/lib/commonjs/list/index.js +94 -0
  4. package/lib/commonjs/list/index.js.map +1 -0
  5. package/lib/commonjs/list/index.web.js +216 -0
  6. package/lib/commonjs/list/index.web.js.map +1 -0
  7. package/lib/commonjs/list/types.js +6 -0
  8. package/lib/commonjs/list/types.js.map +1 -0
  9. package/lib/module/link-preview/LinkPreviewCard.js +9 -1
  10. package/lib/module/link-preview/LinkPreviewCard.js.map +1 -1
  11. package/lib/module/list/index.js +89 -0
  12. package/lib/module/list/index.js.map +1 -0
  13. package/lib/module/list/index.web.js +211 -0
  14. package/lib/module/list/index.web.js.map +1 -0
  15. package/lib/module/list/types.js +4 -0
  16. package/lib/module/list/types.js.map +1 -0
  17. package/lib/typescript/commonjs/link-preview/LinkPreviewCard.d.ts.map +1 -1
  18. package/lib/typescript/commonjs/link-preview/types.d.ts +12 -0
  19. package/lib/typescript/commonjs/link-preview/types.d.ts.map +1 -1
  20. package/lib/typescript/commonjs/list/index.d.ts +29 -0
  21. package/lib/typescript/commonjs/list/index.d.ts.map +1 -0
  22. package/lib/typescript/commonjs/list/index.web.d.ts +45 -0
  23. package/lib/typescript/commonjs/list/index.web.d.ts.map +1 -0
  24. package/lib/typescript/commonjs/list/types.d.ts +77 -0
  25. package/lib/typescript/commonjs/list/types.d.ts.map +1 -0
  26. package/lib/typescript/module/link-preview/LinkPreviewCard.d.ts.map +1 -1
  27. package/lib/typescript/module/link-preview/types.d.ts +12 -0
  28. package/lib/typescript/module/link-preview/types.d.ts.map +1 -1
  29. package/lib/typescript/module/list/index.d.ts +29 -0
  30. package/lib/typescript/module/list/index.d.ts.map +1 -0
  31. package/lib/typescript/module/list/index.web.d.ts +45 -0
  32. package/lib/typescript/module/list/index.web.d.ts.map +1 -0
  33. package/lib/typescript/module/list/types.d.ts +77 -0
  34. package/lib/typescript/module/list/types.d.ts.map +1 -0
  35. package/package.json +20 -1
  36. package/src/__tests__/LinkPreviewCard.test.tsx +29 -1
  37. package/src/__tests__/VirtualList.native.test.tsx +67 -0
  38. package/src/__tests__/VirtualList.web.test.tsx +127 -0
  39. package/src/link-preview/LinkPreviewCard.tsx +5 -1
  40. package/src/link-preview/types.ts +12 -0
  41. package/src/list/index.tsx +121 -0
  42. package/src/list/index.web.tsx +261 -0
  43. package/src/list/types.ts +85 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "0.24.0",
3
+ "version": "0.25.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",
@@ -805,6 +805,22 @@
805
805
  "default": "./lib/commonjs/content-panel/index.js"
806
806
  }
807
807
  },
808
+ "./list": {
809
+ "react-native": "./src/list/index.tsx",
810
+ "browser": {
811
+ "types": "./lib/typescript/module/list/index.web.d.ts",
812
+ "import": "./lib/module/list/index.web.js",
813
+ "require": "./lib/commonjs/list/index.web.js"
814
+ },
815
+ "import": {
816
+ "types": "./lib/typescript/module/list/index.d.ts",
817
+ "default": "./lib/module/list/index.js"
818
+ },
819
+ "require": {
820
+ "types": "./lib/typescript/commonjs/list/index.d.ts",
821
+ "default": "./lib/commonjs/list/index.js"
822
+ }
823
+ },
808
824
  "./package.json": "./package.json"
809
825
  },
810
826
  "files": [
@@ -860,6 +876,8 @@
860
876
  "label",
861
877
  "kbd",
862
878
  "item",
879
+ "list",
880
+ "virtual-list",
863
881
  "ai"
864
882
  ],
865
883
  "repository": {
@@ -992,6 +1010,7 @@
992
1010
  ]
993
1011
  },
994
1012
  "dependencies": {
1013
+ "@tanstack/react-virtual": "^3.14.3",
995
1014
  "nanoid": "^5.1.5",
996
1015
  "react-native-css": "3.0.7",
997
1016
  "react-remove-scroll-bar": "^2.3.8"
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Linking } from 'react-native';
2
+ import { Image, Linking } from 'react-native';
3
3
  import { render, fireEvent } from '@testing-library/react-native';
4
4
 
5
5
  import { BloomThemeProvider } from '../theme/BloomThemeProvider';
@@ -57,4 +57,32 @@ describe('LinkPreviewCard', () => {
57
57
  expect(openURL).toHaveBeenCalledWith('https://example.com/open');
58
58
  openURL.mockRestore();
59
59
  });
60
+
61
+ it('gives the cover image a fixed height by default (coverFill omitted)', () => {
62
+ const { UNSAFE_getByType } = renderWithTheme(
63
+ <LinkPreviewCard
64
+ url="https://example.com"
65
+ title="Fixed"
66
+ image="https://cdn.example.com/og.png"
67
+ />,
68
+ );
69
+ const imageStyle = UNSAFE_getByType(Image).props.style as Record<string, unknown>;
70
+ expect(imageStyle.height).toBe(160);
71
+ expect(imageStyle.flex).toBeUndefined();
72
+ });
73
+
74
+ it('flexes the cover image (no fixed height) when coverFill is true', () => {
75
+ const { UNSAFE_getByType } = renderWithTheme(
76
+ <LinkPreviewCard
77
+ url="https://example.com"
78
+ title="Fill"
79
+ image="https://cdn.example.com/og.png"
80
+ coverFill
81
+ style={{ height: 180 }}
82
+ />,
83
+ );
84
+ const imageStyle = UNSAFE_getByType(Image).props.style as Record<string, unknown>;
85
+ expect(imageStyle.flex).toBe(1);
86
+ expect(imageStyle.height).toBeUndefined();
87
+ });
60
88
  });
@@ -0,0 +1,67 @@
1
+ import React from 'react';
2
+ import { Text } from 'react-native';
3
+ import { render } from '@testing-library/react-native';
4
+
5
+ import { VirtualList, type VirtualListHandle } from '../list';
6
+
7
+ interface Row {
8
+ id: string;
9
+ label: string;
10
+ }
11
+
12
+ describe('VirtualList (native)', () => {
13
+ it('renders the header and every row through FlatList', () => {
14
+ const data: Row[] = [
15
+ { id: 'a', label: 'Alpha' },
16
+ { id: 'b', label: 'Bravo' },
17
+ { id: 'c', label: 'Charlie' },
18
+ ];
19
+
20
+ const { getByText } = render(
21
+ <VirtualList<Row>
22
+ data={data}
23
+ keyExtractor={(item) => item.id}
24
+ renderItem={({ item }) => <Text>{item.label}</Text>}
25
+ ListHeaderComponent={<Text>Header row</Text>}
26
+ />,
27
+ );
28
+
29
+ expect(getByText('Header row')).toBeTruthy();
30
+ expect(getByText('Alpha')).toBeTruthy();
31
+ expect(getByText('Bravo')).toBeTruthy();
32
+ expect(getByText('Charlie')).toBeTruthy();
33
+ });
34
+
35
+ it('renders the empty slot (and no rows) when data is empty', () => {
36
+ const { getByText, queryByText } = render(
37
+ <VirtualList<Row>
38
+ data={[]}
39
+ keyExtractor={(item) => item.id}
40
+ renderItem={({ item }) => <Text>{item.label}</Text>}
41
+ ListEmptyComponent={<Text>Nothing here</Text>}
42
+ />,
43
+ );
44
+
45
+ expect(getByText('Nothing here')).toBeTruthy();
46
+ expect(queryByText('Alpha')).toBeNull();
47
+ });
48
+
49
+ it('exposes an imperative scroll handle via ref', () => {
50
+ const ref = React.createRef<VirtualListHandle>();
51
+
52
+ render(
53
+ <VirtualList<Row>
54
+ ref={ref}
55
+ data={[{ id: 'a', label: 'Alpha' }]}
56
+ keyExtractor={(item) => item.id}
57
+ renderItem={({ item }) => <Text>{item.label}</Text>}
58
+ />,
59
+ );
60
+
61
+ expect(typeof ref.current?.scrollToOffset).toBe('function');
62
+ expect(typeof ref.current?.scrollTo).toBe('function');
63
+ // The FlatList mock does not implement scrollToOffset; calling the handle
64
+ // must be a safe no-op (optional-chained internal ref), never a throw.
65
+ expect(() => ref.current?.scrollToOffset({ offset: 0 })).not.toThrow();
66
+ });
67
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import React from 'react';
6
+ import { act } from 'react';
7
+ import { createRoot, type Root } from 'react-dom/client';
8
+ import { getByText, queryByText } from '@testing-library/dom';
9
+ import '@testing-library/jest-dom';
10
+
11
+ import { VirtualList } from '../list/index.web';
12
+
13
+ // `@tanstack/react-virtual` measures rows via ResizeObserver + getBoundingClientRect,
14
+ // neither of which jsdom implements with a real layout engine. Stub both so the
15
+ // window virtualizer computes a deterministic, non-empty range against jsdom's
16
+ // default 768px-tall window — exactly the path that regressed in production
17
+ // (rows present, wrapper sized, but `getVirtualItems()` came back empty).
18
+ class ResizeObserverStub {
19
+ constructor(_cb: ResizeObserverCallback) {}
20
+ observe(): void {}
21
+ unobserve(): void {}
22
+ disconnect(): void {}
23
+ }
24
+ globalThis.ResizeObserver = ResizeObserverStub;
25
+
26
+ const ROW_HEIGHT = 64;
27
+
28
+ beforeAll(() => {
29
+ jest
30
+ .spyOn(Element.prototype, 'getBoundingClientRect')
31
+ .mockImplementation((): DOMRect => ({
32
+ x: 0,
33
+ y: 0,
34
+ top: 0,
35
+ left: 0,
36
+ right: 0,
37
+ bottom: ROW_HEIGHT,
38
+ width: 0,
39
+ height: ROW_HEIGHT,
40
+ toJSON: () => ({}),
41
+ }));
42
+ });
43
+
44
+ afterAll(() => {
45
+ jest.restoreAllMocks();
46
+ });
47
+
48
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
49
+
50
+ let container: HTMLDivElement;
51
+ let root: Root;
52
+
53
+ function mount(ui: React.ReactElement): HTMLElement {
54
+ act(() => {
55
+ root.render(ui);
56
+ });
57
+ return container;
58
+ }
59
+
60
+ beforeEach(() => {
61
+ container = document.createElement('div');
62
+ document.body.appendChild(container);
63
+ root = createRoot(container);
64
+ });
65
+
66
+ afterEach(() => {
67
+ act(() => root.unmount());
68
+ container.remove();
69
+ });
70
+
71
+ interface Row {
72
+ id: string;
73
+ label: string;
74
+ }
75
+
76
+ describe('VirtualList (web)', () => {
77
+ it('mounts EVERY row of a short list in the first viewport (the empty-rows regression)', () => {
78
+ const data: Row[] = Array.from({ length: 5 }, (_, i) => ({
79
+ id: String(i),
80
+ label: `Row ${i}`,
81
+ }));
82
+
83
+ const c = mount(
84
+ <VirtualList<Row>
85
+ data={data}
86
+ keyExtractor={(item) => item.id}
87
+ renderItem={({ item }) => <span>{item.label}</span>}
88
+ ListHeaderComponent={<div>List header</div>}
89
+ />,
90
+ );
91
+
92
+ expect(getByText(c, 'List header')).toBeTruthy();
93
+ for (const row of data) {
94
+ expect(getByText(c, row.label)).toBeTruthy();
95
+ }
96
+ });
97
+
98
+ it('renders the header + empty slot (and no rows) when data is empty', () => {
99
+ const c = mount(
100
+ <VirtualList<Row>
101
+ data={[]}
102
+ keyExtractor={(item) => item.id}
103
+ renderItem={({ item }) => <span>{item.label}</span>}
104
+ ListHeaderComponent={<div>List header</div>}
105
+ ListEmptyComponent={<div>Nothing here</div>}
106
+ />,
107
+ );
108
+
109
+ expect(getByText(c, 'List header')).toBeTruthy();
110
+ expect(getByText(c, 'Nothing here')).toBeTruthy();
111
+ expect(queryByText(c, 'Row 0')).toBeNull();
112
+ });
113
+
114
+ it('supports a thunk slot for the header', () => {
115
+ const c = mount(
116
+ <VirtualList<Row>
117
+ data={[{ id: 'a', label: 'Only row' }]}
118
+ keyExtractor={(item) => item.id}
119
+ renderItem={({ item }) => <span>{item.label}</span>}
120
+ ListHeaderComponent={() => <div>Thunk header</div>}
121
+ />,
122
+ );
123
+
124
+ expect(getByText(c, 'Thunk header')).toBeTruthy();
125
+ expect(getByText(c, 'Only row')).toBeTruthy();
126
+ });
127
+ });
@@ -56,6 +56,7 @@ const LinkPreviewCardComponent: React.FC<LinkPreviewCardProps> = ({
56
56
  description,
57
57
  image,
58
58
  siteName,
59
+ coverFill = false,
59
60
  onPress,
60
61
  className,
61
62
  style,
@@ -107,7 +108,10 @@ const LinkPreviewCardComponent: React.FC<LinkPreviewCardProps> = ({
107
108
  resizeMode="cover"
108
109
  {...({ className: 'w-full' } as Record<string, string>)}
109
110
  style={{
110
- height: COVER_IMAGE_HEIGHT,
111
+ // Fill-height mode: flex into the card's bounding height (supplied
112
+ // by the consumer via `style`), letting the text block stay a
113
+ // compact footer. Default mode: fixed intrinsic cover height.
114
+ ...(coverFill ? { flex: 1 } : { height: COVER_IMAGE_HEIGHT }),
111
115
  backgroundColor: colors.backgroundTertiary,
112
116
  }}
113
117
  />
@@ -23,6 +23,18 @@ export interface LinkPreviewCardProps {
23
23
  image?: string;
24
24
  /** Site / brand name shown above the title. Falls back to the URL hostname. */
25
25
  siteName?: string;
26
+ /**
27
+ * Fill-height mode for the cover image. Defaults to `false`.
28
+ *
29
+ * - `false` (default): the cover image keeps its fixed intrinsic height —
30
+ * correct when the card sizes itself (e.g. a link rendered alone).
31
+ * - `true`: the cover image flexes (`flex: 1`, no fixed height) to fill
32
+ * whatever vertical space the card is given, with the text block as a
33
+ * compact intrinsic-height footer below it. The consumer supplies the
34
+ * bounding height via `style` (e.g. `style={{ height: 180 }}`) so the card
35
+ * fits a fixed-height row alongside other attachments without overflowing.
36
+ */
37
+ coverFill?: boolean;
26
38
  /** Press handler. When omitted, the card opens `url` via `Linking.openURL`. */
27
39
  onPress?: () => void;
28
40
  /** Extra NativeWind utilities merged onto the card surface. */
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `VirtualList` — the canonical cross-platform virtualized list for the Oxy
3
+ * ecosystem. Every app uses this ONE implementation for grouped/stacked row
4
+ * lists (who-to-follow, starter packs, connections, settings rows, …) so the
5
+ * virtualization behaviour is identical everywhere.
6
+ *
7
+ * NATIVE variant: a thin, correctly-typed wrapper over React Native `FlatList`.
8
+ * The consumer lists this component replaces are short and uniform, so the
9
+ * platform's own virtualization is the simplest robust choice — no
10
+ * `@legendapp/list`, no app-specific scroll bridge, nothing for a consumer to
11
+ * wire up. Bloom stays app-agnostic.
12
+ *
13
+ * WEB variant (`index.web.tsx`, selected via the `"browser"` export condition):
14
+ * a document-scroll window virtualizer built on `@tanstack/react-virtual`.
15
+ *
16
+ * This default file has NO platform-only imports, so consumer `tsc` and web
17
+ * bundlers resolve it cleanly (mirrors the `../scroll` / `../content-panel`
18
+ * fork pattern). The public API is shared via `./types`, so both forks expose
19
+ * an identical, generic, strongly-typed surface.
20
+ */
21
+ import * as React from 'react';
22
+ import { FlatList, type ListRenderItemInfo } from 'react-native';
23
+
24
+ import type {
25
+ VirtualListHandle,
26
+ VirtualListProps,
27
+ VirtualListSlot,
28
+ } from './types';
29
+
30
+ export type {
31
+ VirtualListHandle,
32
+ VirtualListProps,
33
+ VirtualListRenderItem,
34
+ VirtualListRenderItemInfo,
35
+ VirtualListSlot,
36
+ } from './types';
37
+
38
+ function renderSlot(slot: VirtualListSlot): React.ReactElement | null {
39
+ if (!slot) return null;
40
+ return typeof slot === 'function' ? slot() : slot;
41
+ }
42
+
43
+ function VirtualListNativeInner<T>(
44
+ props: VirtualListProps<T>,
45
+ ref: React.ForwardedRef<VirtualListHandle>,
46
+ ) {
47
+ const {
48
+ data,
49
+ renderItem,
50
+ keyExtractor,
51
+ ListHeaderComponent,
52
+ ListEmptyComponent,
53
+ ListFooterComponent,
54
+ style,
55
+ contentContainerStyle,
56
+ onEndReached,
57
+ onEndReachedThreshold,
58
+ refreshing,
59
+ onRefresh,
60
+ initialNumToRender,
61
+ maxToRenderPerBatch,
62
+ windowSize,
63
+ removeClippedSubviews,
64
+ testID,
65
+ } = props;
66
+
67
+ const listRef = React.useRef<FlatList<T>>(null);
68
+
69
+ React.useImperativeHandle(
70
+ ref,
71
+ () => ({
72
+ scrollToOffset: (params) =>
73
+ listRef.current?.scrollToOffset({
74
+ offset: params?.offset ?? 0,
75
+ animated: params?.animated ?? true,
76
+ }),
77
+ scrollTo: (params) =>
78
+ listRef.current?.scrollToOffset({
79
+ offset: params?.y ?? 0,
80
+ animated: params?.animated ?? true,
81
+ }),
82
+ }),
83
+ [],
84
+ );
85
+
86
+ const renderFlatItem = React.useCallback(
87
+ (info: ListRenderItemInfo<T>) =>
88
+ renderItem ? renderItem({ item: info.item, index: info.index }) : null,
89
+ [renderItem],
90
+ );
91
+
92
+ return (
93
+ <FlatList<T>
94
+ ref={listRef}
95
+ data={data ?? undefined}
96
+ renderItem={renderFlatItem}
97
+ keyExtractor={keyExtractor}
98
+ ListHeaderComponent={renderSlot(ListHeaderComponent)}
99
+ ListEmptyComponent={renderSlot(ListEmptyComponent)}
100
+ ListFooterComponent={renderSlot(ListFooterComponent)}
101
+ style={style}
102
+ contentContainerStyle={contentContainerStyle}
103
+ onEndReached={onEndReached ? () => onEndReached() : undefined}
104
+ onEndReachedThreshold={onEndReachedThreshold}
105
+ refreshing={refreshing}
106
+ onRefresh={onRefresh}
107
+ initialNumToRender={initialNumToRender}
108
+ maxToRenderPerBatch={maxToRenderPerBatch}
109
+ windowSize={windowSize}
110
+ removeClippedSubviews={removeClippedSubviews}
111
+ testID={testID}
112
+ />
113
+ );
114
+ }
115
+
116
+ const VirtualList = React.forwardRef(VirtualListNativeInner) as <T>(
117
+ props: VirtualListProps<T> & { ref?: React.Ref<VirtualListHandle> },
118
+ ) => React.ReactElement;
119
+
120
+ export { VirtualList };
121
+ export default VirtualList;