@oxyhq/bloom 0.8.6 → 0.9.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.
- package/lib/commonjs/avatar-group/AvatarGroup.js +26 -0
- package/lib/commonjs/avatar-group/AvatarGroup.js.map +1 -0
- package/lib/commonjs/avatar-group/AvatarGroup.web.js +116 -0
- package/lib/commonjs/avatar-group/AvatarGroup.web.js.map +1 -0
- package/lib/commonjs/avatar-group/AvatarGroupBase.js +184 -0
- package/lib/commonjs/avatar-group/AvatarGroupBase.js.map +1 -0
- package/lib/commonjs/avatar-group/index.js +13 -0
- package/lib/commonjs/avatar-group/index.js.map +1 -0
- package/lib/commonjs/avatar-group/index.web.js +13 -0
- package/lib/commonjs/avatar-group/index.web.js.map +1 -0
- package/lib/commonjs/avatar-group/types.js +6 -0
- package/lib/commonjs/avatar-group/types.js.map +1 -0
- package/lib/commonjs/index.js +60 -44
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +74 -58
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/user-hover-card/UserHoverCard.js +193 -0
- package/lib/commonjs/user-hover-card/UserHoverCard.js.map +1 -0
- package/lib/commonjs/user-hover-card/index.js +13 -0
- package/lib/commonjs/user-hover-card/index.js.map +1 -0
- package/lib/commonjs/user-hover-card/types.js +6 -0
- package/lib/commonjs/user-hover-card/types.js.map +1 -0
- package/lib/module/avatar-group/AvatarGroup.js +21 -0
- package/lib/module/avatar-group/AvatarGroup.js.map +1 -0
- package/lib/module/avatar-group/AvatarGroup.web.js +111 -0
- package/lib/module/avatar-group/AvatarGroup.web.js.map +1 -0
- package/lib/module/avatar-group/AvatarGroupBase.js +179 -0
- package/lib/module/avatar-group/AvatarGroupBase.js.map +1 -0
- package/lib/module/avatar-group/index.js +4 -0
- package/lib/module/avatar-group/index.js.map +1 -0
- package/lib/module/avatar-group/index.web.js +4 -0
- package/lib/module/avatar-group/index.web.js.map +1 -0
- package/lib/module/avatar-group/types.js +4 -0
- package/lib/module/avatar-group/types.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +2 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/user-hover-card/UserHoverCard.js +188 -0
- package/lib/module/user-hover-card/UserHoverCard.js.map +1 -0
- package/lib/module/user-hover-card/index.js +4 -0
- package/lib/module/user-hover-card/index.js.map +1 -0
- package/lib/module/user-hover-card/types.js +4 -0
- package/lib/module/user-hover-card/types.js.map +1 -0
- package/lib/typescript/commonjs/avatar-group/AvatarGroup.d.ts +4 -0
- package/lib/typescript/commonjs/avatar-group/AvatarGroup.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar-group/AvatarGroup.web.d.ts +4 -0
- package/lib/typescript/commonjs/avatar-group/AvatarGroup.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts +16 -0
- package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar-group/index.d.ts +3 -0
- package/lib/typescript/commonjs/avatar-group/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar-group/index.web.d.ts +3 -0
- package/lib/typescript/commonjs/avatar-group/index.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar-group/types.d.ts +63 -0
- package/lib/typescript/commonjs/avatar-group/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +4 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +4 -0
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/user-hover-card/UserHoverCard.d.ts +4 -0
- package/lib/typescript/commonjs/user-hover-card/UserHoverCard.d.ts.map +1 -0
- package/lib/typescript/commonjs/user-hover-card/index.d.ts +3 -0
- package/lib/typescript/commonjs/user-hover-card/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/user-hover-card/types.d.ts +37 -0
- package/lib/typescript/commonjs/user-hover-card/types.d.ts.map +1 -0
- package/lib/typescript/module/avatar-group/AvatarGroup.d.ts +4 -0
- package/lib/typescript/module/avatar-group/AvatarGroup.d.ts.map +1 -0
- package/lib/typescript/module/avatar-group/AvatarGroup.web.d.ts +4 -0
- package/lib/typescript/module/avatar-group/AvatarGroup.web.d.ts.map +1 -0
- package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts +16 -0
- package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts.map +1 -0
- package/lib/typescript/module/avatar-group/index.d.ts +3 -0
- package/lib/typescript/module/avatar-group/index.d.ts.map +1 -0
- package/lib/typescript/module/avatar-group/index.web.d.ts +3 -0
- package/lib/typescript/module/avatar-group/index.web.d.ts.map +1 -0
- package/lib/typescript/module/avatar-group/types.d.ts +63 -0
- package/lib/typescript/module/avatar-group/types.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +4 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +4 -0
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/user-hover-card/UserHoverCard.d.ts +4 -0
- package/lib/typescript/module/user-hover-card/UserHoverCard.d.ts.map +1 -0
- package/lib/typescript/module/user-hover-card/index.d.ts +3 -0
- package/lib/typescript/module/user-hover-card/index.d.ts.map +1 -0
- package/lib/typescript/module/user-hover-card/types.d.ts +37 -0
- package/lib/typescript/module/user-hover-card/types.d.ts.map +1 -0
- package/package.json +30 -1
- package/src/avatar-group/AvatarGroup.stories.tsx +135 -0
- package/src/avatar-group/AvatarGroup.tsx +19 -0
- package/src/avatar-group/AvatarGroup.web.tsx +145 -0
- package/src/avatar-group/AvatarGroupBase.tsx +249 -0
- package/src/avatar-group/index.ts +2 -0
- package/src/avatar-group/index.web.tsx +2 -0
- package/src/avatar-group/types.ts +64 -0
- package/src/index.ts +4 -0
- package/src/index.web.ts +4 -0
- package/src/user-hover-card/UserHoverCard.stories.tsx +113 -0
- package/src/user-hover-card/UserHoverCard.tsx +204 -0
- package/src/user-hover-card/index.ts +2 -0
- package/src/user-hover-card/types.ts +38 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import React, { memo, useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Pressable,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
Text,
|
|
6
|
+
View,
|
|
7
|
+
type ViewStyle,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
|
|
10
|
+
import { Avatar } from '../avatar';
|
|
11
|
+
import { useTheme } from '../theme/use-theme';
|
|
12
|
+
import { fontSize } from '../styles/tokens';
|
|
13
|
+
import type { AvatarGroupItem, AvatarGroupProps } from './types';
|
|
14
|
+
|
|
15
|
+
/** Ring thickness as a fraction of avatar size. */
|
|
16
|
+
const RING_RATIO = 0.08;
|
|
17
|
+
/** Minimum ring thickness so small avatars still separate cleanly. */
|
|
18
|
+
const MIN_RING = 2;
|
|
19
|
+
/** Default horizontal overlap as a fraction of avatar size. */
|
|
20
|
+
const DEFAULT_OVERLAP_RATIO = 0.35;
|
|
21
|
+
|
|
22
|
+
function getItemName(item: AvatarGroupItem): string | undefined {
|
|
23
|
+
return item.displayName ?? item.name ?? item.username;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getItemKey(item: AvatarGroupItem, index: number): string {
|
|
27
|
+
return item.id ?? item.username ?? item.uri ?? `avatar-${index}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Hover callbacks injected by the web fork; undefined (no-op) on native. */
|
|
31
|
+
export interface AvatarGroupCellHoverHandlers {
|
|
32
|
+
onHoverIn?: (item: AvatarGroupItem, index: number) => void;
|
|
33
|
+
onHoverOut?: (item: AvatarGroupItem, index: number) => void;
|
|
34
|
+
/** Sets the measured anchor ref for the hovered cell (web only). */
|
|
35
|
+
registerCellRef?: (index: number, node: View | null) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface AvatarGroupBaseProps extends AvatarGroupProps {
|
|
39
|
+
hoverHandlers?: AvatarGroupCellHoverHandlers;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
|
|
43
|
+
items,
|
|
44
|
+
size = 32,
|
|
45
|
+
max = 5,
|
|
46
|
+
total,
|
|
47
|
+
overlap,
|
|
48
|
+
ringColor,
|
|
49
|
+
onPressItem,
|
|
50
|
+
style,
|
|
51
|
+
testID,
|
|
52
|
+
hoverHandlers,
|
|
53
|
+
}) => {
|
|
54
|
+
const theme = useTheme();
|
|
55
|
+
|
|
56
|
+
const ring = ringColor ?? theme.colors.card;
|
|
57
|
+
const ringWidth = Math.max(MIN_RING, Math.round(size * RING_RATIO));
|
|
58
|
+
const effectiveOverlap =
|
|
59
|
+
overlap ?? Math.round(size * DEFAULT_OVERLAP_RATIO);
|
|
60
|
+
// Each avatar after the first is pulled left by `overlap`, but the ring adds
|
|
61
|
+
// visual width on both sides, so the negative margin accounts for the ring.
|
|
62
|
+
const negativeMargin = -(effectiveOverlap + ringWidth);
|
|
63
|
+
|
|
64
|
+
const shown = useMemo(
|
|
65
|
+
() => items.slice(0, Math.max(0, max)),
|
|
66
|
+
[items, max],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const realTotal = total ?? items.length;
|
|
70
|
+
const overflow = Math.max(0, realTotal - shown.length);
|
|
71
|
+
|
|
72
|
+
const cellStyle = useMemo((): ViewStyle => {
|
|
73
|
+
return {
|
|
74
|
+
width: size + ringWidth * 2,
|
|
75
|
+
height: size + ringWidth * 2,
|
|
76
|
+
borderRadius: (size + ringWidth * 2) / 2,
|
|
77
|
+
backgroundColor: ring,
|
|
78
|
+
alignItems: 'center',
|
|
79
|
+
justifyContent: 'center',
|
|
80
|
+
};
|
|
81
|
+
}, [size, ringWidth, ring]);
|
|
82
|
+
|
|
83
|
+
const overflowTextStyle = useMemo(
|
|
84
|
+
() => ({
|
|
85
|
+
color: theme.colors.textSecondary,
|
|
86
|
+
fontSize: Math.max(fontSize._2xs, Math.round(size * 0.4)),
|
|
87
|
+
fontWeight: '600' as const,
|
|
88
|
+
}),
|
|
89
|
+
[theme.colors.textSecondary, size],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const interactive = typeof onPressItem === 'function';
|
|
93
|
+
const hoverable =
|
|
94
|
+
typeof hoverHandlers?.onHoverIn === 'function' ||
|
|
95
|
+
typeof hoverHandlers?.onHoverOut === 'function';
|
|
96
|
+
|
|
97
|
+
// Pressing the "+N" chip surfaces the first hidden member (if any), so
|
|
98
|
+
// consumers can route it to a full member list.
|
|
99
|
+
const firstHidden = items[shown.length];
|
|
100
|
+
const overflowOnPress =
|
|
101
|
+
interactive && firstHidden
|
|
102
|
+
? () => onPressItem?.(firstHidden, shown.length)
|
|
103
|
+
: undefined;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<View style={[styles.row, style]} testID={testID}>
|
|
107
|
+
{shown.map((item, index) => {
|
|
108
|
+
const name = getItemName(item);
|
|
109
|
+
const accessibilityLabel = item.username
|
|
110
|
+
? `${name ?? item.username} (@${item.username})`
|
|
111
|
+
: name;
|
|
112
|
+
|
|
113
|
+
// The negative margin and stacking order live on the outermost
|
|
114
|
+
// row element so layout and hit-testing stay aligned with the visual
|
|
115
|
+
// overlap. Earlier siblings render on top of later ones.
|
|
116
|
+
const wrapperStyle: ViewStyle = {
|
|
117
|
+
...(index > 0 && { marginLeft: negativeMargin }),
|
|
118
|
+
zIndex: shown.length - index,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const cell = (
|
|
122
|
+
<View
|
|
123
|
+
ref={
|
|
124
|
+
hoverHandlers?.registerCellRef
|
|
125
|
+
? (node) => hoverHandlers.registerCellRef?.(index, node)
|
|
126
|
+
: undefined
|
|
127
|
+
}
|
|
128
|
+
collapsable={false}
|
|
129
|
+
style={cellStyle}
|
|
130
|
+
>
|
|
131
|
+
<Avatar uri={item.uri ?? undefined} name={name} size={size} />
|
|
132
|
+
</View>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
if (interactive || hoverable) {
|
|
136
|
+
return (
|
|
137
|
+
<Pressable
|
|
138
|
+
key={getItemKey(item, index)}
|
|
139
|
+
onPress={
|
|
140
|
+
interactive ? () => onPressItem?.(item, index) : undefined
|
|
141
|
+
}
|
|
142
|
+
onHoverIn={
|
|
143
|
+
hoverHandlers?.onHoverIn
|
|
144
|
+
? () => hoverHandlers.onHoverIn?.(item, index)
|
|
145
|
+
: undefined
|
|
146
|
+
}
|
|
147
|
+
onHoverOut={
|
|
148
|
+
hoverHandlers?.onHoverOut
|
|
149
|
+
? () => hoverHandlers.onHoverOut?.(item, index)
|
|
150
|
+
: undefined
|
|
151
|
+
}
|
|
152
|
+
accessibilityRole={interactive ? 'button' : undefined}
|
|
153
|
+
accessibilityLabel={accessibilityLabel}
|
|
154
|
+
style={wrapperStyle}
|
|
155
|
+
>
|
|
156
|
+
{cell}
|
|
157
|
+
</Pressable>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<View key={getItemKey(item, index)} style={wrapperStyle}>
|
|
163
|
+
{cell}
|
|
164
|
+
</View>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
|
|
168
|
+
{overflow > 0 && (
|
|
169
|
+
<OverflowChip
|
|
170
|
+
count={overflow}
|
|
171
|
+
cellStyle={cellStyle}
|
|
172
|
+
negativeMargin={shown.length > 0 ? negativeMargin : 0}
|
|
173
|
+
textStyle={overflowTextStyle}
|
|
174
|
+
onPress={overflowOnPress}
|
|
175
|
+
/>
|
|
176
|
+
)}
|
|
177
|
+
</View>
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
function OverflowChip({
|
|
182
|
+
count,
|
|
183
|
+
cellStyle,
|
|
184
|
+
negativeMargin,
|
|
185
|
+
textStyle,
|
|
186
|
+
onPress,
|
|
187
|
+
}: {
|
|
188
|
+
count: number;
|
|
189
|
+
cellStyle: ViewStyle;
|
|
190
|
+
negativeMargin: number;
|
|
191
|
+
textStyle: { color: string; fontSize: number; fontWeight: '600' };
|
|
192
|
+
onPress?: () => void;
|
|
193
|
+
}) {
|
|
194
|
+
const theme = useTheme();
|
|
195
|
+
// The chip is anchored at the lowest stacking order so it tucks behind the
|
|
196
|
+
// last avatar's ring. The margin lives on the outer element to keep layout
|
|
197
|
+
// aligned with the overlap.
|
|
198
|
+
const wrapperStyle: ViewStyle = {
|
|
199
|
+
...(negativeMargin !== 0 && { marginLeft: negativeMargin }),
|
|
200
|
+
zIndex: 0,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const chip = (
|
|
204
|
+
<View style={[cellStyle, { backgroundColor: theme.colors.card }]}>
|
|
205
|
+
<View
|
|
206
|
+
style={[
|
|
207
|
+
styles.chipInner,
|
|
208
|
+
{ backgroundColor: theme.colors.backgroundTertiary },
|
|
209
|
+
]}
|
|
210
|
+
>
|
|
211
|
+
<Text allowFontScaling={false} style={textStyle}>
|
|
212
|
+
+{count}
|
|
213
|
+
</Text>
|
|
214
|
+
</View>
|
|
215
|
+
</View>
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (onPress) {
|
|
219
|
+
return (
|
|
220
|
+
<Pressable
|
|
221
|
+
onPress={onPress}
|
|
222
|
+
accessibilityRole="button"
|
|
223
|
+
accessibilityLabel={`${count} more`}
|
|
224
|
+
style={wrapperStyle}
|
|
225
|
+
>
|
|
226
|
+
{chip}
|
|
227
|
+
</Pressable>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return <View style={wrapperStyle}>{chip}</View>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const styles = StyleSheet.create({
|
|
235
|
+
row: {
|
|
236
|
+
flexDirection: 'row',
|
|
237
|
+
alignItems: 'center',
|
|
238
|
+
},
|
|
239
|
+
chipInner: {
|
|
240
|
+
width: '100%',
|
|
241
|
+
height: '100%',
|
|
242
|
+
borderRadius: 999,
|
|
243
|
+
alignItems: 'center',
|
|
244
|
+
justifyContent: 'center',
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
export const AvatarGroupBase = memo(AvatarGroupBaseComponent);
|
|
249
|
+
AvatarGroupBase.displayName = 'AvatarGroupBase';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A single member of an {@link AvatarGroup}. Mirrors the minimal shape needed to
|
|
6
|
+
* render an {@link Avatar} plus drive the optional hover card. `uri` is the
|
|
7
|
+
* avatar image URL; `name`/`displayName` feed Avatar's deterministic
|
|
8
|
+
* name-based placeholder when no image resolves.
|
|
9
|
+
*/
|
|
10
|
+
export interface AvatarGroupItem {
|
|
11
|
+
/** Stable identity used as a React key and passed back to handlers. */
|
|
12
|
+
id?: string;
|
|
13
|
+
/** Avatar image URL (null/undefined → name-based placeholder). */
|
|
14
|
+
uri?: string | null;
|
|
15
|
+
/** Fallback name used for the avatar placeholder when `displayName` is absent. */
|
|
16
|
+
name?: string;
|
|
17
|
+
/** Canonical, already-resolved display name (preferred over `name`). */
|
|
18
|
+
displayName?: string;
|
|
19
|
+
/** Handle without the leading `@`. */
|
|
20
|
+
username?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AvatarGroupProps {
|
|
24
|
+
/** Members to render, in stacking order (first item sits on top). */
|
|
25
|
+
items: AvatarGroupItem[];
|
|
26
|
+
/** Diameter of each avatar in pixels. */
|
|
27
|
+
size?: number;
|
|
28
|
+
/** Maximum number of avatars to render before collapsing into the overflow chip. */
|
|
29
|
+
max?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Real total count of members. When provided and larger than the number of
|
|
32
|
+
* rendered avatars, the overflow chip shows `+ (total - shown)`. When omitted,
|
|
33
|
+
* overflow is derived from `items.length - shown`.
|
|
34
|
+
*/
|
|
35
|
+
total?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Horizontal overlap between adjacent avatars in pixels. Defaults to ~35% of
|
|
38
|
+
* `size`. Larger values pull avatars closer together.
|
|
39
|
+
*/
|
|
40
|
+
overlap?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Color of the ring/border drawn around each avatar so they separate cleanly.
|
|
43
|
+
* Defaults to the theme `card` surface color.
|
|
44
|
+
*/
|
|
45
|
+
ringColor?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Per-avatar press handler. When provided, each avatar (and the overflow chip)
|
|
48
|
+
* becomes pressable. When omitted, the group is purely presentational.
|
|
49
|
+
*/
|
|
50
|
+
onPressItem?: (item: AvatarGroupItem, index: number) => void;
|
|
51
|
+
/**
|
|
52
|
+
* Enables the hover card on web. Hovering an avatar reveals a
|
|
53
|
+
* {@link UserHoverCard} for that item. No-op on native.
|
|
54
|
+
*/
|
|
55
|
+
hoverCard?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Per-item action slot rendered inside the hover card — typically the app's
|
|
58
|
+
* SDK FollowButton. Only used when `hoverCard` is enabled (web).
|
|
59
|
+
*/
|
|
60
|
+
renderItemAction?: (item: AvatarGroupItem, index: number) => ReactNode;
|
|
61
|
+
/** Container style override. */
|
|
62
|
+
style?: StyleProp<ViewStyle>;
|
|
63
|
+
testID?: string;
|
|
64
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -49,6 +49,10 @@ export type {
|
|
|
49
49
|
ErrorBoundaryFallbackContext,
|
|
50
50
|
} from './error-boundary';
|
|
51
51
|
export * from './avatar';
|
|
52
|
+
export { AvatarGroup } from './avatar-group';
|
|
53
|
+
export type { AvatarGroupProps, AvatarGroupItem } from './avatar-group';
|
|
54
|
+
export { UserHoverCard } from './user-hover-card';
|
|
55
|
+
export type { UserHoverCardProps, UserHoverCardStat } from './user-hover-card';
|
|
52
56
|
export * from './loading';
|
|
53
57
|
export * as PromptInput from './prompt-input';
|
|
54
58
|
export * from './switch';
|
package/src/index.web.ts
CHANGED
|
@@ -54,6 +54,10 @@ export type {
|
|
|
54
54
|
ErrorBoundaryFallbackContext,
|
|
55
55
|
} from './error-boundary';
|
|
56
56
|
export * from './avatar';
|
|
57
|
+
export { AvatarGroup } from './avatar-group/index.web';
|
|
58
|
+
export type { AvatarGroupProps, AvatarGroupItem } from './avatar-group/index.web';
|
|
59
|
+
export { UserHoverCard } from './user-hover-card';
|
|
60
|
+
export type { UserHoverCardProps, UserHoverCardStat } from './user-hover-card';
|
|
57
61
|
export * from './loading/index.web';
|
|
58
62
|
export * as PromptInput from './prompt-input';
|
|
59
63
|
export * from './switch';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, Text, View } from 'react-native';
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
4
|
+
|
|
5
|
+
import { UserHoverCard } from './UserHoverCard';
|
|
6
|
+
|
|
7
|
+
const SAMPLE_AVATAR =
|
|
8
|
+
'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200&h=200&fit=crop';
|
|
9
|
+
|
|
10
|
+
function DemoFollowButton() {
|
|
11
|
+
return (
|
|
12
|
+
<Pressable
|
|
13
|
+
style={{
|
|
14
|
+
paddingHorizontal: 14,
|
|
15
|
+
paddingVertical: 7,
|
|
16
|
+
borderRadius: 999,
|
|
17
|
+
backgroundColor: '#1A73E8',
|
|
18
|
+
}}
|
|
19
|
+
>
|
|
20
|
+
<Text style={{ color: '#fff', fontWeight: '600', fontSize: 13 }}>
|
|
21
|
+
Follow
|
|
22
|
+
</Text>
|
|
23
|
+
</Pressable>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const meta: Meta<typeof UserHoverCard> = {
|
|
28
|
+
title: 'Components/UserHoverCard',
|
|
29
|
+
component: UserHoverCard,
|
|
30
|
+
argTypes: {
|
|
31
|
+
verified: { control: 'boolean' },
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default meta;
|
|
36
|
+
|
|
37
|
+
type Story = StoryObj<typeof UserHoverCard>;
|
|
38
|
+
|
|
39
|
+
export const Basic: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
avatar: SAMPLE_AVATAR,
|
|
42
|
+
displayName: 'Nate Isern',
|
|
43
|
+
username: 'nate',
|
|
44
|
+
bio: 'Building the Oxy ecosystem. Designer, engineer, and occasional rose grower.',
|
|
45
|
+
stats: [
|
|
46
|
+
{ label: 'Following', value: '312' },
|
|
47
|
+
{ label: 'Followers', value: '4.8K' },
|
|
48
|
+
],
|
|
49
|
+
verified: true,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const WithAction: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
avatar: SAMPLE_AVATAR,
|
|
56
|
+
displayName: 'Ada Lovelace',
|
|
57
|
+
username: 'ada',
|
|
58
|
+
bio: 'Mathematician. Wrote the first algorithm intended for a machine.',
|
|
59
|
+
stats: [
|
|
60
|
+
{ label: 'Following', value: '12' },
|
|
61
|
+
{ label: 'Followers', value: '1.2M' },
|
|
62
|
+
],
|
|
63
|
+
verified: true,
|
|
64
|
+
action: <DemoFollowButton />,
|
|
65
|
+
},
|
|
66
|
+
name: 'With action slot',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const NoAction: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
avatar: null,
|
|
72
|
+
displayName: 'Grace Hopper',
|
|
73
|
+
username: 'grace',
|
|
74
|
+
bio: 'Computer scientist and US Navy rear admiral.',
|
|
75
|
+
stats: [
|
|
76
|
+
{ label: 'Following', value: '88' },
|
|
77
|
+
{ label: 'Followers', value: '900K' },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
name: 'No action slot',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const Minimal: Story = {
|
|
84
|
+
args: {
|
|
85
|
+
displayName: 'Anonymous',
|
|
86
|
+
},
|
|
87
|
+
name: 'Minimal (name only)',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const Pressable_: Story = {
|
|
91
|
+
render: (args) => (
|
|
92
|
+
<View style={{ gap: 8 }}>
|
|
93
|
+
<UserHoverCard
|
|
94
|
+
{...args}
|
|
95
|
+
avatar={SAMPLE_AVATAR}
|
|
96
|
+
displayName="Linus Torvalds"
|
|
97
|
+
username="linus"
|
|
98
|
+
bio="Creator of Linux and Git."
|
|
99
|
+
stats={[
|
|
100
|
+
{ label: 'Following', value: '3' },
|
|
101
|
+
{ label: 'Followers', value: '2.1M' },
|
|
102
|
+
]}
|
|
103
|
+
action={<DemoFollowButton />}
|
|
104
|
+
onPressProfile={() => {
|
|
105
|
+
// eslint-disable-next-line no-alert
|
|
106
|
+
if (typeof window !== 'undefined') window.alert('open profile');
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
<Text>Click the identity area to open the profile.</Text>
|
|
110
|
+
</View>
|
|
111
|
+
),
|
|
112
|
+
name: 'Pressable identity',
|
|
113
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import React, { memo, useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Platform,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
View,
|
|
8
|
+
type ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
|
|
11
|
+
import { Avatar } from '../avatar';
|
|
12
|
+
import { VerifiedCheck } from '../icons/VerifiedCheck';
|
|
13
|
+
import { useTheme } from '../theme/use-theme';
|
|
14
|
+
import { borderRadius, fontSize, space } from '../styles/tokens';
|
|
15
|
+
import type { UserHoverCardProps } from './types';
|
|
16
|
+
|
|
17
|
+
const AVATAR_SIZE = 48;
|
|
18
|
+
const VERIFIED_SIZE = 16;
|
|
19
|
+
|
|
20
|
+
const UserHoverCardComponent: React.FC<UserHoverCardProps> = ({
|
|
21
|
+
avatar,
|
|
22
|
+
displayName,
|
|
23
|
+
username,
|
|
24
|
+
bio,
|
|
25
|
+
stats,
|
|
26
|
+
verified = false,
|
|
27
|
+
onPressProfile,
|
|
28
|
+
action,
|
|
29
|
+
style,
|
|
30
|
+
testID,
|
|
31
|
+
}) => {
|
|
32
|
+
const theme = useTheme();
|
|
33
|
+
|
|
34
|
+
const cardStyle = useMemo((): ViewStyle => {
|
|
35
|
+
const base: ViewStyle = {
|
|
36
|
+
backgroundColor: theme.colors.card,
|
|
37
|
+
borderRadius: borderRadius.lg,
|
|
38
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
39
|
+
borderColor: theme.colors.border,
|
|
40
|
+
padding: space.lg,
|
|
41
|
+
width: 280,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (Platform.OS === 'web') {
|
|
45
|
+
base.boxShadow = `0px 8px 24px ${theme.colors.shadow}`;
|
|
46
|
+
} else {
|
|
47
|
+
base.shadowColor = theme.colors.shadow;
|
|
48
|
+
base.shadowOffset = { width: 0, height: 8 };
|
|
49
|
+
base.shadowOpacity = 0.18;
|
|
50
|
+
base.shadowRadius = 16;
|
|
51
|
+
base.elevation = 8;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return base;
|
|
55
|
+
}, [theme]);
|
|
56
|
+
|
|
57
|
+
const identity = (
|
|
58
|
+
<View style={styles.identityRow}>
|
|
59
|
+
<Avatar uri={avatar ?? undefined} name={displayName} size={AVATAR_SIZE} />
|
|
60
|
+
<View style={styles.identityText}>
|
|
61
|
+
<View style={styles.nameRow}>
|
|
62
|
+
<Text
|
|
63
|
+
numberOfLines={1}
|
|
64
|
+
style={[styles.displayName, { color: theme.colors.text }]}
|
|
65
|
+
>
|
|
66
|
+
{displayName}
|
|
67
|
+
</Text>
|
|
68
|
+
{verified && (
|
|
69
|
+
<VerifiedCheck
|
|
70
|
+
size="sm"
|
|
71
|
+
fill={theme.colors.primary}
|
|
72
|
+
width={VERIFIED_SIZE}
|
|
73
|
+
height={VERIFIED_SIZE}
|
|
74
|
+
style={styles.verifiedBadge}
|
|
75
|
+
/>
|
|
76
|
+
)}
|
|
77
|
+
</View>
|
|
78
|
+
{username ? (
|
|
79
|
+
<Text
|
|
80
|
+
numberOfLines={1}
|
|
81
|
+
style={[styles.username, { color: theme.colors.textSecondary }]}
|
|
82
|
+
>
|
|
83
|
+
@{username}
|
|
84
|
+
</Text>
|
|
85
|
+
) : null}
|
|
86
|
+
</View>
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<View style={[cardStyle, style]} testID={testID}>
|
|
92
|
+
<View style={styles.header}>
|
|
93
|
+
{onPressProfile ? (
|
|
94
|
+
<Pressable
|
|
95
|
+
onPress={onPressProfile}
|
|
96
|
+
accessibilityRole="button"
|
|
97
|
+
accessibilityLabel={
|
|
98
|
+
username ? `${displayName} (@${username})` : displayName
|
|
99
|
+
}
|
|
100
|
+
style={styles.identityPressable}
|
|
101
|
+
>
|
|
102
|
+
{identity}
|
|
103
|
+
</Pressable>
|
|
104
|
+
) : (
|
|
105
|
+
<View style={styles.identityPressable}>{identity}</View>
|
|
106
|
+
)}
|
|
107
|
+
{action != null ? <View style={styles.action}>{action}</View> : null}
|
|
108
|
+
</View>
|
|
109
|
+
|
|
110
|
+
{bio ? (
|
|
111
|
+
<Text
|
|
112
|
+
numberOfLines={3}
|
|
113
|
+
style={[styles.bio, { color: theme.colors.text }]}
|
|
114
|
+
>
|
|
115
|
+
{bio}
|
|
116
|
+
</Text>
|
|
117
|
+
) : null}
|
|
118
|
+
|
|
119
|
+
{stats && stats.length > 0 ? (
|
|
120
|
+
<View style={styles.statsRow}>
|
|
121
|
+
{stats.map((stat) => (
|
|
122
|
+
<View key={stat.label} style={styles.stat}>
|
|
123
|
+
<Text style={[styles.statValue, { color: theme.colors.text }]}>
|
|
124
|
+
{stat.value}
|
|
125
|
+
</Text>
|
|
126
|
+
<Text
|
|
127
|
+
style={[styles.statLabel, { color: theme.colors.textSecondary }]}
|
|
128
|
+
>
|
|
129
|
+
{stat.label}
|
|
130
|
+
</Text>
|
|
131
|
+
</View>
|
|
132
|
+
))}
|
|
133
|
+
</View>
|
|
134
|
+
) : null}
|
|
135
|
+
</View>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const styles = StyleSheet.create({
|
|
140
|
+
header: {
|
|
141
|
+
flexDirection: 'row',
|
|
142
|
+
alignItems: 'flex-start',
|
|
143
|
+
justifyContent: 'space-between',
|
|
144
|
+
gap: space.sm,
|
|
145
|
+
},
|
|
146
|
+
identityPressable: {
|
|
147
|
+
flexShrink: 1,
|
|
148
|
+
flexGrow: 1,
|
|
149
|
+
},
|
|
150
|
+
identityRow: {
|
|
151
|
+
flexDirection: 'row',
|
|
152
|
+
alignItems: 'center',
|
|
153
|
+
gap: space.sm,
|
|
154
|
+
},
|
|
155
|
+
identityText: {
|
|
156
|
+
flexShrink: 1,
|
|
157
|
+
flexGrow: 1,
|
|
158
|
+
},
|
|
159
|
+
nameRow: {
|
|
160
|
+
flexDirection: 'row',
|
|
161
|
+
alignItems: 'center',
|
|
162
|
+
gap: space.xs,
|
|
163
|
+
},
|
|
164
|
+
displayName: {
|
|
165
|
+
fontSize: fontSize.lg,
|
|
166
|
+
fontWeight: '700',
|
|
167
|
+
flexShrink: 1,
|
|
168
|
+
},
|
|
169
|
+
verifiedBadge: {
|
|
170
|
+
flexShrink: 0,
|
|
171
|
+
},
|
|
172
|
+
username: {
|
|
173
|
+
fontSize: fontSize.sm,
|
|
174
|
+
marginTop: space._2xs,
|
|
175
|
+
},
|
|
176
|
+
action: {
|
|
177
|
+
flexShrink: 0,
|
|
178
|
+
},
|
|
179
|
+
bio: {
|
|
180
|
+
fontSize: fontSize.sm,
|
|
181
|
+
lineHeight: fontSize.sm * 1.4,
|
|
182
|
+
marginTop: space.md,
|
|
183
|
+
},
|
|
184
|
+
statsRow: {
|
|
185
|
+
flexDirection: 'row',
|
|
186
|
+
gap: space.lg,
|
|
187
|
+
marginTop: space.md,
|
|
188
|
+
},
|
|
189
|
+
stat: {
|
|
190
|
+
flexDirection: 'row',
|
|
191
|
+
alignItems: 'baseline',
|
|
192
|
+
gap: space.xs,
|
|
193
|
+
},
|
|
194
|
+
statValue: {
|
|
195
|
+
fontSize: fontSize.sm,
|
|
196
|
+
fontWeight: '700',
|
|
197
|
+
},
|
|
198
|
+
statLabel: {
|
|
199
|
+
fontSize: fontSize.sm,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
export const UserHoverCard = memo(UserHoverCardComponent);
|
|
204
|
+
UserHoverCard.displayName = 'UserHoverCard';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/** A single labeled metric rendered in the {@link UserHoverCardProps.stats} row. */
|
|
5
|
+
export interface UserHoverCardStat {
|
|
6
|
+
/** Human-readable label, e.g. `"Followers"`. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Pre-formatted value, e.g. `"1.2K"` or `42`. */
|
|
9
|
+
value: string | number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UserHoverCardProps {
|
|
13
|
+
/**
|
|
14
|
+
* Avatar image source — a URL string, or null/undefined to fall back to the
|
|
15
|
+
* deterministic name-based placeholder rendered by {@link Avatar}.
|
|
16
|
+
*/
|
|
17
|
+
avatar?: string | null;
|
|
18
|
+
/** Canonical, already-resolved display name (an API contract — render directly). */
|
|
19
|
+
displayName: string;
|
|
20
|
+
/** Handle without the leading `@` (the component prefixes it). */
|
|
21
|
+
username?: string;
|
|
22
|
+
/** Optional short biography; truncated to a few lines. */
|
|
23
|
+
bio?: string;
|
|
24
|
+
/** Optional metrics row (e.g. Following / Followers). */
|
|
25
|
+
stats?: UserHoverCardStat[];
|
|
26
|
+
/** Whether to show the verified badge next to the display name. */
|
|
27
|
+
verified?: boolean;
|
|
28
|
+
/** Makes the identity area (avatar + name + handle) pressable. */
|
|
29
|
+
onPressProfile?: () => void;
|
|
30
|
+
/**
|
|
31
|
+
* Slot for a consumer-injected action — typically the app's SDK FollowButton.
|
|
32
|
+
* Bloom does NOT implement follow logic; it only renders the card.
|
|
33
|
+
*/
|
|
34
|
+
action?: ReactNode;
|
|
35
|
+
/** Container style override. */
|
|
36
|
+
style?: StyleProp<ViewStyle>;
|
|
37
|
+
testID?: string;
|
|
38
|
+
}
|