@oxyhq/bloom 0.8.5 → 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/theme/color-scope/index.web.js +6 -7
- package/lib/commonjs/theme/color-scope/index.web.js.map +1 -1
- package/lib/commonjs/theme/color-scope/style-builder.js +18 -6
- package/lib/commonjs/theme/color-scope/style-builder.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/theme/color-scope/index.web.js +6 -7
- package/lib/module/theme/color-scope/index.web.js.map +1 -1
- package/lib/module/theme/color-scope/style-builder.js +19 -7
- package/lib/module/theme/color-scope/style-builder.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/theme/color-scope/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts +7 -5
- package/lib/typescript/commonjs/theme/color-scope/style-builder.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/theme/color-scope/index.web.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-scope/style-builder.d.ts +7 -5
- package/lib/typescript/module/theme/color-scope/style-builder.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/__tests__/BloomColorScope.test.tsx +12 -4
- 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/theme/color-scope/index.web.tsx +6 -7
- package/src/theme/color-scope/style-builder.ts +19 -7
- 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,135 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text, View } from 'react-native';
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
4
|
+
|
|
5
|
+
import { AvatarGroup } from './AvatarGroup';
|
|
6
|
+
import type { AvatarGroupItem } from './types';
|
|
7
|
+
|
|
8
|
+
const ITEMS: AvatarGroupItem[] = [
|
|
9
|
+
{ id: '1', displayName: 'Ada Lovelace', username: 'ada' },
|
|
10
|
+
{ id: '2', displayName: 'Grace Hopper', username: 'grace' },
|
|
11
|
+
{ id: '3', displayName: 'Alan Turing', username: 'alan' },
|
|
12
|
+
{ id: '4', displayName: 'Linus Torvalds', username: 'linus' },
|
|
13
|
+
{ id: '5', displayName: 'Margaret Hamilton', username: 'margaret' },
|
|
14
|
+
{ id: '6', displayName: 'Katherine Johnson', username: 'katherine' },
|
|
15
|
+
{ id: '7', displayName: 'Dennis Ritchie', username: 'dennis' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const WITH_PHOTOS: AvatarGroupItem[] = [
|
|
19
|
+
{
|
|
20
|
+
id: '1',
|
|
21
|
+
displayName: 'Nate Isern',
|
|
22
|
+
username: 'nate',
|
|
23
|
+
uri: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200&h=200&fit=crop',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: '2',
|
|
27
|
+
displayName: 'Jordan Lee',
|
|
28
|
+
username: 'jordan',
|
|
29
|
+
uri: 'https://images.unsplash.com/photo-1502685104226-ee32379fefbe?w=200&h=200&fit=crop',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: '3',
|
|
33
|
+
displayName: 'Sam Rivera',
|
|
34
|
+
username: 'sam',
|
|
35
|
+
uri: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=200&h=200&fit=crop',
|
|
36
|
+
},
|
|
37
|
+
{ id: '4', displayName: 'Priya Patel', username: 'priya' },
|
|
38
|
+
{ id: '5', displayName: 'Chris Doe', username: 'chris' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const meta: Meta<typeof AvatarGroup> = {
|
|
42
|
+
title: 'Components/AvatarGroup',
|
|
43
|
+
component: AvatarGroup,
|
|
44
|
+
argTypes: {
|
|
45
|
+
size: { control: { type: 'number', min: 16, max: 96, step: 4 } },
|
|
46
|
+
max: { control: { type: 'number', min: 1, max: 10, step: 1 } },
|
|
47
|
+
overlap: { control: { type: 'number', min: 0, max: 48, step: 1 } },
|
|
48
|
+
hoverCard: { control: 'boolean' },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export default meta;
|
|
53
|
+
|
|
54
|
+
type Story = StoryObj<typeof AvatarGroup>;
|
|
55
|
+
|
|
56
|
+
/** Header / starter-pack use-case: purely presentational, no interaction. */
|
|
57
|
+
export const Presentational: Story = {
|
|
58
|
+
args: { items: ITEMS, size: 32, max: 5 },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const WithPhotos: Story = {
|
|
62
|
+
args: { items: WITH_PHOTOS, size: 40, max: 5 },
|
|
63
|
+
name: 'With photos',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Overflow chip showing `+N` derived from `total`. */
|
|
67
|
+
export const WithTotal: Story = {
|
|
68
|
+
args: { items: ITEMS, size: 36, max: 4, total: 128 },
|
|
69
|
+
name: 'With explicit total',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Each avatar is pressable — the consumer navigates to the profile. */
|
|
73
|
+
export const Pressable: Story = {
|
|
74
|
+
render: (args) => (
|
|
75
|
+
<View style={{ gap: 12 }}>
|
|
76
|
+
<AvatarGroup
|
|
77
|
+
{...args}
|
|
78
|
+
items={ITEMS}
|
|
79
|
+
size={40}
|
|
80
|
+
max={5}
|
|
81
|
+
onPressItem={(item) => {
|
|
82
|
+
// eslint-disable-next-line no-alert
|
|
83
|
+
if (typeof window !== 'undefined') window.alert(`@${item.username}`);
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
<Text>Tap an avatar to fire onPressItem.</Text>
|
|
87
|
+
</View>
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Web-only: hovering an avatar reveals the UserHoverCard. */
|
|
92
|
+
export const HoverCard: Story = {
|
|
93
|
+
render: (args) => (
|
|
94
|
+
<View style={{ gap: 12, minHeight: 240 }}>
|
|
95
|
+
<AvatarGroup
|
|
96
|
+
{...args}
|
|
97
|
+
items={WITH_PHOTOS}
|
|
98
|
+
size={44}
|
|
99
|
+
max={5}
|
|
100
|
+
hoverCard
|
|
101
|
+
onPressItem={(item) => {
|
|
102
|
+
// eslint-disable-next-line no-alert
|
|
103
|
+
if (typeof window !== 'undefined') window.alert(`@${item.username}`);
|
|
104
|
+
}}
|
|
105
|
+
renderItemAction={(item) => (
|
|
106
|
+
<View
|
|
107
|
+
style={{
|
|
108
|
+
paddingHorizontal: 12,
|
|
109
|
+
paddingVertical: 6,
|
|
110
|
+
borderRadius: 999,
|
|
111
|
+
backgroundColor: '#1A73E8',
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<Text style={{ color: '#fff', fontWeight: '600', fontSize: 13 }}>
|
|
115
|
+
Follow
|
|
116
|
+
</Text>
|
|
117
|
+
</View>
|
|
118
|
+
)}
|
|
119
|
+
/>
|
|
120
|
+
<Text>Hover an avatar (web) to see the card with an injected action.</Text>
|
|
121
|
+
</View>
|
|
122
|
+
),
|
|
123
|
+
name: 'Hover card (web)',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const Sizes: Story = {
|
|
127
|
+
render: () => (
|
|
128
|
+
<View style={{ gap: 16 }}>
|
|
129
|
+
<AvatarGroup items={ITEMS} size={24} max={5} />
|
|
130
|
+
<AvatarGroup items={ITEMS} size={32} max={5} />
|
|
131
|
+
<AvatarGroup items={ITEMS} size={48} max={5} />
|
|
132
|
+
<AvatarGroup items={ITEMS} size={64} max={5} />
|
|
133
|
+
</View>
|
|
134
|
+
),
|
|
135
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React, { memo } from 'react';
|
|
2
|
+
|
|
3
|
+
import { AvatarGroupBase } from './AvatarGroupBase';
|
|
4
|
+
import type { AvatarGroupProps } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Native `AvatarGroup` — an overlapping, ringed stack of {@link Avatar}s with a
|
|
8
|
+
* trailing `+N` overflow chip.
|
|
9
|
+
*
|
|
10
|
+
* Hover behavior is web-only; on native the `hoverCard` / `renderItemAction`
|
|
11
|
+
* props are accepted for API parity but intentionally do nothing (no hover on
|
|
12
|
+
* touch devices). The web fork (`AvatarGroup.web.tsx`) wires up the hover card.
|
|
13
|
+
*/
|
|
14
|
+
const AvatarGroupComponent: React.FC<AvatarGroupProps> = (props) => {
|
|
15
|
+
return <AvatarGroupBase {...props} />;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const AvatarGroup = memo(AvatarGroupComponent);
|
|
19
|
+
AvatarGroup.displayName = 'AvatarGroup';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { Portal } from '../portal';
|
|
5
|
+
import { Z_INDEX } from '../styles/z-index';
|
|
6
|
+
import { UserHoverCard } from '../user-hover-card';
|
|
7
|
+
import {
|
|
8
|
+
AvatarGroupBase,
|
|
9
|
+
type AvatarGroupCellHoverHandlers,
|
|
10
|
+
} from './AvatarGroupBase';
|
|
11
|
+
import type { AvatarGroupItem, AvatarGroupProps } from './types';
|
|
12
|
+
|
|
13
|
+
/** Delay before dismissing the card so the cursor can travel into it. */
|
|
14
|
+
const CLOSE_DELAY_MS = 120;
|
|
15
|
+
/** Gap between the anchored avatar and the floating card. */
|
|
16
|
+
const CARD_GAP = 8;
|
|
17
|
+
const CARD_WIDTH = 280;
|
|
18
|
+
|
|
19
|
+
interface HoverState {
|
|
20
|
+
item: AvatarGroupItem;
|
|
21
|
+
index: number;
|
|
22
|
+
top: number;
|
|
23
|
+
left: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getItemName(item: AvatarGroupItem): string {
|
|
27
|
+
return item.displayName ?? item.name ?? item.username ?? '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Web `AvatarGroup`. Identical stack rendering to native via
|
|
32
|
+
* {@link AvatarGroupBase}, plus an optional hover card: when `hoverCard` is
|
|
33
|
+
* enabled, hovering an avatar reveals a {@link UserHoverCard} positioned beneath
|
|
34
|
+
* it. The card receives the per-item `renderItemAction` slot so the app can drop
|
|
35
|
+
* its SDK FollowButton inside.
|
|
36
|
+
*/
|
|
37
|
+
const AvatarGroupWebComponent: React.FC<AvatarGroupProps> = (props) => {
|
|
38
|
+
const { hoverCard, renderItemAction, onPressItem } = props;
|
|
39
|
+
const [hover, setHover] = useState<HoverState | null>(null);
|
|
40
|
+
const cellRefs = useRef<Map<number, View>>(new Map());
|
|
41
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
42
|
+
|
|
43
|
+
const cancelClose = useCallback(() => {
|
|
44
|
+
if (closeTimer.current) {
|
|
45
|
+
clearTimeout(closeTimer.current);
|
|
46
|
+
closeTimer.current = null;
|
|
47
|
+
}
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const scheduleClose = useCallback(() => {
|
|
51
|
+
cancelClose();
|
|
52
|
+
closeTimer.current = setTimeout(() => setHover(null), CLOSE_DELAY_MS);
|
|
53
|
+
}, [cancelClose]);
|
|
54
|
+
|
|
55
|
+
const registerCellRef = useCallback((index: number, node: View | null) => {
|
|
56
|
+
if (node) cellRefs.current.set(index, node);
|
|
57
|
+
else cellRefs.current.delete(index);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const handleHoverIn = useCallback(
|
|
61
|
+
(item: AvatarGroupItem, index: number) => {
|
|
62
|
+
cancelClose();
|
|
63
|
+
const node = cellRefs.current.get(index);
|
|
64
|
+
if (!node) return;
|
|
65
|
+
node.measureInWindow((x, y, width, height) => {
|
|
66
|
+
// Center the card under the avatar, clamped to the viewport.
|
|
67
|
+
const viewportWidth =
|
|
68
|
+
typeof window !== 'undefined' ? window.innerWidth : CARD_WIDTH;
|
|
69
|
+
let left = x + width / 2 - CARD_WIDTH / 2;
|
|
70
|
+
left = Math.max(
|
|
71
|
+
CARD_GAP,
|
|
72
|
+
Math.min(left, viewportWidth - CARD_WIDTH - CARD_GAP),
|
|
73
|
+
);
|
|
74
|
+
setHover({
|
|
75
|
+
item,
|
|
76
|
+
index,
|
|
77
|
+
top: y + height + CARD_GAP,
|
|
78
|
+
left,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
[cancelClose],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const hoverHandlers = useMemo<AvatarGroupCellHoverHandlers | undefined>(() => {
|
|
86
|
+
if (!hoverCard) return undefined;
|
|
87
|
+
return {
|
|
88
|
+
onHoverIn: handleHoverIn,
|
|
89
|
+
onHoverOut: scheduleClose,
|
|
90
|
+
registerCellRef,
|
|
91
|
+
};
|
|
92
|
+
}, [hoverCard, handleHoverIn, scheduleClose, registerCellRef]);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<AvatarGroupBase {...props} hoverHandlers={hoverHandlers} />
|
|
97
|
+
{hoverCard && hover && (
|
|
98
|
+
<Portal>
|
|
99
|
+
{/*
|
|
100
|
+
A Pressable hosts the hover-bridge: `onHoverIn`/`onHoverOut` are
|
|
101
|
+
typed in React Native (and map to mouseenter/mouseleave on
|
|
102
|
+
react-native-web), so the card stays open while the cursor is over
|
|
103
|
+
it without any web-only DOM-prop type suppression.
|
|
104
|
+
*/}
|
|
105
|
+
<Pressable
|
|
106
|
+
onHoverIn={cancelClose}
|
|
107
|
+
onHoverOut={scheduleClose}
|
|
108
|
+
accessibilityRole="none"
|
|
109
|
+
pointerEvents="auto"
|
|
110
|
+
style={[
|
|
111
|
+
webStyles.floating,
|
|
112
|
+
{ top: hover.top, left: hover.left, width: CARD_WIDTH },
|
|
113
|
+
]}
|
|
114
|
+
>
|
|
115
|
+
<UserHoverCard
|
|
116
|
+
avatar={hover.item.uri ?? undefined}
|
|
117
|
+
displayName={getItemName(hover.item)}
|
|
118
|
+
username={hover.item.username}
|
|
119
|
+
onPressProfile={
|
|
120
|
+
onPressItem
|
|
121
|
+
? () => onPressItem(hover.item, hover.index)
|
|
122
|
+
: undefined
|
|
123
|
+
}
|
|
124
|
+
action={renderItemAction?.(hover.item, hover.index)}
|
|
125
|
+
style={webStyles.card}
|
|
126
|
+
/>
|
|
127
|
+
</Pressable>
|
|
128
|
+
</Portal>
|
|
129
|
+
)}
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const webStyles = StyleSheet.create({
|
|
135
|
+
floating: {
|
|
136
|
+
position: 'fixed' as 'absolute',
|
|
137
|
+
zIndex: Z_INDEX.tooltip,
|
|
138
|
+
},
|
|
139
|
+
card: {
|
|
140
|
+
width: CARD_WIDTH,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
export const AvatarGroup = memo(AvatarGroupWebComponent);
|
|
145
|
+
AvatarGroup.displayName = 'AvatarGroup';
|
|
@@ -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';
|
|
@@ -3,20 +3,19 @@ import React, { Children, cloneElement, isValidElement, useContext, useMemo } fr
|
|
|
3
3
|
import { BloomThemeContext, type BloomThemeContextValue } from '../BloomThemeProvider';
|
|
4
4
|
import { buildTheme } from '../build-theme';
|
|
5
5
|
import type { AppColorName } from '../color-presets';
|
|
6
|
-
import {
|
|
6
|
+
import { buildScopeVars } from './style-builder';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* `
|
|
10
|
-
* `rgb(...)` string
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* full CSS color with no per-value wrapping needed.
|
|
9
|
+
* `buildScopeVars` returns every canonical token already resolved to an sRGB
|
|
10
|
+
* `rgb(...)` string, plus the `--color-*` aliases consumed by Tailwind v4
|
|
11
|
+
* utilities. Scoped onto an element's inline `style`, profile-level NativeWind
|
|
12
|
+
* classes resolve against the subtree preset instead of the document root.
|
|
14
13
|
*/
|
|
15
14
|
function buildWebScopeVars(
|
|
16
15
|
colorPreset: AppColorName,
|
|
17
16
|
mode: 'light' | 'dark',
|
|
18
17
|
): React.CSSProperties {
|
|
19
|
-
return
|
|
18
|
+
return buildScopeVars(colorPreset, mode) as React.CSSProperties;
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
export interface BloomColorScopeProps {
|