@oxyhq/bloom 0.34.3 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/lib/commonjs/avatar-group/AvatarGroup.js +3 -2
  2. package/lib/commonjs/avatar-group/AvatarGroup.js.map +1 -1
  3. package/lib/commonjs/avatar-group/AvatarGroup.web.js +5 -5
  4. package/lib/commonjs/avatar-group/AvatarGroupBase.js +217 -48
  5. package/lib/commonjs/avatar-group/AvatarGroupBase.js.map +1 -1
  6. package/lib/commonjs/avatar-group/cluster-layout.js +252 -0
  7. package/lib/commonjs/avatar-group/cluster-layout.js.map +1 -0
  8. package/lib/module/avatar-group/AvatarGroup.js +3 -2
  9. package/lib/module/avatar-group/AvatarGroup.js.map +1 -1
  10. package/lib/module/avatar-group/AvatarGroup.web.js +5 -5
  11. package/lib/module/avatar-group/AvatarGroupBase.js +217 -48
  12. package/lib/module/avatar-group/AvatarGroupBase.js.map +1 -1
  13. package/lib/module/avatar-group/cluster-layout.js +248 -0
  14. package/lib/module/avatar-group/cluster-layout.js.map +1 -0
  15. package/lib/typescript/commonjs/avatar-group/AvatarGroup.d.ts.map +1 -1
  16. package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts.map +1 -1
  17. package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts +55 -0
  18. package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts.map +1 -0
  19. package/lib/typescript/commonjs/avatar-group/types.d.ts +23 -5
  20. package/lib/typescript/commonjs/avatar-group/types.d.ts.map +1 -1
  21. package/lib/typescript/module/avatar-group/AvatarGroup.d.ts.map +1 -1
  22. package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts.map +1 -1
  23. package/lib/typescript/module/avatar-group/cluster-layout.d.ts +55 -0
  24. package/lib/typescript/module/avatar-group/cluster-layout.d.ts.map +1 -0
  25. package/lib/typescript/module/avatar-group/types.d.ts +23 -5
  26. package/lib/typescript/module/avatar-group/types.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/__tests__/AvatarGroupCluster.test.tsx +168 -0
  29. package/src/avatar-group/AvatarGroup.stories.tsx +69 -0
  30. package/src/avatar-group/AvatarGroup.tsx +3 -2
  31. package/src/avatar-group/AvatarGroup.web.tsx +5 -5
  32. package/src/avatar-group/AvatarGroupBase.tsx +298 -89
  33. package/src/avatar-group/cluster-layout.ts +240 -0
  34. package/src/avatar-group/types.ts +23 -5
