@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.
Files changed (119) hide show
  1. package/lib/commonjs/avatar-group/AvatarGroup.js +26 -0
  2. package/lib/commonjs/avatar-group/AvatarGroup.js.map +1 -0
  3. package/lib/commonjs/avatar-group/AvatarGroup.web.js +116 -0
  4. package/lib/commonjs/avatar-group/AvatarGroup.web.js.map +1 -0
  5. package/lib/commonjs/avatar-group/AvatarGroupBase.js +184 -0
  6. package/lib/commonjs/avatar-group/AvatarGroupBase.js.map +1 -0
  7. package/lib/commonjs/avatar-group/index.js +13 -0
  8. package/lib/commonjs/avatar-group/index.js.map +1 -0
  9. package/lib/commonjs/avatar-group/index.web.js +13 -0
  10. package/lib/commonjs/avatar-group/index.web.js.map +1 -0
  11. package/lib/commonjs/avatar-group/types.js +6 -0
  12. package/lib/commonjs/avatar-group/types.js.map +1 -0
  13. package/lib/commonjs/index.js +60 -44
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/index.web.js +74 -58
  16. package/lib/commonjs/index.web.js.map +1 -1
  17. package/lib/commonjs/theme/color-scope/index.web.js +6 -7
  18. package/lib/commonjs/theme/color-scope/index.web.js.map +1 -1
  19. package/lib/commonjs/theme/color-scope/style-builder.js +18 -6
  20. package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
  21. package/lib/commonjs/user-hover-card/UserHoverCard.js +193 -0
  22. package/lib/commonjs/user-hover-card/UserHoverCard.js.map +1 -0
  23. package/lib/commonjs/user-hover-card/index.js +13 -0
  24. package/lib/commonjs/user-hover-card/index.js.map +1 -0
  25. package/lib/commonjs/user-hover-card/types.js +6 -0
  26. package/lib/commonjs/user-hover-card/types.js.map +1 -0
  27. package/lib/module/avatar-group/AvatarGroup.js +21 -0
  28. package/lib/module/avatar-group/AvatarGroup.js.map +1 -0
  29. package/lib/module/avatar-group/AvatarGroup.web.js +111 -0
  30. package/lib/module/avatar-group/AvatarGroup.web.js.map +1 -0
  31. package/lib/module/avatar-group/AvatarGroupBase.js +179 -0
  32. package/lib/module/avatar-group/AvatarGroupBase.js.map +1 -0
  33. package/lib/module/avatar-group/index.js +4 -0
  34. package/lib/module/avatar-group/index.js.map +1 -0
  35. package/lib/module/avatar-group/index.web.js +4 -0
  36. package/lib/module/avatar-group/index.web.js.map +1 -0
  37. package/lib/module/avatar-group/types.js +4 -0
  38. package/lib/module/avatar-group/types.js.map +1 -0
  39. package/lib/module/index.js +2 -0
  40. package/lib/module/index.js.map +1 -1
  41. package/lib/module/index.web.js +2 -0
  42. package/lib/module/index.web.js.map +1 -1
  43. package/lib/module/theme/color-scope/index.web.js +6 -7
  44. package/lib/module/theme/color-scope/index.web.js.map +1 -1
  45. package/lib/module/theme/color-scope/style-builder.js +19 -7
  46. package/lib/module/theme/color-scope/style-builder.js.map +1 -1
  47. package/lib/module/user-hover-card/UserHoverCard.js +188 -0
  48. package/lib/module/user-hover-card/UserHoverCard.js.map +1 -0
  49. package/lib/module/user-hover-card/index.js +4 -0
  50. package/lib/module/user-hover-card/index.js.map +1 -0
  51. package/lib/module/user-hover-card/types.js +4 -0
  52. package/lib/module/user-hover-card/types.js.map +1 -0
  53. package/lib/typescript/commonjs/avatar-group/AvatarGroup.d.ts +4 -0
  54. package/lib/typescript/commonjs/avatar-group/AvatarGroup.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/avatar-group/AvatarGroup.web.d.ts +4 -0
  56. package/lib/typescript/commonjs/avatar-group/AvatarGroup.web.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts +16 -0
  58. package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/avatar-group/index.d.ts +3 -0
  60. package/lib/typescript/commonjs/avatar-group/index.d.ts.map +1 -0
  61. package/lib/typescript/commonjs/avatar-group/index.web.d.ts +3 -0
  62. package/lib/typescript/commonjs/avatar-group/index.web.d.ts.map +1 -0
  63. package/lib/typescript/commonjs/avatar-group/types.d.ts +63 -0
  64. package/lib/typescript/commonjs/avatar-group/types.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/index.d.ts +4 -0
  66. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/index.web.d.ts +4 -0
  68. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/theme/color-scope/index.web.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts +7 -5
  71. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/user-hover-card/UserHoverCard.d.ts +4 -0
  73. package/lib/typescript/commonjs/user-hover-card/UserHoverCard.d.ts.map +1 -0
  74. package/lib/typescript/commonjs/user-hover-card/index.d.ts +3 -0
  75. package/lib/typescript/commonjs/user-hover-card/index.d.ts.map +1 -0
  76. package/lib/typescript/commonjs/user-hover-card/types.d.ts +37 -0
  77. package/lib/typescript/commonjs/user-hover-card/types.d.ts.map +1 -0
  78. package/lib/typescript/module/avatar-group/AvatarGroup.d.ts +4 -0
  79. package/lib/typescript/module/avatar-group/AvatarGroup.d.ts.map +1 -0
  80. package/lib/typescript/module/avatar-group/AvatarGroup.web.d.ts +4 -0
  81. package/lib/typescript/module/avatar-group/AvatarGroup.web.d.ts.map +1 -0
  82. package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts +16 -0
  83. package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts.map +1 -0
  84. package/lib/typescript/module/avatar-group/index.d.ts +3 -0
  85. package/lib/typescript/module/avatar-group/index.d.ts.map +1 -0
  86. package/lib/typescript/module/avatar-group/index.web.d.ts +3 -0
  87. package/lib/typescript/module/avatar-group/index.web.d.ts.map +1 -0
  88. package/lib/typescript/module/avatar-group/types.d.ts +63 -0
  89. package/lib/typescript/module/avatar-group/types.d.ts.map +1 -0
  90. package/lib/typescript/module/index.d.ts +4 -0
  91. package/lib/typescript/module/index.d.ts.map +1 -1
  92. package/lib/typescript/module/index.web.d.ts +4 -0
  93. package/lib/typescript/module/index.web.d.ts.map +1 -1
  94. package/lib/typescript/module/theme/color-scope/index.web.d.ts.map +1 -1
  95. package/lib/typescript/module/theme/color-scope/style-builder.d.ts +7 -5
  96. package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
  97. package/lib/typescript/module/user-hover-card/UserHoverCard.d.ts +4 -0
  98. package/lib/typescript/module/user-hover-card/UserHoverCard.d.ts.map +1 -0
  99. package/lib/typescript/module/user-hover-card/index.d.ts +3 -0
  100. package/lib/typescript/module/user-hover-card/index.d.ts.map +1 -0
  101. package/lib/typescript/module/user-hover-card/types.d.ts +37 -0
  102. package/lib/typescript/module/user-hover-card/types.d.ts.map +1 -0
  103. package/package.json +30 -1
  104. package/src/__tests__/BloomColorScope.test.tsx +12 -4
  105. package/src/avatar-group/AvatarGroup.stories.tsx +135 -0
  106. package/src/avatar-group/AvatarGroup.tsx +19 -0
  107. package/src/avatar-group/AvatarGroup.web.tsx +145 -0
  108. package/src/avatar-group/AvatarGroupBase.tsx +249 -0
  109. package/src/avatar-group/index.ts +2 -0
  110. package/src/avatar-group/index.web.tsx +2 -0
  111. package/src/avatar-group/types.ts +64 -0
  112. package/src/index.ts +4 -0
  113. package/src/index.web.ts +4 -0
  114. package/src/theme/color-scope/index.web.tsx +6 -7
  115. package/src/theme/color-scope/style-builder.ts +19 -7
  116. package/src/user-hover-card/UserHoverCard.stories.tsx +113 -0
  117. package/src/user-hover-card/UserHoverCard.tsx +204 -0
  118. package/src/user-hover-card/index.ts +2 -0
  119. 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,2 @@
1
+ export { AvatarGroup } from './AvatarGroup';
2
+ export type { AvatarGroupProps, AvatarGroupItem } from './types';
@@ -0,0 +1,2 @@
1
+ export { AvatarGroup } from './AvatarGroup.web';
2
+ export type { AvatarGroupProps, AvatarGroupItem } from './types';
@@ -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 { getResolvedTokens } from '../token-registry';
6
+ import { buildScopeVars } from './style-builder';
7
7
 
8
8
  /**
9
- * `getResolvedTokens` returns every canonical token already resolved to an sRGB
10
- * `rgb(...)` string the SAME pipeline the document-root web writer and the
11
- * native writer use. Scoped onto an element's inline `style`, Tailwind v4's
12
- * compiled utilities read these as `var(--x)` directly, so each base token is a
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 getResolvedTokens(colorPreset, mode) as React.CSSProperties;
18
+ return buildScopeVars(colorPreset, mode) as React.CSSProperties;
20
19
  }
21
20
 
22
21
  export interface BloomColorScopeProps {