@oxyhq/bloom 0.8.6 → 0.9.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 +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 +186 -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 +181 -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 +69 -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 +39 -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 +69 -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 +39 -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 +257 -0
- package/src/avatar-group/index.ts +2 -0
- package/src/avatar-group/index.web.tsx +2 -0
- package/src/avatar-group/types.ts +70 -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 +40 -0
|
@@ -0,0 +1,257 @@
|
|
|
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 (border) thickness around each avatar, in pixels. Matches the
|
|
16
|
+
* original Mention `AvatarStack` (a single hairline-ish 1px separator). */
|
|
17
|
+
const RING_WIDTH = 1;
|
|
18
|
+
/** Default horizontal overlap as a fraction of avatar size — the original
|
|
19
|
+
* stack pulled each avatar left by `size / 3`. */
|
|
20
|
+
const DEFAULT_OVERLAP_RATIO = 1 / 3;
|
|
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
|
+
// The ring is the thin separator drawn between overlapping avatars. It
|
|
57
|
+
// defaults to the page background so avatars read as cleanly punched out of
|
|
58
|
+
// the surface behind them — matching the original Mention stack, which used
|
|
59
|
+
// `theme.colors.background`, not a large `card`-colored ring cell.
|
|
60
|
+
const ring = ringColor ?? theme.colors.background;
|
|
61
|
+
const effectiveOverlap =
|
|
62
|
+
overlap ?? Math.round(size * DEFAULT_OVERLAP_RATIO);
|
|
63
|
+
// Each avatar after the first is pulled left by `overlap`. The cell carries a
|
|
64
|
+
// 1px border on each side, so the negative margin absorbs both borders to
|
|
65
|
+
// keep the visual overlap equal to `effectiveOverlap`.
|
|
66
|
+
const negativeMargin = -(effectiveOverlap + RING_WIDTH * 2);
|
|
67
|
+
|
|
68
|
+
const shown = useMemo(
|
|
69
|
+
() => items.slice(0, Math.max(0, max)),
|
|
70
|
+
[items, max],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const realTotal = total ?? items.length;
|
|
74
|
+
const overflow = Math.max(0, realTotal - shown.length);
|
|
75
|
+
|
|
76
|
+
// Each cell is a circular, clipped container with a 1px ring border. The inner
|
|
77
|
+
// Avatar is inset by the border on both sides so the image fills the circle
|
|
78
|
+
// exactly inside the ring.
|
|
79
|
+
const innerSize = size - RING_WIDTH * 2;
|
|
80
|
+
const cellStyle = useMemo((): ViewStyle => {
|
|
81
|
+
return {
|
|
82
|
+
width: size,
|
|
83
|
+
height: size,
|
|
84
|
+
borderRadius: size / 2,
|
|
85
|
+
borderWidth: RING_WIDTH,
|
|
86
|
+
borderColor: ring,
|
|
87
|
+
overflow: 'hidden',
|
|
88
|
+
alignItems: 'center',
|
|
89
|
+
justifyContent: 'center',
|
|
90
|
+
};
|
|
91
|
+
}, [size, ring]);
|
|
92
|
+
|
|
93
|
+
const overflowTextStyle = useMemo(
|
|
94
|
+
() => ({
|
|
95
|
+
color: '#FFFFFF',
|
|
96
|
+
fontSize: Math.max(fontSize._2xs, Math.round(size * 0.36)),
|
|
97
|
+
fontWeight: '600' as const,
|
|
98
|
+
}),
|
|
99
|
+
[size],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const interactive = typeof onPressItem === 'function';
|
|
103
|
+
const hoverable =
|
|
104
|
+
typeof hoverHandlers?.onHoverIn === 'function' ||
|
|
105
|
+
typeof hoverHandlers?.onHoverOut === 'function';
|
|
106
|
+
|
|
107
|
+
// Pressing the "+N" circle surfaces the first hidden member (if any), so
|
|
108
|
+
// consumers can route it to a full member list.
|
|
109
|
+
const firstHidden = items[shown.length];
|
|
110
|
+
const overflowOnPress =
|
|
111
|
+
interactive && firstHidden
|
|
112
|
+
? () => onPressItem?.(firstHidden, shown.length)
|
|
113
|
+
: undefined;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<View style={[styles.row, style]} testID={testID}>
|
|
117
|
+
{shown.map((item, index) => {
|
|
118
|
+
const name = getItemName(item);
|
|
119
|
+
const accessibilityLabel = item.username
|
|
120
|
+
? `${name ?? item.username} (@${item.username})`
|
|
121
|
+
: name;
|
|
122
|
+
|
|
123
|
+
// The negative margin and stacking order live on the outermost
|
|
124
|
+
// row element so layout and hit-testing stay aligned with the visual
|
|
125
|
+
// overlap. Earlier siblings render on top of later ones.
|
|
126
|
+
const wrapperStyle: ViewStyle = {
|
|
127
|
+
...(index > 0 && { marginLeft: negativeMargin }),
|
|
128
|
+
zIndex: shown.length - index,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const cell = (
|
|
132
|
+
<View
|
|
133
|
+
ref={
|
|
134
|
+
hoverHandlers?.registerCellRef
|
|
135
|
+
? (node) => hoverHandlers.registerCellRef?.(index, node)
|
|
136
|
+
: undefined
|
|
137
|
+
}
|
|
138
|
+
collapsable={false}
|
|
139
|
+
style={cellStyle}
|
|
140
|
+
>
|
|
141
|
+
{/*
|
|
142
|
+
Route the item's avatar value through Avatar's `source` prop (not
|
|
143
|
+
`uri`): full URLs pass through directly, while resolver-handled ids
|
|
144
|
+
(e.g. Oxy file IDs) are resolved by the consumer's ImageResolver —
|
|
145
|
+
exactly like the original Mention stack. No `name` is passed, so a
|
|
146
|
+
missing avatar shows Avatar's neutral default placeholder rather
|
|
147
|
+
than a colored deterministic initial.
|
|
148
|
+
*/}
|
|
149
|
+
<Avatar source={item.uri ?? undefined} size={innerSize} />
|
|
150
|
+
</View>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (interactive || hoverable) {
|
|
154
|
+
return (
|
|
155
|
+
<Pressable
|
|
156
|
+
key={getItemKey(item, index)}
|
|
157
|
+
onPress={
|
|
158
|
+
interactive ? () => onPressItem?.(item, index) : undefined
|
|
159
|
+
}
|
|
160
|
+
onHoverIn={
|
|
161
|
+
hoverHandlers?.onHoverIn
|
|
162
|
+
? () => hoverHandlers.onHoverIn?.(item, index)
|
|
163
|
+
: undefined
|
|
164
|
+
}
|
|
165
|
+
onHoverOut={
|
|
166
|
+
hoverHandlers?.onHoverOut
|
|
167
|
+
? () => hoverHandlers.onHoverOut?.(item, index)
|
|
168
|
+
: undefined
|
|
169
|
+
}
|
|
170
|
+
accessibilityRole={interactive ? 'button' : undefined}
|
|
171
|
+
accessibilityLabel={accessibilityLabel}
|
|
172
|
+
style={wrapperStyle}
|
|
173
|
+
>
|
|
174
|
+
{cell}
|
|
175
|
+
</Pressable>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<View key={getItemKey(item, index)} style={wrapperStyle}>
|
|
181
|
+
{cell}
|
|
182
|
+
</View>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
|
|
186
|
+
{overflow > 0 && (
|
|
187
|
+
<OverflowCircle
|
|
188
|
+
count={overflow}
|
|
189
|
+
cellStyle={cellStyle}
|
|
190
|
+
negativeMargin={shown.length > 0 ? negativeMargin : 0}
|
|
191
|
+
textStyle={overflowTextStyle}
|
|
192
|
+
onPress={overflowOnPress}
|
|
193
|
+
/>
|
|
194
|
+
)}
|
|
195
|
+
</View>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function OverflowCircle({
|
|
200
|
+
count,
|
|
201
|
+
cellStyle,
|
|
202
|
+
negativeMargin,
|
|
203
|
+
textStyle,
|
|
204
|
+
onPress,
|
|
205
|
+
}: {
|
|
206
|
+
count: number;
|
|
207
|
+
cellStyle: ViewStyle;
|
|
208
|
+
negativeMargin: number;
|
|
209
|
+
textStyle: { color: string; fontSize: number; fontWeight: '600' };
|
|
210
|
+
onPress?: () => void;
|
|
211
|
+
}) {
|
|
212
|
+
const theme = useTheme();
|
|
213
|
+
// The count circle is anchored at the lowest stacking order so it tucks behind
|
|
214
|
+
// the last avatar's ring. The margin lives on the outer element to keep layout
|
|
215
|
+
// aligned with the overlap.
|
|
216
|
+
const wrapperStyle: ViewStyle = {
|
|
217
|
+
...(negativeMargin !== 0 && { marginLeft: negativeMargin }),
|
|
218
|
+
zIndex: 0,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// A solid "+N" count circle: secondary-text-colored fill with white text,
|
|
222
|
+
// matching the original Mention `ResponsiveAvatarStack` count circle.
|
|
223
|
+
const circle = (
|
|
224
|
+
<View
|
|
225
|
+
style={[cellStyle, { backgroundColor: theme.colors.textSecondary }]}
|
|
226
|
+
>
|
|
227
|
+
<Text allowFontScaling={false} style={textStyle}>
|
|
228
|
+
+{count}
|
|
229
|
+
</Text>
|
|
230
|
+
</View>
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
if (onPress) {
|
|
234
|
+
return (
|
|
235
|
+
<Pressable
|
|
236
|
+
onPress={onPress}
|
|
237
|
+
accessibilityRole="button"
|
|
238
|
+
accessibilityLabel={`${count} more`}
|
|
239
|
+
style={wrapperStyle}
|
|
240
|
+
>
|
|
241
|
+
{circle}
|
|
242
|
+
</Pressable>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return <View style={wrapperStyle}>{circle}</View>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const styles = StyleSheet.create({
|
|
250
|
+
row: {
|
|
251
|
+
flexDirection: 'row',
|
|
252
|
+
alignItems: 'center',
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
export const AvatarGroupBase = memo(AvatarGroupBaseComponent);
|
|
257
|
+
AvatarGroupBase.displayName = 'AvatarGroupBase';
|
|
@@ -0,0 +1,70 @@
|
|
|
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` carries the
|
|
7
|
+
* avatar value (routed into Avatar's `source` prop); `name`/`displayName`/
|
|
8
|
+
* `username` are used for the hover card and accessibility only.
|
|
9
|
+
*/
|
|
10
|
+
export interface AvatarGroupItem {
|
|
11
|
+
/** Stable identity used as a React key and passed back to handlers. */
|
|
12
|
+
id?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Avatar value, routed into {@link Avatar}'s `source` prop. Accepts a full
|
|
15
|
+
* image URL OR a resolver-handled id (e.g. an Oxy file ID) — non-URL strings
|
|
16
|
+
* resolve through the consumer's ImageResolver. Null/undefined renders
|
|
17
|
+
* Avatar's neutral default placeholder.
|
|
18
|
+
*/
|
|
19
|
+
uri?: string | null;
|
|
20
|
+
/** Name used for the hover card / accessibility when `displayName` is absent. */
|
|
21
|
+
name?: string;
|
|
22
|
+
/** Canonical, already-resolved display name (preferred over `name`). */
|
|
23
|
+
displayName?: string;
|
|
24
|
+
/** Handle without the leading `@`. */
|
|
25
|
+
username?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AvatarGroupProps {
|
|
29
|
+
/** Members to render, in stacking order (first item sits on top). */
|
|
30
|
+
items: AvatarGroupItem[];
|
|
31
|
+
/** Diameter of each avatar in pixels. */
|
|
32
|
+
size?: number;
|
|
33
|
+
/** Maximum number of avatars to render before collapsing into the overflow chip. */
|
|
34
|
+
max?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Real total count of members. When provided and larger than the number of
|
|
37
|
+
* rendered avatars, the overflow chip shows `+ (total - shown)`. When omitted,
|
|
38
|
+
* overflow is derived from `items.length - shown`.
|
|
39
|
+
*/
|
|
40
|
+
total?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Horizontal overlap between adjacent avatars in pixels. Defaults to ~1/3 of
|
|
43
|
+
* `size`. Larger values pull avatars closer together.
|
|
44
|
+
*/
|
|
45
|
+
overlap?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Color of the thin ring/border drawn around each avatar so overlapping
|
|
48
|
+
* avatars separate cleanly. Defaults to the theme `background` color so the
|
|
49
|
+
* avatars read as punched out of the surface behind them.
|
|
50
|
+
*/
|
|
51
|
+
ringColor?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Per-avatar press handler. When provided, each avatar (and the overflow chip)
|
|
54
|
+
* becomes pressable. When omitted, the group is purely presentational.
|
|
55
|
+
*/
|
|
56
|
+
onPressItem?: (item: AvatarGroupItem, index: number) => void;
|
|
57
|
+
/**
|
|
58
|
+
* Enables the hover card on web. Hovering an avatar reveals a
|
|
59
|
+
* {@link UserHoverCard} for that item. No-op on native.
|
|
60
|
+
*/
|
|
61
|
+
hoverCard?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Per-item action slot rendered inside the hover card — typically the app's
|
|
64
|
+
* SDK FollowButton. Only used when `hoverCard` is enabled (web).
|
|
65
|
+
*/
|
|
66
|
+
renderItemAction?: (item: AvatarGroupItem, index: number) => ReactNode;
|
|
67
|
+
/** Container style override. */
|
|
68
|
+
style?: StyleProp<ViewStyle>;
|
|
69
|
+
testID?: string;
|
|
70
|
+
}
|
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 source={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';
|