@@ -0,0 +1,168 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react-native';
3
+
4
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
5
+ import { AvatarGroup } from '../avatar-group';
6
+ import type { AvatarGroupItem } from '../avatar-group';
7
+ import {
8
+ computeClusterLayout,
9
+ type ClusterBubble,
10
+ } from '../avatar-group/cluster-layout';
11
+
12
+ /** Returns bubble at `index` or throws — keeps the tests `!`/`as`-free under
13
+ * `noUncheckedIndexedAccess`. */
14
+ function bubbleAt(bubbles: readonly ClusterBubble[], index: number): ClusterBubble {
15
+ const bubble = bubbles[index];
16
+ if (!bubble) throw new Error(`expected a bubble at index ${index}`);
17
+ return bubble;
18
+ }
19
+
20
+ /** Nearest-neighbour edge gap for every bubble (negative = overlap). */
21
+ function minEdgeGap(bubbles: readonly ClusterBubble[]): number {
22
+ let worst = Infinity;
23
+ for (const a of bubbles) {
24
+ for (const b of bubbles) {
25
+ if (a === b) continue;
26
+ const centerDist = Math.hypot(a.cx - b.cx, a.cy - b.cy);
27
+ const gap = centerDist - (a.d / 2 + b.d / 2);
28
+ if (gap < worst) worst = gap;
29
+ }
30
+ }
31
+ return worst;
32
+ }
33
+
34
+ describe('computeClusterLayout', () => {
35
+ it('returns an empty layout for non-positive counts', () => {
36
+ expect(computeClusterLayout(0)).toEqual([]);
37
+ expect(computeClusterLayout(-3)).toEqual([]);
38
+ });
39
+
40
+ it('returns exactly `count` bubbles', () => {
41
+ for (let count = 1; count <= 20; count++) {
42
+ expect(computeClusterLayout(count)).toHaveLength(count);
43
+ }
44
+ });
45
+
46
+ it('is fully deterministic (no Math.random)', () => {
47
+ for (const count of [2, 3, 4, 8, 20]) {
48
+ expect(computeClusterLayout(count)).toEqual(computeClusterLayout(count));
49
+ }
50
+ });
51
+
52
+ it('keeps every bubble inside the unit box with no NaN', () => {
53
+ for (let count = 1; count <= 20; count++) {
54
+ const bubbles = computeClusterLayout(count);
55
+ for (const bubble of bubbles) {
56
+ expect(Number.isNaN(bubble.cx)).toBe(false);
57
+ expect(Number.isNaN(bubble.cy)).toBe(false);
58
+ expect(Number.isNaN(bubble.d)).toBe(false);
59
+ expect(bubble.cx - bubble.d / 2).toBeGreaterThanOrEqual(-1e-6);
60
+ expect(bubble.cx + bubble.d / 2).toBeLessThanOrEqual(1 + 1e-6);
61
+ expect(bubble.cy - bubble.d / 2).toBeGreaterThanOrEqual(-1e-6);
62
+ expect(bubble.cy + bubble.d / 2).toBeLessThanOrEqual(1 + 1e-6);
63
+ }
64
+ }
65
+ });
66
+
67
+ it('makes the primary (index 0) the largest bubble', () => {
68
+ for (let count = 2; count <= 20; count++) {
69
+ const bubbles = computeClusterLayout(count);
70
+ const primary = bubbleAt(bubbles, 0);
71
+ for (const bubble of bubbles) {
72
+ expect(primary.d).toBeGreaterThanOrEqual(bubble.d - 1e-9);
73
+ }
74
+ }
75
+ });
76
+
77
+ it('fills the round box edge-to-edge for the force-packed counts (4+)', () => {
78
+ // After the recentre + scale, the outermost bubble edge lands on the box
79
+ // radius (0.5 from the box centre) — the blob fills the round box densely
80
+ // with no big empty margin — and the whole cluster stays roughly centred.
81
+ for (const count of [4, 5, 8, 12, 20]) {
82
+ const bubbles = computeClusterLayout(count);
83
+ let reach = 0;
84
+ for (const bubble of bubbles) {
85
+ const r = Math.hypot(bubble.cx - 0.5, bubble.cy - 0.5) + bubble.d / 2;
86
+ if (r > reach) reach = r;
87
+ }
88
+ expect(reach).toBeCloseTo(0.5, 6);
89
+ const primary = bubbleAt(bubbles, 0);
90
+ expect(primary.cx).toBeGreaterThan(0.4);
91
+ expect(primary.cx).toBeLessThan(0.6);
92
+ expect(primary.cy).toBeGreaterThan(0.4);
93
+ expect(primary.cy).toBeLessThan(0.6);
94
+ }
95
+ });
96
+
97
+ it('keeps the force-packed bubbles near-equal in size (mild taper)', () => {
98
+ // The pack should read as near-equal magnetic bubbles: a modestly larger
99
+ // primary, everyone else only slightly smaller — not a big primary ringed by
100
+ // tiny dots. The smallest bubble stays at least ~75% of the largest.
101
+ for (const count of [4, 5, 6, 8, 12, 20]) {
102
+ const diameters = computeClusterLayout(count).map((bubble) => bubble.d);
103
+ const maxD = Math.max(...diameters);
104
+ const minD = Math.min(...diameters);
105
+ expect(minD / maxD).toBeGreaterThan(0.75);
106
+ }
107
+ });
108
+
109
+ it('keeps a uniform, non-overlapping gap for 3+ members', () => {
110
+ // n=2 is the intentional "front + behind" overlap; 3+ pack with a gap.
111
+ for (let count = 3; count <= 20; count++) {
112
+ expect(minEdgeGap(computeClusterLayout(count))).toBeGreaterThan(-1e-3);
113
+ }
114
+ });
115
+
116
+ it('gives the two-member cluster equal-diameter, overlapping bubbles', () => {
117
+ const bubbles = computeClusterLayout(2);
118
+ expect(bubbles).toHaveLength(2);
119
+ const front = bubbleAt(bubbles, 0);
120
+ const behind = bubbleAt(bubbles, 1);
121
+ // The fix: both bubbles share the SAME diameter — the one drawn behind is no
122
+ // longer rendered too small.
123
+ expect(front.d).toBeCloseTo(behind.d, 10);
124
+ // Still the intentional iMessage "front + behind" overlap (negative gap).
125
+ expect(minEdgeGap(bubbles)).toBeLessThan(0);
126
+ // Deterministic (no Math.random).
127
+ expect(computeClusterLayout(2)).toEqual(bubbles);
128
+ // Both bubbles sit fully inside the unit box.
129
+ for (const bubble of bubbles) {
130
+ expect(bubble.cx - bubble.d / 2).toBeGreaterThanOrEqual(-1e-6);
131
+ expect(bubble.cx + bubble.d / 2).toBeLessThanOrEqual(1 + 1e-6);
132
+ expect(bubble.cy - bubble.d / 2).toBeGreaterThanOrEqual(-1e-6);
133
+ expect(bubble.cy + bubble.d / 2).toBeLessThanOrEqual(1 + 1e-6);
134
+ }
135
+ });
136
+ });
137
+
138
+ const CLUSTER_ITEMS: AvatarGroupItem[] = Array.from({ length: 25 }, (_, i) => ({
139
+ id: `c${i}`,
140
+ displayName: `Member ${i}`,
141
+ username: `member${i}`,
142
+ }));
143
+
144
+ function renderCluster(ui: React.ReactElement) {
145
+ return render(<BloomThemeProvider mode="light" colorPreset="teal">{ui}</BloomThemeProvider>);
146
+ }
147
+
148
+ describe('AvatarGroup cluster layout', () => {
149
+ it('renders a "+N" overflow bubble past the cap', () => {
150
+ // 25 members, cap 20 → 19 avatars + a "+6" bubble.
151
+ const { getByText } = renderCluster(
152
+ <AvatarGroup layout="cluster" items={CLUSTER_ITEMS} size={140} max={20} showInitials />,
153
+ );
154
+ expect(getByText('+6')).toBeTruthy();
155
+ });
156
+
157
+ it('shows no overflow bubble when members fit under the cap', () => {
158
+ const { queryByText } = renderCluster(
159
+ <AvatarGroup
160
+ layout="cluster"
161
+ items={CLUSTER_ITEMS.slice(0, 4)}
162
+ size={64}
163
+ showInitials
164
+ />,
165
+ );
166
+ expect(queryByText(/^\+/)).toBeNull();
167
+ });
168
+ });
@@ -147,3 +147,72 @@ export const RowLayout: Story = {
147
147
  ),
