@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
|
@@ -4,12 +4,15 @@ import {
|
|
|
4
4
|
StyleSheet,
|
|
5
5
|
Text,
|
|
6
6
|
View,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type TextStyle,
|
|
7
9
|
type ViewStyle,
|
|
8
10
|
} from 'react-native';
|
|
9
11
|
|
|
10
12
|
import { Avatar } from '../avatar';
|
|
11
13
|
import { useTheme } from '../theme/use-theme';
|
|
12
14
|
import { fontSize } from '../styles/tokens';
|
|
15
|
+
import { computeClusterLayout } from './cluster-layout';
|
|
13
16
|
import type { AvatarGroupItem, AvatarGroupProps } from './types';
|
|
14
17
|
|
|
15
18
|
/** Ring (border) thickness around each avatar, in pixels. Matches the
|
|
@@ -18,6 +21,15 @@ const RING_WIDTH = 1;
|
|
|
18
21
|
/** Default horizontal overlap as a fraction of avatar size — the original
|
|
19
22
|
* stack pulled each avatar left by `size / 3`. */
|
|
20
23
|
const DEFAULT_OVERLAP_RATIO = 1 / 3;
|
|
24
|
+
/** Default `max` for the `stack`/`row` facepile layouts. */
|
|
25
|
+
const DEFAULT_MAX = 5;
|
|
26
|
+
/** Default `max` (visible cap) for the `cluster` layout — it packs densely. */
|
|
27
|
+
const CLUSTER_DEFAULT_MAX = 20;
|
|
28
|
+
/** Separator ring thickness for cluster bubbles, as a fraction of the box. */
|
|
29
|
+
const CLUSTER_RING_RATIO = 0.02;
|
|
30
|
+
/** Overflow "+N" text color — white reads on the grey chip in light + dark,
|
|
31
|
+
* matching the original Mention count circle (shared by every layout). */
|
|
32
|
+
const OVERFLOW_TEXT_COLOR = '#FFFFFF';
|
|
21
33
|
|
|
22
34
|
function getItemName(item: AvatarGroupItem): string | undefined {
|
|
23
35
|
return item.displayName ?? item.name ?? item.username;
|
|
@@ -39,12 +51,103 @@ interface AvatarGroupBaseProps extends AvatarGroupProps {
|
|
|
39
51
|
hoverHandlers?: AvatarGroupCellHoverHandlers;
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A single avatar cell shared by every layout: a circular, clipped container
|
|
56
|
+
* (`cellStyle`) holding one {@link Avatar}, positioned by `wrapperStyle`, and
|
|
57
|
+
* made pressable/hoverable when the relevant handlers are supplied. Layout
|
|
58
|
+
* (margins for the facepile, absolute coords for the cluster) lives entirely in
|
|
59
|
+
* the caller-provided styles so this stays layout-agnostic.
|
|
60
|
+
*/
|
|
61
|
+
function AvatarGroupCell({
|
|
62
|
+
item,
|
|
63
|
+
index,
|
|
64
|
+
innerSize,
|
|
65
|
+
variant,
|
|
66
|
+
showInitials,
|
|
67
|
+
cellStyle,
|
|
68
|
+
wrapperStyle,
|
|
69
|
+
onPressItem,
|
|
70
|
+
hoverHandlers,
|
|
71
|
+
}: {
|
|
72
|
+
item: AvatarGroupItem;
|
|
73
|
+
index: number;
|
|
74
|
+
innerSize: number;
|
|
75
|
+
variant?: string;
|
|
76
|
+
showInitials: boolean;
|
|
77
|
+
cellStyle: StyleProp<ViewStyle>;
|
|
78
|
+
wrapperStyle: StyleProp<ViewStyle>;
|
|
79
|
+
onPressItem?: (item: AvatarGroupItem, index: number) => void;
|
|
80
|
+
hoverHandlers?: AvatarGroupCellHoverHandlers;
|
|
81
|
+
}) {
|
|
82
|
+
const name = getItemName(item);
|
|
83
|
+
const accessibilityLabel = item.username
|
|
84
|
+
? `${name ?? item.username} (@${item.username})`
|
|
85
|
+
: name;
|
|
86
|
+
const interactive = typeof onPressItem === 'function';
|
|
87
|
+
const hoverable =
|
|
88
|
+
typeof hoverHandlers?.onHoverIn === 'function' ||
|
|
89
|
+
typeof hoverHandlers?.onHoverOut === 'function';
|
|
90
|
+
|
|
91
|
+
const cell = (
|
|
92
|
+
<View
|
|
93
|
+
ref={
|
|
94
|
+
hoverHandlers?.registerCellRef
|
|
95
|
+
? (node) => hoverHandlers.registerCellRef?.(index, node)
|
|
96
|
+
: undefined
|
|
97
|
+
}
|
|
98
|
+
collapsable={false}
|
|
99
|
+
style={cellStyle}
|
|
100
|
+
>
|
|
101
|
+
{/*
|
|
102
|
+
Route the item's avatar value through Avatar's `source` prop (not `uri`):
|
|
103
|
+
full URLs pass through directly, while resolver-handled ids (e.g. Oxy
|
|
104
|
+
file IDs) are resolved by the consumer's ImageResolver. `variant`
|
|
105
|
+
(default `'thumb'`) is forwarded so the resolver builds a small rendition.
|
|
106
|
+
A missing avatar shows Avatar's neutral default placeholder unless
|
|
107
|
+
`showInitials` is set, in which case the item's name renders a colored
|
|
108
|
+
initial.
|
|
109
|
+
*/}
|
|
110
|
+
<Avatar
|
|
111
|
+
source={item.uri ?? undefined}
|
|
112
|
+
name={showInitials ? name : undefined}
|
|
113
|
+
variant={variant}
|
|
114
|
+
size={innerSize}
|
|
115
|
+
/>
|
|
116
|
+
</View>
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (interactive || hoverable) {
|
|
120
|
+
return (
|
|
121
|
+
<Pressable
|
|
122
|
+
onPress={interactive ? () => onPressItem?.(item, index) : undefined}
|
|
123
|
+
onHoverIn={
|
|
124
|
+
hoverHandlers?.onHoverIn
|
|
125
|
+
? () => hoverHandlers.onHoverIn?.(item, index)
|
|
126
|
+
: undefined
|
|
127
|
+
}
|
|
128
|
+
onHoverOut={
|
|
129
|
+
hoverHandlers?.onHoverOut
|
|
130
|
+
? () => hoverHandlers.onHoverOut?.(item, index)
|
|
131
|
+
: undefined
|
|
132
|
+
}
|
|
133
|
+
accessibilityRole={interactive ? 'button' : undefined}
|
|
134
|
+
accessibilityLabel={accessibilityLabel}
|
|
135
|
+
style={wrapperStyle}
|
|
136
|
+
>
|
|
137
|
+
{cell}
|
|
138
|
+
</Pressable>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return <View style={wrapperStyle}>{cell}</View>;
|
|
143
|
+
}
|
|
144
|
+
|
|
42
145
|
const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
|
|
43
146
|
items,
|
|
44
147
|
layout = 'stack',
|
|
45
148
|
size = 32,
|
|
46
149
|
variant = 'thumb',
|
|
47
|
-
max
|
|
150
|
+
max,
|
|
48
151
|
total,
|
|
49
152
|
overlap,
|
|
50
153
|
spacing,
|
|
@@ -56,6 +159,7 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
|
|
|
56
159
|
hoverHandlers,
|
|
57
160
|
}) => {
|
|
58
161
|
const theme = useTheme();
|
|
162
|
+
const isCluster = layout === 'cluster';
|
|
59
163
|
const isRow = layout === 'row';
|
|
60
164
|
|
|
61
165
|
// The ring is the thin separator drawn between overlapping avatars. It
|
|
@@ -63,6 +167,8 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
|
|
|
63
167
|
// the surface behind them — matching the original Mention stack, which used
|
|
64
168
|
// `theme.colors.background`, not a large `card`-colored ring cell.
|
|
65
169
|
const ring = ringColor ?? theme.colors.background;
|
|
170
|
+
|
|
171
|
+
const effectiveMax = max ?? DEFAULT_MAX;
|
|
66
172
|
const effectiveOverlap =
|
|
67
173
|
overlap ?? Math.round(size * DEFAULT_OVERLAP_RATIO);
|
|
68
174
|
// Each avatar after the first is pulled left by `overlap`. The cell carries a
|
|
@@ -75,8 +181,8 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
|
|
|
75
181
|
const itemMargin = isRow ? rowGap : negativeMargin;
|
|
76
182
|
|
|
77
183
|
const shown = useMemo(
|
|
78
|
-
() => items.slice(0, Math.max(0,
|
|
79
|
-
[items,
|
|
184
|
+
() => items.slice(0, Math.max(0, effectiveMax)),
|
|
185
|
+
[items, effectiveMax],
|
|
80
186
|
);
|
|
81
187
|
|
|
82
188
|
const realTotal = total ?? items.length;
|
|
@@ -109,18 +215,15 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
|
|
|
109
215
|
}, [isRow, size, ring]);
|
|
110
216
|
|
|
111
217
|
const overflowTextStyle = useMemo(
|
|
112
|
-
() => ({
|
|
113
|
-
color:
|
|
218
|
+
(): TextStyle => ({
|
|
219
|
+
color: OVERFLOW_TEXT_COLOR,
|
|
114
220
|
fontSize: Math.max(fontSize._2xs, Math.round(size * 0.36)),
|
|
115
|
-
fontWeight: '600'
|
|
221
|
+
fontWeight: '600',
|
|
116
222
|
}),
|
|
117
223
|
[size],
|
|
118
224
|
);
|
|
119
225
|
|
|
120
226
|
const interactive = typeof onPressItem === 'function';
|
|
121
|
-
const hoverable =
|
|
122
|
-
typeof hoverHandlers?.onHoverIn === 'function' ||
|
|
123
|
-
typeof hoverHandlers?.onHoverOut === 'function';
|
|
124
227
|
|
|
125
228
|
// Pressing the "+N" circle surfaces the first hidden member (if any), so
|
|
126
229
|
// consumers can route it to a full member list.
|
|
@@ -130,14 +233,29 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
|
|
|
130
233
|
? () => onPressItem?.(firstHidden, shown.length)
|
|
131
234
|
: undefined;
|
|
132
235
|
|
|
236
|
+
// The cluster is a compact 2D bubble pack with its own absolute-positioned
|
|
237
|
+
// renderer; hooks above still run unconditionally so the branch is safe.
|
|
238
|
+
if (isCluster) {
|
|
239
|
+
return (
|
|
240
|
+
<ClusterAvatarGroup
|
|
241
|
+
items={items}
|
|
242
|
+
size={size}
|
|
243
|
+
variant={variant}
|
|
244
|
+
max={max}
|
|
245
|
+
total={total}
|
|
246
|
+
ring={ring}
|
|
247
|
+
showInitials={showInitials}
|
|
248
|
+
onPressItem={onPressItem}
|
|
249
|
+
style={style}
|
|
250
|
+
testID={testID}
|
|
251
|
+
hoverHandlers={hoverHandlers}
|
|
252
|
+
/>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
133
256
|
return (
|
|
134
257
|
<View style={[styles.row, style]} testID={testID}>
|
|
135
258
|
{shown.map((item, index) => {
|
|
136
|
-
const name = getItemName(item);
|
|
137
|
-
const accessibilityLabel = item.username
|
|
138
|
-
? `${name ?? item.username} (@${item.username})`
|
|
139
|
-
: name;
|
|
140
|
-
|
|
141
259
|
// The horizontal margin and stacking order live on the outermost row
|
|
142
260
|
// element so layout and hit-testing stay aligned with the visual
|
|
143
261
|
// overlap. In stack mode earlier siblings render on top of later ones
|
|
@@ -148,66 +266,19 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
|
|
|
148
266
|
...(isRow ? {} : { zIndex: shown.length - index }),
|
|
149
267
|
};
|
|
150
268
|
|
|
151
|
-
const cell = (
|
|
152
|
-
<View
|
|
153
|
-
ref={
|
|
154
|
-
hoverHandlers?.registerCellRef
|
|
155
|
-
? (node) => hoverHandlers.registerCellRef?.(index, node)
|
|
156
|
-
: undefined
|
|
157
|
-
}
|
|
158
|
-
collapsable={false}
|
|
159
|
-
style={cellStyle}
|
|
160
|
-
>
|
|
161
|
-
{/*
|
|
162
|
-
Route the item's avatar value through Avatar's `source` prop (not
|
|
163
|
-
`uri`): full URLs pass through directly, while resolver-handled ids
|
|
164
|
-
(e.g. Oxy file IDs) are resolved by the consumer's ImageResolver —
|
|
165
|
-
exactly like the original Mention stack. `variant` (default
|
|
166
|
-
`'thumb'`) is forwarded so the resolver builds a small rendition
|
|
167
|
-
for these stacked avatars. No `name` is passed, so a missing
|
|
168
|
-
avatar shows Avatar's neutral default placeholder rather than a
|
|
169
|
-
colored deterministic initial. When `showInitials` is set the item's
|
|
170
|
-
name IS passed, so an avatar-less item renders a colored initial.
|
|
171
|
-
*/}
|
|
172
|
-
<Avatar
|
|
173
|
-
source={item.uri ?? undefined}
|
|
174
|
-
name={showInitials ? name : undefined}
|
|
175
|
-
variant={variant}
|
|
176
|
-
size={innerSize}
|
|
177
|
-
/>
|
|
178
|
-
</View>
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
if (interactive || hoverable) {
|
|
182
|
-
return (
|
|
183
|
-
<Pressable
|
|
184
|
-
key={getItemKey(item, index)}
|
|
185
|
-
onPress={
|
|
186
|
-
interactive ? () => onPressItem?.(item, index) : undefined
|
|
187
|
-
}
|
|
188
|
-
onHoverIn={
|
|
189
|
-
hoverHandlers?.onHoverIn
|
|
190
|
-
? () => hoverHandlers.onHoverIn?.(item, index)
|
|
191
|
-
: undefined
|
|
192
|
-
}
|
|
193
|
-
onHoverOut={
|
|
194
|
-
hoverHandlers?.onHoverOut
|
|
195
|
-
? () => hoverHandlers.onHoverOut?.(item, index)
|
|
196
|
-
: undefined
|
|
197
|
-
}
|
|
198
|
-
accessibilityRole={interactive ? 'button' : undefined}
|
|
199
|
-
accessibilityLabel={accessibilityLabel}
|
|
200
|
-
style={wrapperStyle}
|
|
201
|
-
>
|
|
202
|
-
{cell}
|
|
203
|
-
</Pressable>
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
269
|
return (
|
|
208
|
-
<
|
|
209
|
-
{
|
|
210
|
-
|
|
270
|
+
<AvatarGroupCell
|
|
271
|
+
key={getItemKey(item, index)}
|
|
272
|
+
item={item}
|
|
273
|
+
index={index}
|
|
274
|
+
innerSize={innerSize}
|
|
275
|
+
variant={variant}
|
|
276
|
+
showInitials={showInitials}
|
|
277
|
+
cellStyle={cellStyle}
|
|
278
|
+
wrapperStyle={wrapperStyle}
|
|
279
|
+
onPressItem={onPressItem}
|
|
280
|
+
hoverHandlers={hoverHandlers}
|
|
281
|
+
/>
|
|
211
282
|
);
|
|
212
283
|
})}
|
|
213
284
|
|
|
@@ -215,8 +286,10 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
|
|
|
215
286
|
<OverflowCircle
|
|
216
287
|
count={overflow}
|
|
217
288
|
cellStyle={cellStyle}
|
|
218
|
-
|
|
219
|
-
|
|
289
|
+
wrapperStyle={{
|
|
290
|
+
...(shown.length > 0 && { marginLeft: itemMargin }),
|
|
291
|
+
...(isRow ? {} : { zIndex: 0 }),
|
|
292
|
+
}}
|
|
220
293
|
textStyle={overflowTextStyle}
|
|
221
294
|
onPress={overflowOnPress}
|
|
222
295
|
/>
|
|
@@ -225,32 +298,168 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
|
|
|
225
298
|
);
|
|
226
299
|
};
|
|
227
300
|
|
|
301
|
+
interface ClusterAvatarGroupProps {
|
|
302
|
+
items: AvatarGroupItem[];
|
|
303
|
+
size: number;
|
|
304
|
+
variant?: string;
|
|
305
|
+
max?: number;
|
|
306
|
+
total?: number;
|
|
307
|
+
ring: string;
|
|
308
|
+
showInitials: boolean;
|
|
309
|
+
onPressItem?: (item: AvatarGroupItem, index: number) => void;
|
|
310
|
+
style?: StyleProp<ViewStyle>;
|
|
311
|
+
testID?: string;
|
|
312
|
+
hoverHandlers?: AvatarGroupCellHoverHandlers;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The `cluster` layout: an iMessage-style magnetic bubble pack. Members are laid
|
|
317
|
+
* out by {@link computeClusterLayout} (a deterministic force-directed pack) into
|
|
318
|
+
* box-fraction bubbles, then absolutely positioned inside a `size × size` box so
|
|
319
|
+
* the whole cluster drops in where a single round Avatar would. The largest
|
|
320
|
+
* member (index 0) sits centred and in front; the rest pack around it with a
|
|
321
|
+
* uniform gap. Overflow beyond `max` collapses into a trailing "+N" bubble.
|
|
322
|
+
*/
|
|
323
|
+
const ClusterAvatarGroup: React.FC<ClusterAvatarGroupProps> = ({
|
|
324
|
+
items,
|
|
325
|
+
size,
|
|
326
|
+
variant,
|
|
327
|
+
max,
|
|
328
|
+
total,
|
|
329
|
+
ring,
|
|
330
|
+
showInitials,
|
|
331
|
+
onPressItem,
|
|
332
|
+
style,
|
|
333
|
+
testID,
|
|
334
|
+
hoverHandlers,
|
|
335
|
+
}) => {
|
|
336
|
+
const cap = Math.max(1, max ?? CLUSTER_DEFAULT_MAX);
|
|
337
|
+
const realTotal = total ?? items.length;
|
|
338
|
+
const hasOverflow = realTotal > cap;
|
|
339
|
+
// Reserve the last bubble for the "+N" chip when there are more members than
|
|
340
|
+
// the cap; otherwise every capped member gets its own bubble.
|
|
341
|
+
const avatarCap = hasOverflow ? Math.max(0, cap - 1) : cap;
|
|
342
|
+
const shown = useMemo(
|
|
343
|
+
() => items.slice(0, avatarCap),
|
|
344
|
+
[items, avatarCap],
|
|
345
|
+
);
|
|
346
|
+
const overflow = hasOverflow ? Math.max(0, realTotal - shown.length) : 0;
|
|
347
|
+
const bubbleCount = shown.length + (overflow > 0 ? 1 : 0);
|
|
348
|
+
const bubbles = useMemo(
|
|
349
|
+
() => computeClusterLayout(bubbleCount),
|
|
350
|
+
[bubbleCount],
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const ringWidth = Math.max(1, Math.round(size * CLUSTER_RING_RATIO));
|
|
354
|
+
const interactive = typeof onPressItem === 'function';
|
|
355
|
+
|
|
356
|
+
// Pressing "+N" surfaces the first hidden member (parity with the stack).
|
|
357
|
+
const firstHidden = items[shown.length];
|
|
358
|
+
const overflowOnPress =
|
|
359
|
+
interactive && firstHidden
|
|
360
|
+
? () => onPressItem?.(firstHidden, shown.length)
|
|
361
|
+
: undefined;
|
|
362
|
+
|
|
363
|
+
const overflowBubble = overflow > 0 ? bubbles[bubbleCount - 1] : undefined;
|
|
364
|
+
let overflowElement: React.ReactNode = null;
|
|
365
|
+
if (overflow > 0 && overflowBubble) {
|
|
366
|
+
const diameter = overflowBubble.d * size;
|
|
367
|
+
overflowElement = (
|
|
368
|
+
<OverflowCircle
|
|
369
|
+
count={overflow}
|
|
370
|
+
cellStyle={{
|
|
371
|
+
width: diameter,
|
|
372
|
+
height: diameter,
|
|
373
|
+
borderRadius: diameter / 2,
|
|
374
|
+
borderWidth: ringWidth,
|
|
375
|
+
borderColor: ring,
|
|
376
|
+
alignItems: 'center',
|
|
377
|
+
justifyContent: 'center',
|
|
378
|
+
}}
|
|
379
|
+
wrapperStyle={{
|
|
380
|
+
position: 'absolute',
|
|
381
|
+
left: overflowBubble.cx * size - diameter / 2,
|
|
382
|
+
top: overflowBubble.cy * size - diameter / 2,
|
|
383
|
+
width: diameter,
|
|
384
|
+
height: diameter,
|
|
385
|
+
zIndex: 0,
|
|
386
|
+
}}
|
|
387
|
+
textStyle={{
|
|
388
|
+
color: OVERFLOW_TEXT_COLOR,
|
|
389
|
+
fontSize: Math.max(fontSize._2xs, Math.round(diameter * 0.34)),
|
|
390
|
+
fontWeight: '600',
|
|
391
|
+
}}
|
|
392
|
+
onPress={overflowOnPress}
|
|
393
|
+
/>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<View style={[{ width: size, height: size }, style]} testID={testID}>
|
|
399
|
+
{shown.map((item, index) => {
|
|
400
|
+
const bubble = bubbles[index];
|
|
401
|
+
if (!bubble) return null;
|
|
402
|
+
const diameter = bubble.d * size;
|
|
403
|
+
const inner = Math.max(1, diameter - ringWidth * 2);
|
|
404
|
+
const wrapperStyle: ViewStyle = {
|
|
405
|
+
position: 'absolute',
|
|
406
|
+
left: bubble.cx * size - diameter / 2,
|
|
407
|
+
top: bubble.cy * size - diameter / 2,
|
|
408
|
+
width: diameter,
|
|
409
|
+
height: diameter,
|
|
410
|
+
// Primary (index 0) is largest and sits in front; later members tuck
|
|
411
|
+
// behind in descending order (the "+N" chip lands lowest).
|
|
412
|
+
zIndex: bubbleCount - index,
|
|
413
|
+
};
|
|
414
|
+
const cellStyle: ViewStyle = {
|
|
415
|
+
width: diameter,
|
|
416
|
+
height: diameter,
|
|
417
|
+
borderRadius: diameter / 2,
|
|
418
|
+
borderWidth: ringWidth,
|
|
419
|
+
borderColor: ring,
|
|
420
|
+
overflow: 'hidden',
|
|
421
|
+
alignItems: 'center',
|
|
422
|
+
justifyContent: 'center',
|
|
423
|
+
};
|
|
424
|
+
return (
|
|
425
|
+
<AvatarGroupCell
|
|
426
|
+
key={getItemKey(item, index)}
|
|
427
|
+
item={item}
|
|
428
|
+
index={index}
|
|
429
|
+
innerSize={inner}
|
|
430
|
+
variant={variant}
|
|
431
|
+
showInitials={showInitials}
|
|
432
|
+
cellStyle={cellStyle}
|
|
433
|
+
wrapperStyle={wrapperStyle}
|
|
434
|
+
onPressItem={onPressItem}
|
|
435
|
+
hoverHandlers={hoverHandlers}
|
|
436
|
+
/>
|
|
437
|
+
);
|
|
438
|
+
})}
|
|
439
|
+
{overflowElement}
|
|
440
|
+
</View>
|
|
441
|
+
);
|
|
442
|
+
};
|
|
443
|
+
|
|
228
444
|
function OverflowCircle({
|
|
229
445
|
count,
|
|
230
446
|
cellStyle,
|
|
231
|
-
|
|
232
|
-
isRow,
|
|
447
|
+
wrapperStyle,
|
|
233
448
|
textStyle,
|
|
234
449
|
onPress,
|
|
235
450
|
}: {
|
|
236
451
|
count: number;
|
|
237
|
-
cellStyle: ViewStyle
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
textStyle: { color: string; fontSize: number; fontWeight: '600' };
|
|
452
|
+
cellStyle: StyleProp<ViewStyle>;
|
|
453
|
+
wrapperStyle: StyleProp<ViewStyle>;
|
|
454
|
+
textStyle: TextStyle;
|
|
241
455
|
onPress?: () => void;
|
|
242
456
|
}) {
|
|
243
457
|
const theme = useTheme();
|
|
244
|
-
// In stack mode the count circle is anchored at the lowest stacking order so
|
|
245
|
-
// it tucks behind the last avatar's ring; row mode has no overlap. The margin
|
|
246
|
-
// lives on the outer element to keep layout aligned with the spacing.
|
|
247
|
-
const wrapperStyle: ViewStyle = {
|
|
248
|
-
...(marginLeft !== 0 && { marginLeft }),
|
|
249
|
-
...(isRow ? {} : { zIndex: 0 }),
|
|
250
|
-
};
|
|
251
458
|
|
|
252
459
|
// A solid "+N" count circle: secondary-text-colored fill with white text,
|
|
253
|
-
// matching the original Mention `ResponsiveAvatarStack` count circle.
|
|
460
|
+
// matching the original Mention `ResponsiveAvatarStack` count circle. Layout
|
|
461
|
+
// (margin for the facepile, absolute coords for the cluster) is supplied by
|
|
462
|
+
// the caller via `wrapperStyle`.
|
|
254
463
|
const circle = (
|
|
255
464
|
<View
|
|
256
465
|
style={[cellStyle, { backgroundColor: theme.colors.textSecondary }]}
|