@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.
- package/lib/commonjs/avatar-group/AvatarGroup.js +3 -2
- package/lib/commonjs/avatar-group/AvatarGroup.js.map +1 -1
- package/lib/commonjs/avatar-group/AvatarGroup.web.js +5 -5
- package/lib/commonjs/avatar-group/AvatarGroupBase.js +217 -48
- package/lib/commonjs/avatar-group/AvatarGroupBase.js.map +1 -1
- package/lib/commonjs/avatar-group/cluster-layout.js +252 -0
- package/lib/commonjs/avatar-group/cluster-layout.js.map +1 -0
- package/lib/module/avatar-group/AvatarGroup.js +3 -2
- package/lib/module/avatar-group/AvatarGroup.js.map +1 -1
- package/lib/module/avatar-group/AvatarGroup.web.js +5 -5
- package/lib/module/avatar-group/AvatarGroupBase.js +217 -48
- package/lib/module/avatar-group/AvatarGroupBase.js.map +1 -1
- package/lib/module/avatar-group/cluster-layout.js +248 -0
- package/lib/module/avatar-group/cluster-layout.js.map +1 -0
- package/lib/typescript/commonjs/avatar-group/AvatarGroup.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts +55 -0
- package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar-group/types.d.ts +23 -5
- package/lib/typescript/commonjs/avatar-group/types.d.ts.map +1 -1
- package/lib/typescript/module/avatar-group/AvatarGroup.d.ts.map +1 -1
- package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts.map +1 -1
- package/lib/typescript/module/avatar-group/cluster-layout.d.ts +55 -0
- package/lib/typescript/module/avatar-group/cluster-layout.d.ts.map +1 -0
- package/lib/typescript/module/avatar-group/types.d.ts +23 -5
- package/lib/typescript/module/avatar-group/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/AvatarGroupCluster.test.tsx +168 -0
- package/src/avatar-group/AvatarGroup.stories.tsx +69 -0
- package/src/avatar-group/AvatarGroup.tsx +3 -2
- package/src/avatar-group/AvatarGroup.web.tsx +5 -5
- package/src/avatar-group/AvatarGroupBase.tsx +298 -89
- package/src/avatar-group/cluster-layout.ts +240 -0
- package/src/avatar-group/types.ts +23 -5
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.computeClusterLayout = computeClusterLayout;
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic organic circle-packing for the {@link AvatarGroup} `cluster`
|
|
9
|
+
* layout — the iMessage-style "magnetic bubble cluster" where several avatars of
|
|
10
|
+
* NEAR-EQUAL size nestle tightly together and fill a round bounding box (a
|
|
11
|
+
* modestly larger primary with the rest only slightly smaller, packed against
|
|
12
|
+
* each other with a small uniform gap — not a big primary ringed by tiny dots).
|
|
13
|
+
*
|
|
14
|
+
* For 4+ members the layout is produced by a small, FULLY DETERMINISTIC
|
|
15
|
+
* force-directed relaxation (no `Math.random`): the primary is pinned at the
|
|
16
|
+
* centre and the remaining members are seeded on a golden-angle spiral, then a
|
|
17
|
+
* fixed number of relaxation passes (a) pull every non-primary circle toward the
|
|
18
|
+
* centre and (b) push any two circles apart until they clear a uniform gap. A
|
|
19
|
+
* fixed iteration count + deterministic seed means the same `count` always
|
|
20
|
+
* yields byte-identical positions, so native and web render the cluster
|
|
21
|
+
* identically with no `onLayout`/DOM measurement. The very small counts (1, 2,
|
|
22
|
+
* 3) — where a relaxation degenerates into a line or a lone pair — use explicit
|
|
23
|
+
* iMessage-style arrangements instead.
|
|
24
|
+
*
|
|
25
|
+
* Output is resolution-independent: each bubble is expressed as a fraction of
|
|
26
|
+
* the group's bounding box (`cx`/`cy` centre, `d` diameter, all 0..1), so the
|
|
27
|
+
* consumer just multiplies by the pixel `size`. The relaxed blob is recentred on
|
|
28
|
+
* its own centre and scaled so the outermost bubble edge lands on the box edge,
|
|
29
|
+
* so it fills the round box densely with minimal empty margin while every bubble
|
|
30
|
+
* is guaranteed to sit fully inside the `[0, 1]` box — the cluster drops in
|
|
31
|
+
* exactly where a single round Avatar would.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** A single packed bubble, expressed as fractions of the bounding box (0..1). */
|
|
35
|
+
|
|
36
|
+
// Golden angle (~137.5°) — spreads the seed points evenly with no directional
|
|
37
|
+
// bias, which is what gives the relaxed result its organic, non-grid feel.
|
|
38
|
+
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
|
39
|
+
// Fixed relaxation passes + a final separation-only cleanup so the last thing
|
|
40
|
+
// that happens is overlap resolution (gaps end uniform and non-negative).
|
|
41
|
+
const ITERATIONS = 500;
|
|
42
|
+
const CLEANUP_ITERATIONS = 80;
|
|
43
|
+
// How hard each pass pulls every non-primary circle toward the centre. This is
|
|
44
|
+
// the only compacting force; separation only ever pushes apart, so any positive
|
|
45
|
+
// value packs the cluster fully — this just controls convergence speed.
|
|
46
|
+
const CENTERING = 0.05;
|
|
47
|
+
// Fraction of an overlap resolved per pass. 1 fully separates each pass; with
|
|
48
|
+
// the pinned primary and many passes this stays stable and converges.
|
|
49
|
+
const SEPARATION_STRENGTH = 1;
|
|
50
|
+
// Uniform gap kept between every touching pair, in primary-radius units
|
|
51
|
+
// (primary radius = 1). Small, so near-equal bubbles nestle tightly like
|
|
52
|
+
// magnets rather than floating apart with visible margins.
|
|
53
|
+
const LAYOUT_GAP = 0.12;
|
|
54
|
+
// Initial spiral spacing. Only affects convergence (the result is re-fitted to
|
|
55
|
+
// the box afterwards), not the final scale.
|
|
56
|
+
const SEED_SPACING = 1.7;
|
|
57
|
+
// Relative radii: the primary is only MODESTLY the largest; every other member
|
|
58
|
+
// tapers gently from SECONDARY_MAX (nearest the primary) down to SECONDARY_MIN
|
|
59
|
+
// (outermost). The spread is deliberately narrow so the cluster reads as a pack
|
|
60
|
+
// of near-equal magnetic bubbles — a slightly larger primary, not a big primary
|
|
61
|
+
// ringed by tiny dots.
|
|
62
|
+
const PRIMARY_RADIUS = 1;
|
|
63
|
+
const SECONDARY_MAX = 0.9;
|
|
64
|
+
const SECONDARY_MIN = 0.8;
|
|
65
|
+
const EPSILON = 1e-6;
|
|
66
|
+
|
|
67
|
+
/** Relative radius for member `index` of a `count`-member cluster. */
|
|
68
|
+
function relativeRadius(index, count) {
|
|
69
|
+
if (index === 0) return PRIMARY_RADIUS;
|
|
70
|
+
if (count <= 2) return SECONDARY_MAX;
|
|
71
|
+
const t = (index - 1) / (count - 2);
|
|
72
|
+
return SECONDARY_MAX + (SECONDARY_MIN - SECONDARY_MAX) * t;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One separation pass: push any pair closer than `(r_i + r_j + gap)` apart. The
|
|
77
|
+
* primary (index 0) is pinned — when a pair involves it, only the other circle
|
|
78
|
+
* moves — which keeps the largest avatar dead-centre and in front.
|
|
79
|
+
*/
|
|
80
|
+
function separate(xs, ys, radii, count) {
|
|
81
|
+
for (let i = 0; i < count; i++) {
|
|
82
|
+
for (let j = i + 1; j < count; j++) {
|
|
83
|
+
let dx = (xs[j] ?? 0) - (xs[i] ?? 0);
|
|
84
|
+
let dy = (ys[j] ?? 0) - (ys[i] ?? 0);
|
|
85
|
+
let dist = Math.hypot(dx, dy);
|
|
86
|
+
const minDist = (radii[i] ?? 0) + (radii[j] ?? 0) + LAYOUT_GAP;
|
|
87
|
+
if (dist >= minDist) continue;
|
|
88
|
+
if (dist < EPSILON) {
|
|
89
|
+
// Coincident points: pick a deterministic direction from the indices so
|
|
90
|
+
// the split is stable (never random).
|
|
91
|
+
const a = (i + 1) * GOLDEN_ANGLE + j;
|
|
92
|
+
dx = Math.cos(a);
|
|
93
|
+
dy = Math.sin(a);
|
|
94
|
+
dist = 1;
|
|
95
|
+
}
|
|
96
|
+
const overlap = (minDist - dist) * SEPARATION_STRENGTH;
|
|
97
|
+
const nx = dx / dist;
|
|
98
|
+
const ny = dy / dist;
|
|
99
|
+
if (i === 0) {
|
|
100
|
+
// Primary pinned: move only the other circle by the full overlap.
|
|
101
|
+
xs[j] = (xs[j] ?? 0) + nx * overlap;
|
|
102
|
+
ys[j] = (ys[j] ?? 0) + ny * overlap;
|
|
103
|
+
} else {
|
|
104
|
+
xs[i] = (xs[i] ?? 0) - nx * overlap / 2;
|
|
105
|
+
ys[i] = (ys[i] ?? 0) - ny * overlap / 2;
|
|
106
|
+
xs[j] = (xs[j] ?? 0) + nx * overlap / 2;
|
|
107
|
+
ys[j] = (ys[j] ?? 0) + ny * overlap / 2;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Deterministic force-directed pack of `count` circles (primary pinned at the
|
|
115
|
+
* centre), returned as box-fraction bubbles. Used for every cluster with 4+
|
|
116
|
+
* members; 1–3 are handled as explicit arrangements in
|
|
117
|
+
* {@link computeClusterLayout}.
|
|
118
|
+
*/
|
|
119
|
+
function packCluster(count) {
|
|
120
|
+
const radii = new Array(count);
|
|
121
|
+
const xs = new Array(count);
|
|
122
|
+
const ys = new Array(count);
|
|
123
|
+
for (let i = 0; i < count; i++) {
|
|
124
|
+
radii[i] = relativeRadius(i, count);
|
|
125
|
+
if (i === 0) {
|
|
126
|
+
xs[i] = 0;
|
|
127
|
+
ys[i] = 0;
|
|
128
|
+
} else {
|
|
129
|
+
// Golden-angle spiral seed around the pinned primary.
|
|
130
|
+
const seedR = SEED_SPACING * Math.sqrt(i);
|
|
131
|
+
const angle = i * GOLDEN_ANGLE;
|
|
132
|
+
xs[i] = seedR * Math.cos(angle);
|
|
133
|
+
ys[i] = seedR * Math.sin(angle);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (let iter = 0; iter < ITERATIONS; iter++) {
|
|
137
|
+
// (a) Centering: pull every non-primary circle toward the centre.
|
|
138
|
+
for (let i = 1; i < count; i++) {
|
|
139
|
+
xs[i] = (xs[i] ?? 0) * (1 - CENTERING);
|
|
140
|
+
ys[i] = (ys[i] ?? 0) * (1 - CENTERING);
|
|
141
|
+
}
|
|
142
|
+
// (b) Separation resolves any resulting overlaps (primary stays pinned).
|
|
143
|
+
separate(xs, ys, radii, count);
|
|
144
|
+
xs[0] = 0;
|
|
145
|
+
ys[0] = 0;
|
|
146
|
+
}
|
|
147
|
+
// Final separation-only passes so the cluster ends with clean uniform gaps.
|
|
148
|
+
for (let iter = 0; iter < CLEANUP_ITERATIONS; iter++) {
|
|
149
|
+
separate(xs, ys, radii, count);
|
|
150
|
+
xs[0] = 0;
|
|
151
|
+
ys[0] = 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Fit to the unit box so the pack FILLS the round box edge-to-edge with
|
|
155
|
+
// minimal empty margin. The primary was pinned at the origin during
|
|
156
|
+
// relaxation, but the relaxed blob is not centred on it, so we recentre on the
|
|
157
|
+
// cluster's OWN centre (its bounding-box midpoint) rather than the primary:
|
|
158
|
+
// enclose every bubble in a circle around that centre, then scale so the
|
|
159
|
+
// outermost bubble edge lands exactly on the box radius (0.5). Centring the
|
|
160
|
+
// enclosing circle at the box centre keeps every bubble inside the [0, 1] box
|
|
161
|
+
// (the enclosing circle is inscribed in the square) while packing the blob
|
|
162
|
+
// tightly against the edge instead of floating with a gap.
|
|
163
|
+
let minX = Infinity;
|
|
164
|
+
let maxX = -Infinity;
|
|
165
|
+
let minY = Infinity;
|
|
166
|
+
let maxY = -Infinity;
|
|
167
|
+
for (let i = 0; i < count; i++) {
|
|
168
|
+
const x = xs[i] ?? 0;
|
|
169
|
+
const y = ys[i] ?? 0;
|
|
170
|
+
const r = radii[i] ?? 0;
|
|
171
|
+
if (x - r < minX) minX = x - r;
|
|
172
|
+
if (x + r > maxX) maxX = x + r;
|
|
173
|
+
if (y - r < minY) minY = y - r;
|
|
174
|
+
if (y + r > maxY) maxY = y + r;
|
|
175
|
+
}
|
|
176
|
+
const centerX = (minX + maxX) / 2;
|
|
177
|
+
const centerY = (minY + maxY) / 2;
|
|
178
|
+
let bound = 0;
|
|
179
|
+
for (let i = 0; i < count; i++) {
|
|
180
|
+
const reach = Math.hypot((xs[i] ?? 0) - centerX, (ys[i] ?? 0) - centerY) + (radii[i] ?? 0);
|
|
181
|
+
if (reach > bound) bound = reach;
|
|
182
|
+
}
|
|
183
|
+
const scale = bound > EPSILON ? 0.5 / bound : 0.5;
|
|
184
|
+
const bubbles = new Array(count);
|
|
185
|
+
for (let i = 0; i < count; i++) {
|
|
186
|
+
bubbles[i] = {
|
|
187
|
+
cx: 0.5 + ((xs[i] ?? 0) - centerX) * scale,
|
|
188
|
+
cy: 0.5 + ((ys[i] ?? 0) - centerY) * scale,
|
|
189
|
+
d: 2 * (radii[i] ?? 0) * scale
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return bubbles;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Deterministic cluster layout for `count` bubbles, as box-fraction bubbles
|
|
197
|
+
* ordered primary-first. `count` includes the `+N` overflow bubble when present
|
|
198
|
+
* (it is simply the last, smallest member of the pack).
|
|
199
|
+
*
|
|
200
|
+
* - `<= 0` → empty.
|
|
201
|
+
* - `1` → a single bubble filling the box.
|
|
202
|
+
* - `2` → the iMessage "one in front, one behind" pair: two EQUAL-diameter
|
|
203
|
+
* bubbles offset on a diagonal — the front set low-left, the other tucked
|
|
204
|
+
* behind it to the upper-right. Same radius, so neither member reads as
|
|
205
|
+
* secondary; the overlap + separator ring alone convey the stacking.
|
|
206
|
+
* - `3` → an iMessage-style triangle: the larger primary along the bottom with
|
|
207
|
+
* two smaller members above it.
|
|
208
|
+
* - `4+` → the deterministic force-directed pack of near-equal bubbles, recentred
|
|
209
|
+
* and scaled to fill the round box edge-to-edge (a modestly larger primary near
|
|
210
|
+
* the centre, the rest packed magnetically around it, denser as the count
|
|
211
|
+
* grows).
|
|
212
|
+
*/
|
|
213
|
+
function computeClusterLayout(count) {
|
|
214
|
+
if (count <= 0) return [];
|
|
215
|
+
if (count === 1) return [{
|
|
216
|
+
cx: 0.5,
|
|
217
|
+
cy: 0.5,
|
|
218
|
+
d: 1
|
|
219
|
+
}];
|
|
220
|
+
if (count === 2) {
|
|
221
|
+
// Two EQUAL-diameter bubbles, offset symmetrically on the diagonal: the
|
|
222
|
+
// front (index 0, highest zIndex) sits low-left, the other tucks behind it
|
|
223
|
+
// to the upper-right. They overlap (the intentional "front + behind" pair);
|
|
224
|
+
// the separator ring — not a size difference — reads the stacking.
|
|
225
|
+
return [{
|
|
226
|
+
cx: 0.35,
|
|
227
|
+
cy: 0.65,
|
|
228
|
+
d: 0.64
|
|
229
|
+
}, {
|
|
230
|
+
cx: 0.65,
|
|
231
|
+
cy: 0.35,
|
|
232
|
+
d: 0.64
|
|
233
|
+
}];
|
|
234
|
+
}
|
|
235
|
+
if (count === 3) {
|
|
236
|
+
return [{
|
|
237
|
+
cx: 0.5,
|
|
238
|
+
cy: 0.69,
|
|
239
|
+
d: 0.54
|
|
240
|
+
}, {
|
|
241
|
+
cx: 0.285,
|
|
242
|
+
cy: 0.24,
|
|
243
|
+
d: 0.4
|
|
244
|
+
}, {
|
|
245
|
+
cx: 0.715,
|
|
246
|
+
cy: 0.24,
|
|
247
|
+
d: 0.4
|
|
248
|
+
}];
|
|
249
|
+
}
|
|
250
|
+
return packCluster(count);
|
|
251
|
+
}
|
|
252
|
+
//# sourceMappingURL=cluster-layout.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["GOLDEN_ANGLE","Math","PI","sqrt","ITERATIONS","CLEANUP_ITERATIONS","CENTERING","SEPARATION_STRENGTH","LAYOUT_GAP","SEED_SPACING","PRIMARY_RADIUS","SECONDARY_MAX","SECONDARY_MIN","EPSILON","relativeRadius","index","count","t","separate","xs","ys","radii","i","j","dx","dy","dist","hypot","minDist","a","cos","sin","overlap","nx","ny","packCluster","Array","seedR","angle","iter","minX","Infinity","maxX","minY","maxY","x","y","r","centerX","centerY","bound","reach","scale","bubbles","cx","cy","d","computeClusterLayout"],"sourceRoot":"../../../src","sources":["avatar-group/cluster-layout.ts"],"mappings":"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","ignoreList":[]}
|
|
@@ -4,8 +4,9 @@ import React, { memo } from 'react';
|
|
|
4
4
|
import { AvatarGroupBase } from "./AvatarGroupBase.js";
|
|
5
5
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
6
6
|
/**
|
|
7
|
-
* Native `AvatarGroup` —
|
|
8
|
-
*
|
|
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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["React","memo","AvatarGroupBase","jsx","_jsx","AvatarGroupComponent","props","AvatarGroup","displayName"],"sourceRoot":"../../../src","sources":["avatar-group/AvatarGroup.tsx"],"mappings":";;AAAA,OAAOA,KAAK,IAAIC,IAAI,QAAQ,OAAO;AAEnC,SAASC,eAAe,QAAQ,sBAAmB;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAGpD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,MAAMC,oBAAgD,GAAIC,KAAK,IAAK;EAClE,oBAAOF,IAAA,CAACF,eAAe;IAAA,GAAKI;EAAK,CAAG,CAAC;AACvC,CAAC;AAED,OAAO,MAAMC,WAAW,gBAAGN,IAAI,CAACI,oBAAoB,CAAC;AACrDE,WAAW,CAACC,WAAW,GAAG,aAAa","ignoreList":[]}
|
|
1
|
+
{"version":3,"names":["React","memo","AvatarGroupBase","jsx","_jsx","AvatarGroupComponent","props","AvatarGroup","displayName"],"sourceRoot":"../../../src","sources":["avatar-group/AvatarGroup.tsx"],"mappings":";;AAAA,OAAOA,KAAK,IAAIC,IAAI,QAAQ,OAAO;AAEnC,SAASC,eAAe,QAAQ,sBAAmB;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAGpD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,MAAMC,oBAAgD,GAAIC,KAAK,IAAK;EAClE,oBAAOF,IAAA,CAACF,eAAe;IAAA,GAAKI;EAAK,CAAG,CAAC;AACvC,CAAC;AAED,OAAO,MAAMC,WAAW,gBAAGN,IAAI,CAACI,oBAAoB,CAAC;AACrDE,WAAW,CAACC,WAAW,GAAG,aAAa","ignoreList":[]}
|
|
@@ -17,11 +17,11 @@ function getItemName(item) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* Web `AvatarGroup`. Identical
|
|
21
|
-
* {@link AvatarGroupBase}, plus an optional hover card: when
|
|
22
|
-
* enabled, hovering an avatar reveals a {@link UserHoverCard}
|
|
23
|
-
* it. The card receives the per-item `renderItemAction` slot
|
|
24
|
-
* its SDK FollowButton inside.
|
|
20
|
+
* Web `AvatarGroup`. Identical layout rendering to native (stack / row /
|
|
21
|
+
* cluster) via {@link AvatarGroupBase}, plus an optional hover card: when
|
|
22
|
+
* `hoverCard` is enabled, hovering an avatar reveals a {@link UserHoverCard}
|
|
23
|
+
* positioned beneath it. The card receives the per-item `renderItemAction` slot
|
|
24
|
+
* so the app can drop its SDK FollowButton inside.
|
|
25
25
|
*/
|
|
26
26
|
const AvatarGroupWebComponent = props => {
|
|
27
27
|
const {
|
|
@@ -5,6 +5,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
|
5
5
|
import { Avatar } from "../avatar/index.js";
|
|
6
6
|
import { useTheme } from "../theme/use-theme.js";
|
|
7
7
|
import { fontSize } from "../styles/tokens.js";
|
|
8
|
+
import { computeClusterLayout } from "./cluster-layout.js";
|
|
8
9
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
9
10
|
/** Ring (border) thickness around each avatar, in pixels. Matches the
|
|
10
11
|
* original Mention `AvatarStack` (a single hairline-ish 1px separator). */
|
|
@@ -12,6 +13,15 @@ const RING_WIDTH = 1;
|
|
|
12
13
|
/** Default horizontal overlap as a fraction of avatar size — the original
|
|
13
14
|
* stack pulled each avatar left by `size / 3`. */
|
|
14
15
|
const DEFAULT_OVERLAP_RATIO = 1 / 3;
|
|
16
|
+
/** Default `max` for the `stack`/`row` facepile layouts. */
|
|
17
|
+
const DEFAULT_MAX = 5;
|
|
18
|
+
/** Default `max` (visible cap) for the `cluster` layout — it packs densely. */
|
|
19
|
+
const CLUSTER_DEFAULT_MAX = 20;
|
|
20
|
+
/** Separator ring thickness for cluster bubbles, as a fraction of the box. */
|
|
21
|
+
const CLUSTER_RING_RATIO = 0.02;
|
|
22
|
+
/** Overflow "+N" text color — white reads on the grey chip in light + dark,
|
|
23
|
+
* matching the original Mention count circle (shared by every layout). */
|
|
24
|
+
const OVERFLOW_TEXT_COLOR = '#FFFFFF';
|
|
15
25
|
function getItemName(item) {
|
|
16
26
|
return item.displayName ?? item.name ?? item.username;
|
|
17
27
|
}
|
|
@@ -21,12 +31,61 @@ function getItemKey(item, index) {
|
|
|
21
31
|
|
|
22
32
|
/** Hover callbacks injected by the web fork; undefined (no-op) on native. */
|
|
23
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A single avatar cell shared by every layout: a circular, clipped container
|
|
36
|
+
* (`cellStyle`) holding one {@link Avatar}, positioned by `wrapperStyle`, and
|
|
37
|
+
* made pressable/hoverable when the relevant handlers are supplied. Layout
|
|
38
|
+
* (margins for the facepile, absolute coords for the cluster) lives entirely in
|
|
39
|
+
* the caller-provided styles so this stays layout-agnostic.
|
|
40
|
+
*/
|
|
41
|
+
function AvatarGroupCell({
|
|
42
|
+
item,
|
|
43
|
+
index,
|
|
44
|
+
innerSize,
|
|
45
|
+
variant,
|
|
46
|
+
showInitials,
|
|
47
|
+
cellStyle,
|
|
48
|
+
wrapperStyle,
|
|
49
|
+
onPressItem,
|
|
50
|
+
hoverHandlers
|
|
51
|
+
}) {
|
|
52
|
+
const name = getItemName(item);
|
|
53
|
+
const accessibilityLabel = item.username ? `${name ?? item.username} (@${item.username})` : name;
|
|
54
|
+
const interactive = typeof onPressItem === 'function';
|
|
55
|
+
const hoverable = typeof hoverHandlers?.onHoverIn === 'function' || typeof hoverHandlers?.onHoverOut === 'function';
|
|
56
|
+
const cell = /*#__PURE__*/_jsx(View, {
|
|
57
|
+
ref: hoverHandlers?.registerCellRef ? node => hoverHandlers.registerCellRef?.(index, node) : undefined,
|
|
58
|
+
collapsable: false,
|
|
59
|
+
style: cellStyle,
|
|
60
|
+
children: /*#__PURE__*/_jsx(Avatar, {
|
|
61
|
+
source: item.uri ?? undefined,
|
|
62
|
+
name: showInitials ? name : undefined,
|
|
63
|
+
variant: variant,
|
|
64
|
+
size: innerSize
|
|
65
|
+
})
|
|
66
|
+
});
|
|
67
|
+
if (interactive || hoverable) {
|
|
68
|
+
return /*#__PURE__*/_jsx(Pressable, {
|
|
69
|
+
onPress: interactive ? () => onPressItem?.(item, index) : undefined,
|
|
70
|
+
onHoverIn: hoverHandlers?.onHoverIn ? () => hoverHandlers.onHoverIn?.(item, index) : undefined,
|
|
71
|
+
onHoverOut: hoverHandlers?.onHoverOut ? () => hoverHandlers.onHoverOut?.(item, index) : undefined,
|
|
72
|
+
accessibilityRole: interactive ? 'button' : undefined,
|
|
73
|
+
accessibilityLabel: accessibilityLabel,
|
|
74
|
+
style: wrapperStyle,
|
|
75
|
+
children: cell
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return /*#__PURE__*/_jsx(View, {
|
|
79
|
+
style: wrapperStyle,
|
|
80
|
+
children: cell
|
|
81
|
+
});
|
|
82
|
+
}
|
|
24
83
|
const AvatarGroupBaseComponent = ({
|
|
25
84
|
items,
|
|
26
85
|
layout = 'stack',
|
|
27
86
|
size = 32,
|
|
28
87
|
variant = 'thumb',
|
|
29
|
-
max
|
|
88
|
+
max,
|
|
30
89
|
total,
|
|
31
90
|
overlap,
|
|
32
91
|
spacing,
|
|
@@ -38,6 +97,7 @@ const AvatarGroupBaseComponent = ({
|
|
|
38
97
|
hoverHandlers
|
|
39
98
|
}) => {
|
|
40
99
|
const theme = useTheme();
|
|
100
|
+
const isCluster = layout === 'cluster';
|
|
41
101
|
const isRow = layout === 'row';
|
|
42
102
|
|
|
43
103
|
// The ring is the thin separator drawn between overlapping avatars. It
|
|
@@ -45,6 +105,7 @@ const AvatarGroupBaseComponent = ({
|
|
|
45
105
|
// the surface behind them — matching the original Mention stack, which used
|
|
46
106
|
// `theme.colors.background`, not a large `card`-colored ring cell.
|
|
47
107
|
const ring = ringColor ?? theme.colors.background;
|
|
108
|
+
const effectiveMax = max ?? DEFAULT_MAX;
|
|
48
109
|
const effectiveOverlap = overlap ?? Math.round(size * DEFAULT_OVERLAP_RATIO);
|
|
49
110
|
// Each avatar after the first is pulled left by `overlap`. The cell carries a
|
|
50
111
|
// 1px border on each side, so the negative margin absorbs both borders to
|
|
@@ -54,7 +115,7 @@ const AvatarGroupBaseComponent = ({
|
|
|
54
115
|
const rowGap = spacing ?? Math.round(size * DEFAULT_OVERLAP_RATIO);
|
|
55
116
|
// Horizontal offset applied to every avatar after the first.
|
|
56
117
|
const itemMargin = isRow ? rowGap : negativeMargin;
|
|
57
|
-
const shown = useMemo(() => items.slice(0, Math.max(0,
|
|
118
|
+
const shown = useMemo(() => items.slice(0, Math.max(0, effectiveMax)), [items, effectiveMax]);
|
|
58
119
|
const realTotal = total ?? items.length;
|
|
59
120
|
const overflow = Math.max(0, realTotal - shown.length);
|
|
60
121
|
|
|
@@ -84,24 +145,38 @@ const AvatarGroupBaseComponent = ({
|
|
|
84
145
|
};
|
|
85
146
|
}, [isRow, size, ring]);
|
|
86
147
|
const overflowTextStyle = useMemo(() => ({
|
|
87
|
-
color:
|
|
148
|
+
color: OVERFLOW_TEXT_COLOR,
|
|
88
149
|
fontSize: Math.max(fontSize._2xs, Math.round(size * 0.36)),
|
|
89
150
|
fontWeight: '600'
|
|
90
151
|
}), [size]);
|
|
91
152
|
const interactive = typeof onPressItem === 'function';
|
|
92
|
-
const hoverable = typeof hoverHandlers?.onHoverIn === 'function' || typeof hoverHandlers?.onHoverOut === 'function';
|
|
93
153
|
|
|
94
154
|
// Pressing the "+N" circle surfaces the first hidden member (if any), so
|
|
95
155
|
// consumers can route it to a full member list.
|
|
96
156
|
const firstHidden = items[shown.length];
|
|
97
157
|
const overflowOnPress = interactive && firstHidden ? () => onPressItem?.(firstHidden, shown.length) : undefined;
|
|
158
|
+
|
|
159
|
+
// The cluster is a compact 2D bubble pack with its own absolute-positioned
|
|
160
|
+
// renderer; hooks above still run unconditionally so the branch is safe.
|
|
161
|
+
if (isCluster) {
|
|
162
|
+
return /*#__PURE__*/_jsx(ClusterAvatarGroup, {
|
|
163
|
+
items: items,
|
|
164
|
+
size: size,
|
|
165
|
+
variant: variant,
|
|
166
|
+
max: max,
|
|
167
|
+
total: total,
|
|
168
|
+
ring: ring,
|
|
169
|
+
showInitials: showInitials,
|
|
170
|
+
onPressItem: onPressItem,
|
|
171
|
+
style: style,
|
|
172
|
+
testID: testID,
|
|
173
|
+
hoverHandlers: hoverHandlers
|
|
174
|
+
});
|
|
175
|
+
}
|
|
98
176
|
return /*#__PURE__*/_jsxs(View, {
|
|
99
177
|
style: [styles.row, style],
|
|
100
178
|
testID: testID,
|
|
101
179
|
children: [shown.map((item, index) => {
|
|
102
|
-
const name = getItemName(item);
|
|
103
|
-
const accessibilityLabel = item.username ? `${name ?? item.username} (@${item.username})` : name;
|
|
104
|
-
|
|
105
180
|
// The horizontal margin and stacking order live on the outermost row
|
|
106
181
|
// element so layout and hit-testing stay aligned with the visual
|
|
107
182
|
// overlap. In stack mode earlier siblings render on top of later ones
|
|
@@ -115,65 +190,159 @@ const AvatarGroupBaseComponent = ({
|
|
|
115
190
|
zIndex: shown.length - index
|
|
116
191
|
})
|
|
117
192
|
};
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
});
|
|
129
|
-
if (interactive || hoverable) {
|
|
130
|
-
return /*#__PURE__*/_jsx(Pressable, {
|
|
131
|
-
onPress: interactive ? () => onPressItem?.(item, index) : undefined,
|
|
132
|
-
onHoverIn: hoverHandlers?.onHoverIn ? () => hoverHandlers.onHoverIn?.(item, index) : undefined,
|
|
133
|
-
onHoverOut: hoverHandlers?.onHoverOut ? () => hoverHandlers.onHoverOut?.(item, index) : undefined,
|
|
134
|
-
accessibilityRole: interactive ? 'button' : undefined,
|
|
135
|
-
accessibilityLabel: accessibilityLabel,
|
|
136
|
-
style: wrapperStyle,
|
|
137
|
-
children: cell
|
|
138
|
-
}, getItemKey(item, index));
|
|
139
|
-
}
|
|
140
|
-
return /*#__PURE__*/_jsx(View, {
|
|
141
|
-
style: wrapperStyle,
|
|
142
|
-
children: cell
|
|
193
|
+
return /*#__PURE__*/_jsx(AvatarGroupCell, {
|
|
194
|
+
item: item,
|
|
195
|
+
index: index,
|
|
196
|
+
innerSize: innerSize,
|
|
197
|
+
variant: variant,
|
|
198
|
+
showInitials: showInitials,
|
|
199
|
+
cellStyle: cellStyle,
|
|
200
|
+
wrapperStyle: wrapperStyle,
|
|
201
|
+
onPressItem: onPressItem,
|
|
202
|
+
hoverHandlers: hoverHandlers
|
|
143
203
|
}, getItemKey(item, index));
|
|
144
204
|
}), overflow > 0 && /*#__PURE__*/_jsx(OverflowCircle, {
|
|
145
205
|
count: overflow,
|
|
146
206
|
cellStyle: cellStyle,
|
|
147
|
-
|
|
148
|
-
|
|
207
|
+
wrapperStyle: {
|
|
208
|
+
...(shown.length > 0 && {
|
|
209
|
+
marginLeft: itemMargin
|
|
210
|
+
}),
|
|
211
|
+
...(isRow ? {} : {
|
|
212
|
+
zIndex: 0
|
|
213
|
+
})
|
|
214
|
+
},
|
|
149
215
|
textStyle: overflowTextStyle,
|
|
150
216
|
onPress: overflowOnPress
|
|
151
217
|
})]
|
|
152
218
|
});
|
|
153
219
|
};
|
|
220
|
+
/**
|
|
221
|
+
* The `cluster` layout: an iMessage-style magnetic bubble pack. Members are laid
|
|
222
|
+
* out by {@link computeClusterLayout} (a deterministic force-directed pack) into
|
|
223
|
+
* box-fraction bubbles, then absolutely positioned inside a `size × size` box so
|
|
224
|
+
* the whole cluster drops in where a single round Avatar would. The largest
|
|
225
|
+
* member (index 0) sits centred and in front; the rest pack around it with a
|
|
226
|
+
* uniform gap. Overflow beyond `max` collapses into a trailing "+N" bubble.
|
|
227
|
+
*/
|
|
228
|
+
const ClusterAvatarGroup = ({
|
|
229
|
+
items,
|
|
230
|
+
size,
|
|
231
|
+
variant,
|
|
232
|
+
max,
|
|
233
|
+
total,
|
|
234
|
+
ring,
|
|
235
|
+
showInitials,
|
|
236
|
+
onPressItem,
|
|
237
|
+
style,
|
|
238
|
+
testID,
|
|
239
|
+
hoverHandlers
|
|
240
|
+
}) => {
|
|
241
|
+
const cap = Math.max(1, max ?? CLUSTER_DEFAULT_MAX);
|
|
242
|
+
const realTotal = total ?? items.length;
|
|
243
|
+
const hasOverflow = realTotal > cap;
|
|
244
|
+
// Reserve the last bubble for the "+N" chip when there are more members than
|
|
245
|
+
// the cap; otherwise every capped member gets its own bubble.
|
|
246
|
+
const avatarCap = hasOverflow ? Math.max(0, cap - 1) : cap;
|
|
247
|
+
const shown = useMemo(() => items.slice(0, avatarCap), [items, avatarCap]);
|
|
248
|
+
const overflow = hasOverflow ? Math.max(0, realTotal - shown.length) : 0;
|
|
249
|
+
const bubbleCount = shown.length + (overflow > 0 ? 1 : 0);
|
|
250
|
+
const bubbles = useMemo(() => computeClusterLayout(bubbleCount), [bubbleCount]);
|
|
251
|
+
const ringWidth = Math.max(1, Math.round(size * CLUSTER_RING_RATIO));
|
|
252
|
+
const interactive = typeof onPressItem === 'function';
|
|
253
|
+
|
|
254
|
+
// Pressing "+N" surfaces the first hidden member (parity with the stack).
|
|
255
|
+
const firstHidden = items[shown.length];
|
|
256
|
+
const overflowOnPress = interactive && firstHidden ? () => onPressItem?.(firstHidden, shown.length) : undefined;
|
|
257
|
+
const overflowBubble = overflow > 0 ? bubbles[bubbleCount - 1] : undefined;
|
|
258
|
+
let overflowElement = null;
|
|
259
|
+
if (overflow > 0 && overflowBubble) {
|
|
260
|
+
const diameter = overflowBubble.d * size;
|
|
261
|
+
overflowElement = /*#__PURE__*/_jsx(OverflowCircle, {
|
|
262
|
+
count: overflow,
|
|
263
|
+
cellStyle: {
|
|
264
|
+
width: diameter,
|
|
265
|
+
height: diameter,
|
|
266
|
+
borderRadius: diameter / 2,
|
|
267
|
+
borderWidth: ringWidth,
|
|
268
|
+
borderColor: ring,
|
|
269
|
+
alignItems: 'center',
|
|
270
|
+
justifyContent: 'center'
|
|
271
|
+
},
|
|
272
|
+
wrapperStyle: {
|
|
273
|
+
position: 'absolute',
|
|
274
|
+
left: overflowBubble.cx * size - diameter / 2,
|
|
275
|
+
top: overflowBubble.cy * size - diameter / 2,
|
|
276
|
+
width: diameter,
|
|
277
|
+
height: diameter,
|
|
278
|
+
zIndex: 0
|
|
279
|
+
},
|
|
280
|
+
textStyle: {
|
|
281
|
+
color: OVERFLOW_TEXT_COLOR,
|
|
282
|
+
fontSize: Math.max(fontSize._2xs, Math.round(diameter * 0.34)),
|
|
283
|
+
fontWeight: '600'
|
|
284
|
+
},
|
|
285
|
+
onPress: overflowOnPress
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
289
|
+
style: [{
|
|
290
|
+
width: size,
|
|
291
|
+
height: size
|
|
292
|
+
}, style],
|
|
293
|
+
testID: testID,
|
|
294
|
+
children: [shown.map((item, index) => {
|
|
295
|
+
const bubble = bubbles[index];
|
|
296
|
+
if (!bubble) return null;
|
|
297
|
+
const diameter = bubble.d * size;
|
|
298
|
+
const inner = Math.max(1, diameter - ringWidth * 2);
|
|
299
|
+
const wrapperStyle = {
|
|
300
|
+
position: 'absolute',
|
|
301
|
+
left: bubble.cx * size - diameter / 2,
|
|
302
|
+
top: bubble.cy * size - diameter / 2,
|
|
303
|
+
width: diameter,
|
|
304
|
+
height: diameter,
|
|
305
|
+
// Primary (index 0) is largest and sits in front; later members tuck
|
|
306
|
+
// behind in descending order (the "+N" chip lands lowest).
|
|
307
|
+
zIndex: bubbleCount - index
|
|
308
|
+
};
|
|
309
|
+
const cellStyle = {
|
|
310
|
+
width: diameter,
|
|
311
|
+
height: diameter,
|
|
312
|
+
borderRadius: diameter / 2,
|
|
313
|
+
borderWidth: ringWidth,
|
|
314
|
+
borderColor: ring,
|
|
315
|
+
overflow: 'hidden',
|
|
316
|
+
alignItems: 'center',
|
|
317
|
+
justifyContent: 'center'
|
|
318
|
+
};
|
|
319
|
+
return /*#__PURE__*/_jsx(AvatarGroupCell, {
|
|
320
|
+
item: item,
|
|
321
|
+
index: index,
|
|
322
|
+
innerSize: inner,
|
|
323
|
+
variant: variant,
|
|
324
|
+
showInitials: showInitials,
|
|
325
|
+
cellStyle: cellStyle,
|
|
326
|
+
wrapperStyle: wrapperStyle,
|
|
327
|
+
onPressItem: onPressItem,
|
|
328
|
+
hoverHandlers: hoverHandlers
|
|
329
|
+
}, getItemKey(item, index));
|
|
330
|
+
}), overflowElement]
|
|
331
|
+
});
|
|
332
|
+
};
|
|
154
333
|
function OverflowCircle({
|
|
155
334
|
count,
|
|
156
335
|
cellStyle,
|
|
157
|
-
|
|
158
|
-
isRow,
|
|
336
|
+
wrapperStyle,
|
|
159
337
|
textStyle,
|
|
160
338
|
onPress
|
|
161
339
|
}) {
|
|
162
340
|
const theme = useTheme();
|
|
163
|
-
// In stack mode the count circle is anchored at the lowest stacking order so
|
|
164
|
-
// it tucks behind the last avatar's ring; row mode has no overlap. The margin
|
|
165
|
-
// lives on the outer element to keep layout aligned with the spacing.
|
|
166
|
-
const wrapperStyle = {
|
|
167
|
-
...(marginLeft !== 0 && {
|
|
168
|
-
marginLeft
|
|
169
|
-
}),
|
|
170
|
-
...(isRow ? {} : {
|
|
171
|
-
zIndex: 0
|
|
172
|
-
})
|
|
173
|
-
};
|
|
174
341
|
|
|
175
342
|
// A solid "+N" count circle: secondary-text-colored fill with white text,
|
|
176
|
-
// matching the original Mention `ResponsiveAvatarStack` count circle.
|
|
343
|
+
// matching the original Mention `ResponsiveAvatarStack` count circle. Layout
|
|
344
|
+
// (margin for the facepile, absolute coords for the cluster) is supplied by
|
|
345
|
+
// the caller via `wrapperStyle`.
|
|
177
346
|
const circle = /*#__PURE__*/_jsx(View, {
|
|
178
347
|
style: [cellStyle, {
|
|
179
348
|
backgroundColor: theme.colors.textSecondary
|