148
148
  name: 'Row layout',
149
149
  };
150
+
151
+ // A larger pool of initials-only members to show the cluster densely packed.
152
+ const MANY: AvatarGroupItem[] = Array.from({ length: 25 }, (_, i) => ({
153
+ id: `c${i}`,
154
+ displayName: String.fromCharCode(65 + (i % 26)),
155
+ username: `user${i}`,
156
+ }));
157
+
158
+ /**
159
+ * Cluster layout: a compact iMessage-style "magnetic bubble cluster" of
160
+ * NEAR-EQUAL-size avatars packed densely into a round box (`size` = box
161
+ * diameter). A modestly larger primary sits in front; the rest are only slightly
162
+ * smaller and nestle against it with a small uniform gap, filling the round box
163
+ * edge-to-edge. `showInitials` renders a colored letter for avatar-less members
164
+ * so the packing reads clearly. Shown at counts 3, 4, 5, 6, 8, 12, and 20.
165
+ */
166
+ export const ClusterLayout: Story = {
167
+ render: () => (
168
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24, alignItems: 'center' }}>
169
+ {[3, 4, 5, 6, 8, 12, 20].map((count) => (
170
+ <View key={count} style={{ alignItems: 'center', gap: 8 }}>
171
+ <AvatarGroup
172
+ layout="cluster"
173
+ items={MANY.slice(0, count)}
174
+ size={120}
175
+ showInitials
176
+ />
177
+ <Text>{count}</Text>
178
+ </View>
179
+ ))}
180
+ </View>
181
+ ),
182
+ name: 'Cluster layout',
183
+ };
184
+
185
+ /**
186
+ * Cluster with photos at the common collaborative-post sizes (2 = "front +
187
+ * behind", then 3 and 4).
188
+ */
189
+ export const ClusterWithPhotos: Story = {
190
+ render: () => (
191
+ <View style={{ flexDirection: 'row', gap: 24, alignItems: 'center' }}>
192
+ {[2, 3, 4].map((count) => (
193
+ <AvatarGroup
194
+ key={count}
195
+ layout="cluster"
196
+ items={WITH_PHOTOS.slice(0, count)}
197
+ size={64}
198
+ showInitials
199
+ />
200
+ ))}
201
+ </View>
202
+ ),
203
+ name: 'Cluster with photos',
204
+ };
205
+
206
+ /**
207
+ * Cluster overflow: 25 members with `max={20}` shows 19 avatars + a trailing
208
+ * "+N" bubble as the last (smallest) cluster member.
209
+ */
210
+ export const ClusterOverflow: Story = {
211
+ render: () => (
212
+ <View style={{ flexDirection: 'row', gap: 24, alignItems: 'center' }}>
213
+ <AvatarGroup layout="cluster" items={MANY} size={140} max={20} showInitials />
214
+ <Text>25 members, max 20 → cap + &quot;+N&quot;</Text>
215
+ </View>
216
+ ),
217
+ name: 'Cluster overflow',
218
+ };
@@ -4,8 +4,9 @@ import { AvatarGroupBase } from './AvatarGroupBase';
4
4
  import type { AvatarGroupProps } from './types';
5
5
 
6
6
  /**
7
- * Native `AvatarGroup` — an overlapping, ringed stack of {@link Avatar}s with a
8
- * trailing `+N` overflow chip.
7
+ * Native `AvatarGroup` — {@link Avatar}s arranged as a horizontal facepile
8
+ * (`stack`), an adjacent `row`, or a 2D magnetic `cluster`, with a trailing
9
+ * `+N` overflow chip. See `layout` in {@link AvatarGroupProps}.
9
10
  *
10
11
  * Hover behavior is web-only; on native the `hoverCard` / `renderItemAction`
11
12
  * props are accepted for API parity but intentionally do nothing (no hover on
@@ -28,11 +28,11 @@ function getItemName(item: AvatarGroupItem): string {
28
28
  }
29
29
 
30
30
  /**
31
- * Web `AvatarGroup`. Identical stack rendering to native via
32
- * {@link AvatarGroupBase}, plus an optional hover card: when `hoverCard` is
33
- * enabled, hovering an avatar reveals a {@link UserHoverCard} positioned beneath
34
- * it. The card receives the per-item `renderItemAction` slot so the app can drop
35
- * its SDK FollowButton inside.
31
+ * Web `AvatarGroup`. Identical layout rendering to native (stack / row /
32
+ * cluster) via {@link AvatarGroupBase}, plus an optional hover card: when
33
+ * `hoverCard` is enabled, hovering an avatar reveals a {@link UserHoverCard}
34
+ * positioned beneath it. The card receives the per-item `renderItemAction` slot
35
+ * so the app can drop its SDK FollowButton inside.
36
36
  */
37
37
  const AvatarGroupWebComponent: React.FC<AvatarGroupProps> = (props) => {
38
38
  const { hoverCard, renderItemAction, onPressItem } = props;