@oxyhq/bloom 0.29.6 → 0.30.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.
Files changed (183) hide show
  1. package/lib/commonjs/activity-heatmap/ActivityHeatmap.js +304 -0
  2. package/lib/commonjs/activity-heatmap/ActivityHeatmap.js.map +1 -0
  3. package/lib/commonjs/activity-heatmap/bucketByDay.js +51 -0
  4. package/lib/commonjs/activity-heatmap/bucketByDay.js.map +1 -0
  5. package/lib/commonjs/activity-heatmap/index.js +20 -0
  6. package/lib/commonjs/activity-heatmap/index.js.map +1 -0
  7. package/lib/commonjs/activity-heatmap/types.js +6 -0
  8. package/lib/commonjs/activity-heatmap/types.js.map +1 -0
  9. package/lib/commonjs/avatar-group/AvatarGroupBase.js +44 -18
  10. package/lib/commonjs/avatar-group/AvatarGroupBase.js.map +1 -1
  11. package/lib/commonjs/composition-bar/CompositionBar.js +159 -0
  12. package/lib/commonjs/composition-bar/CompositionBar.js.map +1 -0
  13. package/lib/commonjs/composition-bar/index.js +13 -0
  14. package/lib/commonjs/composition-bar/index.js.map +1 -0
  15. package/lib/commonjs/composition-bar/types.js +6 -0
  16. package/lib/commonjs/composition-bar/types.js.map +1 -0
  17. package/lib/commonjs/dot-grid-meter/DotGridMeter.js +69 -0
  18. package/lib/commonjs/dot-grid-meter/DotGridMeter.js.map +1 -0
  19. package/lib/commonjs/dot-grid-meter/index.js +13 -0
  20. package/lib/commonjs/dot-grid-meter/index.js.map +1 -0
  21. package/lib/commonjs/dot-grid-meter/types.js +6 -0
  22. package/lib/commonjs/dot-grid-meter/types.js.map +1 -0
  23. package/lib/commonjs/index.js +59 -12
  24. package/lib/commonjs/index.js.map +1 -1
  25. package/lib/commonjs/index.web.js +59 -12
  26. package/lib/commonjs/index.web.js.map +1 -1
  27. package/lib/commonjs/profile-card/ProfileCard.js +311 -0
  28. package/lib/commonjs/profile-card/ProfileCard.js.map +1 -0
  29. package/lib/commonjs/profile-card/index.js +13 -0
  30. package/lib/commonjs/profile-card/index.js.map +1 -0
  31. package/lib/commonjs/profile-card/types.js +6 -0
  32. package/lib/commonjs/profile-card/types.js.map +1 -0
  33. package/lib/commonjs/stat-bar/StatBar.js +213 -0
  34. package/lib/commonjs/stat-bar/StatBar.js.map +1 -0
  35. package/lib/commonjs/stat-bar/index.js +13 -0
  36. package/lib/commonjs/stat-bar/index.js.map +1 -0
  37. package/lib/commonjs/stat-bar/types.js +6 -0
  38. package/lib/commonjs/stat-bar/types.js.map +1 -0
  39. package/lib/module/activity-heatmap/ActivityHeatmap.js +299 -0
  40. package/lib/module/activity-heatmap/ActivityHeatmap.js.map +1 -0
  41. package/lib/module/activity-heatmap/bucketByDay.js +47 -0
  42. package/lib/module/activity-heatmap/bucketByDay.js.map +1 -0
  43. package/lib/module/activity-heatmap/index.js +5 -0
  44. package/lib/module/activity-heatmap/index.js.map +1 -0
  45. package/lib/module/activity-heatmap/types.js +4 -0
  46. package/lib/module/activity-heatmap/types.js.map +1 -0
  47. package/lib/module/avatar-group/AvatarGroupBase.js +44 -18
  48. package/lib/module/avatar-group/AvatarGroupBase.js.map +1 -1
  49. package/lib/module/composition-bar/CompositionBar.js +154 -0
  50. package/lib/module/composition-bar/CompositionBar.js.map +1 -0
  51. package/lib/module/composition-bar/index.js +4 -0
  52. package/lib/module/composition-bar/index.js.map +1 -0
  53. package/lib/module/composition-bar/types.js +4 -0
  54. package/lib/module/composition-bar/types.js.map +1 -0
  55. package/lib/module/dot-grid-meter/DotGridMeter.js +64 -0
  56. package/lib/module/dot-grid-meter/DotGridMeter.js.map +1 -0
  57. package/lib/module/dot-grid-meter/index.js +4 -0
  58. package/lib/module/dot-grid-meter/index.js.map +1 -0
  59. package/lib/module/dot-grid-meter/types.js +4 -0
  60. package/lib/module/dot-grid-meter/types.js.map +1 -0
  61. package/lib/module/index.js +6 -0
  62. package/lib/module/index.js.map +1 -1
  63. package/lib/module/index.web.js +6 -0
  64. package/lib/module/index.web.js.map +1 -1
  65. package/lib/module/profile-card/ProfileCard.js +306 -0
  66. package/lib/module/profile-card/ProfileCard.js.map +1 -0
  67. package/lib/module/profile-card/index.js +4 -0
  68. package/lib/module/profile-card/index.js.map +1 -0
  69. package/lib/module/profile-card/types.js +4 -0
  70. package/lib/module/profile-card/types.js.map +1 -0
  71. package/lib/module/stat-bar/StatBar.js +208 -0
  72. package/lib/module/stat-bar/StatBar.js.map +1 -0
  73. package/lib/module/stat-bar/index.js +4 -0
  74. package/lib/module/stat-bar/index.js.map +1 -0
  75. package/lib/module/stat-bar/types.js +4 -0
  76. package/lib/module/stat-bar/types.js.map +1 -0
  77. package/lib/typescript/commonjs/activity-heatmap/ActivityHeatmap.d.ts +4 -0
  78. package/lib/typescript/commonjs/activity-heatmap/ActivityHeatmap.d.ts.map +1 -0
  79. package/lib/typescript/commonjs/activity-heatmap/bucketByDay.d.ts +9 -0
  80. package/lib/typescript/commonjs/activity-heatmap/bucketByDay.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/activity-heatmap/index.d.ts +4 -0
  82. package/lib/typescript/commonjs/activity-heatmap/index.d.ts.map +1 -0
  83. package/lib/typescript/commonjs/activity-heatmap/types.d.ts +73 -0
  84. package/lib/typescript/commonjs/activity-heatmap/types.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/avatar-group/types.d.ts +23 -1
  87. package/lib/typescript/commonjs/avatar-group/types.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/composition-bar/CompositionBar.d.ts +4 -0
  89. package/lib/typescript/commonjs/composition-bar/CompositionBar.d.ts.map +1 -0
  90. package/lib/typescript/commonjs/composition-bar/index.d.ts +3 -0
  91. package/lib/typescript/commonjs/composition-bar/index.d.ts.map +1 -0
  92. package/lib/typescript/commonjs/composition-bar/types.d.ts +29 -0
  93. package/lib/typescript/commonjs/composition-bar/types.d.ts.map +1 -0
  94. package/lib/typescript/commonjs/dot-grid-meter/DotGridMeter.d.ts +4 -0
  95. package/lib/typescript/commonjs/dot-grid-meter/DotGridMeter.d.ts.map +1 -0
  96. package/lib/typescript/commonjs/dot-grid-meter/index.d.ts +3 -0
  97. package/lib/typescript/commonjs/dot-grid-meter/index.d.ts.map +1 -0
  98. package/lib/typescript/commonjs/dot-grid-meter/types.d.ts +36 -0
  99. package/lib/typescript/commonjs/dot-grid-meter/types.d.ts.map +1 -0
  100. package/lib/typescript/commonjs/index.d.ts +10 -0
  101. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/index.web.d.ts +10 -0
  103. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/profile-card/ProfileCard.d.ts +4 -0
  105. package/lib/typescript/commonjs/profile-card/ProfileCard.d.ts.map +1 -0
  106. package/lib/typescript/commonjs/profile-card/index.d.ts +3 -0
  107. package/lib/typescript/commonjs/profile-card/index.d.ts.map +1 -0
  108. package/lib/typescript/commonjs/profile-card/types.d.ts +86 -0
  109. package/lib/typescript/commonjs/profile-card/types.d.ts.map +1 -0
  110. package/lib/typescript/commonjs/stat-bar/StatBar.d.ts +4 -0
  111. package/lib/typescript/commonjs/stat-bar/StatBar.d.ts.map +1 -0
  112. package/lib/typescript/commonjs/stat-bar/index.d.ts +3 -0
  113. package/lib/typescript/commonjs/stat-bar/index.d.ts.map +1 -0
  114. package/lib/typescript/commonjs/stat-bar/types.d.ts +62 -0
  115. package/lib/typescript/commonjs/stat-bar/types.d.ts.map +1 -0
  116. package/lib/typescript/module/activity-heatmap/ActivityHeatmap.d.ts +4 -0
  117. package/lib/typescript/module/activity-heatmap/ActivityHeatmap.d.ts.map +1 -0
  118. package/lib/typescript/module/activity-heatmap/bucketByDay.d.ts +9 -0
  119. package/lib/typescript/module/activity-heatmap/bucketByDay.d.ts.map +1 -0
  120. package/lib/typescript/module/activity-heatmap/index.d.ts +4 -0
  121. package/lib/typescript/module/activity-heatmap/index.d.ts.map +1 -0
  122. package/lib/typescript/module/activity-heatmap/types.d.ts +73 -0
  123. package/lib/typescript/module/activity-heatmap/types.d.ts.map +1 -0
  124. package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts.map +1 -1
  125. package/lib/typescript/module/avatar-group/types.d.ts +23 -1
  126. package/lib/typescript/module/avatar-group/types.d.ts.map +1 -1
  127. package/lib/typescript/module/composition-bar/CompositionBar.d.ts +4 -0
  128. package/lib/typescript/module/composition-bar/CompositionBar.d.ts.map +1 -0
  129. package/lib/typescript/module/composition-bar/index.d.ts +3 -0
  130. package/lib/typescript/module/composition-bar/index.d.ts.map +1 -0
  131. package/lib/typescript/module/composition-bar/types.d.ts +29 -0
  132. package/lib/typescript/module/composition-bar/types.d.ts.map +1 -0
  133. package/lib/typescript/module/dot-grid-meter/DotGridMeter.d.ts +4 -0
  134. package/lib/typescript/module/dot-grid-meter/DotGridMeter.d.ts.map +1 -0
  135. package/lib/typescript/module/dot-grid-meter/index.d.ts +3 -0
  136. package/lib/typescript/module/dot-grid-meter/index.d.ts.map +1 -0
  137. package/lib/typescript/module/dot-grid-meter/types.d.ts +36 -0
  138. package/lib/typescript/module/dot-grid-meter/types.d.ts.map +1 -0
  139. package/lib/typescript/module/index.d.ts +10 -0
  140. package/lib/typescript/module/index.d.ts.map +1 -1
  141. package/lib/typescript/module/index.web.d.ts +10 -0
  142. package/lib/typescript/module/index.web.d.ts.map +1 -1
  143. package/lib/typescript/module/profile-card/ProfileCard.d.ts +4 -0
  144. package/lib/typescript/module/profile-card/ProfileCard.d.ts.map +1 -0
  145. package/lib/typescript/module/profile-card/index.d.ts +3 -0
  146. package/lib/typescript/module/profile-card/index.d.ts.map +1 -0
  147. package/lib/typescript/module/profile-card/types.d.ts +86 -0
  148. package/lib/typescript/module/profile-card/types.d.ts.map +1 -0
  149. package/lib/typescript/module/stat-bar/StatBar.d.ts +4 -0
  150. package/lib/typescript/module/stat-bar/StatBar.d.ts.map +1 -0
  151. package/lib/typescript/module/stat-bar/index.d.ts +3 -0
  152. package/lib/typescript/module/stat-bar/index.d.ts.map +1 -0
  153. package/lib/typescript/module/stat-bar/types.d.ts +62 -0
  154. package/lib/typescript/module/stat-bar/types.d.ts.map +1 -0
  155. package/package.json +56 -1
  156. package/src/__tests__/StatWidgets.test.tsx +184 -0
  157. package/src/__tests__/public-api-contract.test.ts +15 -0
  158. package/src/activity-heatmap/ActivityHeatmap.stories.tsx +108 -0
  159. package/src/activity-heatmap/ActivityHeatmap.tsx +289 -0
  160. package/src/activity-heatmap/bucketByDay.ts +49 -0
  161. package/src/activity-heatmap/index.ts +3 -0
  162. package/src/activity-heatmap/types.ts +74 -0
  163. package/src/avatar-group/AvatarGroup.stories.tsx +14 -0
  164. package/src/avatar-group/AvatarGroupBase.tsx +48 -20
  165. package/src/avatar-group/types.ts +23 -1
  166. package/src/composition-bar/CompositionBar.stories.tsx +70 -0
  167. package/src/composition-bar/CompositionBar.tsx +161 -0
  168. package/src/composition-bar/index.ts +2 -0
  169. package/src/composition-bar/types.ts +30 -0
  170. package/src/dot-grid-meter/DotGridMeter.stories.tsx +47 -0
  171. package/src/dot-grid-meter/DotGridMeter.tsx +71 -0
  172. package/src/dot-grid-meter/index.ts +2 -0
  173. package/src/dot-grid-meter/types.ts +36 -0
  174. package/src/index.ts +24 -0
  175. package/src/index.web.ts +24 -0
  176. package/src/profile-card/ProfileCard.stories.tsx +210 -0
  177. package/src/profile-card/ProfileCard.tsx +269 -0
  178. package/src/profile-card/index.ts +9 -0
  179. package/src/profile-card/types.ts +86 -0
  180. package/src/stat-bar/StatBar.stories.tsx +61 -0
  181. package/src/stat-bar/StatBar.tsx +126 -0
  182. package/src/stat-bar/index.ts +7 -0
  183. package/src/stat-bar/types.ts +65 -0
@@ -0,0 +1,74 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+
3
+ /** A single day cell in the {@link ActivityHeatmap}. */
4
+ export interface ActivityHeatmapDay {
5
+ /** ISO `YYYY-MM-DD` date (parsed in UTC). */
6
+ date: string;
7
+ /** Activity count for that day. */
8
+ count: number;
9
+ }
10
+
11
+ export interface ActivityHeatmapProps {
12
+ /** Per-day activity counts. Days not present are treated as count 0. */
13
+ data: ActivityHeatmapDay[];
14
+ /**
15
+ * Number of days to render, ending at `endDate`.
16
+ * @default 364
17
+ */
18
+ numDays?: number;
19
+ /**
20
+ * Last (rightmost) day of the grid as `YYYY-MM-DD`. Defaults to the latest
21
+ * date present in `data`. The component never reads the system clock, so pass
22
+ * `endDate` explicitly to anchor the grid to "today".
23
+ */
24
+ endDate?: string;
25
+ /**
26
+ * Ascending count thresholds partitioning positive counts into color levels.
27
+ * With N thresholds a positive count maps to `colorScale[number of thresholds
28
+ * met]` (clamped to the scale). Count 0 always uses `emptyColor`.
29
+ * @default [1, 3, 6, 10]
30
+ */
31
+ levels?: number[];
32
+ /**
33
+ * Colors low→high. Index 0 is the faintest activity shade, the last index the
34
+ * strongest. Defaults to five opacity steps of `theme.colors.primary`.
35
+ */
36
+ colorScale?: string[];
37
+ /**
38
+ * Color for zero-activity days.
39
+ * @default theme.colors.backgroundSecondary
40
+ */
41
+ emptyColor?: string;
42
+ /**
43
+ * Side length of each day cell in px.
44
+ * @default 12
45
+ */
46
+ cellSize?: number;
47
+ /**
48
+ * Gap between cells in px (both axes).
49
+ * @default 3
50
+ */
51
+ gap?: number;
52
+ /**
53
+ * First day of the week: 0 = Sunday, 1 = Monday. Controls both the row order
54
+ * and how the leading partial week aligns.
55
+ * @default 0
56
+ */
57
+ weekStartsOn?: 0 | 1;
58
+ /**
59
+ * 12 short month names indexed by month (0 = January), for the month-label
60
+ * row. Pass localized names; defaults to English abbreviations.
61
+ */
62
+ monthLabels?: string[];
63
+ /**
64
+ * 7 short weekday names indexed by day of week (0 = Sunday). When provided, a
65
+ * weekday-label column is rendered to the left of the grid. Omit for no
66
+ * weekday column. Pass empty strings for rows you want left blank.
67
+ */
68
+ weekdayLabels?: string[];
69
+ /** Called with the pressed day. When set, each populated cell is pressable. */
70
+ onPressDay?: (day: ActivityHeatmapDay) => void;
71
+ /** Container style override. */
72
+ style?: StyleProp<ViewStyle>;
73
+ testID?: string;
74
+ }
@@ -133,3 +133,17 @@ export const Sizes: Story = {
133
133
  </View>
134
134
  ),
135
135
  };
136
+
137
+ /**
138
+ * Row layout: avatars placed adjacent with a positive gap and no separator
139
+ * ring — the "top tokens" / adjacent-icons strip. Still collapses to `+N`.
140
+ */
141
+ export const RowLayout: Story = {
142
+ render: () => (
143
+ <View style={{ gap: 16 }}>
144
+ <AvatarGroup layout="row" items={WITH_PHOTOS} size={28} max={4} />
145
+ <AvatarGroup layout="row" items={WITH_PHOTOS} size={36} max={5} spacing={10} />
146
+ </View>
147
+ ),
148
+ name: 'Row layout',
149
+ };
@@ -41,18 +41,22 @@ interface AvatarGroupBaseProps extends AvatarGroupProps {
41
41
 
42
42
  const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
43
43
  items,
44
+ layout = 'stack',
44
45
  size = 32,
45
46
  variant = 'thumb',
46
47
  max = 5,
47
48
  total,
48
49
  overlap,
50
+ spacing,
49
51
  ringColor,
52
+ showInitials = false,
50
53
  onPressItem,
51
54
  style,
52
55
  testID,
53
56
  hoverHandlers,
54
57
  }) => {
55
58
  const theme = useTheme();
59
+ const isRow = layout === 'row';
56
60
 
57
61
  // The ring is the thin separator drawn between overlapping avatars. It
58
62
  // defaults to the page background so avatars read as cleanly punched out of
@@ -65,6 +69,10 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
65
69
  // 1px border on each side, so the negative margin absorbs both borders to
66
70
  // keep the visual overlap equal to `effectiveOverlap`.
67
71
  const negativeMargin = -(effectiveOverlap + RING_WIDTH * 2);
72
+ // Row layout: a positive gap between adjacent avatars (no overlap, no ring).
73
+ const rowGap = spacing ?? Math.round(size * DEFAULT_OVERLAP_RATIO);
74
+ // Horizontal offset applied to every avatar after the first.
75
+ const itemMargin = isRow ? rowGap : negativeMargin;
68
76
 
69
77
  const shown = useMemo(
70
78
  () => items.slice(0, Math.max(0, max)),
@@ -74,11 +82,20 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
74
82
  const realTotal = total ?? items.length;
75
83
  const overflow = Math.max(0, realTotal - shown.length);
76
84
 
77
- // Each cell is a circular, clipped container with a 1px ring border. The inner
78
- // Avatar is inset by the border on both sides so the image fills the circle
79
- // exactly inside the ring.
80
- const innerSize = size - RING_WIDTH * 2;
85
+ // Stack: each cell is a circular, clipped container with a 1px ring border,
86
+ // and the inner Avatar is inset by the border on both sides. Row: no ring, so
87
+ // the Avatar fills the full cell.
88
+ const innerSize = isRow ? size : size - RING_WIDTH * 2;
81
89
  const cellStyle = useMemo((): ViewStyle => {
90
+ if (isRow) {
91
+ return {
92
+ width: size,
93
+ height: size,
94
+ borderRadius: size / 2,
95
+ alignItems: 'center',
96
+ justifyContent: 'center',
97
+ };
98
+ }
82
99
  return {
83
100
  width: size,
84
101
  height: size,
@@ -89,7 +106,7 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
89
106
  alignItems: 'center',
90
107
  justifyContent: 'center',
91
108
  };
92
- }, [size, ring]);
109
+ }, [isRow, size, ring]);
93
110
 
94
111
  const overflowTextStyle = useMemo(
95
112
  () => ({
@@ -121,12 +138,14 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
121
138
  ? `${name ?? item.username} (@${item.username})`
122
139
  : name;
123
140
 
124
- // The negative margin and stacking order live on the outermost
125
- // row element so layout and hit-testing stay aligned with the visual
126
- // overlap. Earlier siblings render on top of later ones.
141
+ // The horizontal margin and stacking order live on the outermost row
142
+ // element so layout and hit-testing stay aligned with the visual
143
+ // overlap. In stack mode earlier siblings render on top of later ones
144
+ // (descending zIndex); in row mode there is no overlap so stacking
145
+ // order is irrelevant.
127
146
  const wrapperStyle: ViewStyle = {
128
- ...(index > 0 && { marginLeft: negativeMargin }),
129
- zIndex: shown.length - index,
147
+ ...(index > 0 && { marginLeft: itemMargin }),
148
+ ...(isRow ? {} : { zIndex: shown.length - index }),
130
149
  };
131
150
 
132
151
  const cell = (
@@ -147,9 +166,15 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
147
166
  `'thumb'`) is forwarded so the resolver builds a small rendition
148
167
  for these stacked avatars. No `name` is passed, so a missing
149
168
  avatar shows Avatar's neutral default placeholder rather than a
150
- colored deterministic initial.
169
+ colored deterministic initial. When `showInitials` is set the item's
170
+ name IS passed, so an avatar-less item renders a colored initial.
151
171
  */}
152
- <Avatar source={item.uri ?? undefined} variant={variant} size={innerSize} />
172
+ <Avatar
173
+ source={item.uri ?? undefined}
174
+ name={showInitials ? name : undefined}
175
+ variant={variant}
176
+ size={innerSize}
177
+ />
153
178
  </View>
154
179
  );
155
180
 
@@ -190,7 +215,8 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
190
215
  <OverflowCircle
191
216
  count={overflow}
192
217
  cellStyle={cellStyle}
193
- negativeMargin={shown.length > 0 ? negativeMargin : 0}
218
+ marginLeft={shown.length > 0 ? itemMargin : 0}
219
+ isRow={isRow}
194
220
  textStyle={overflowTextStyle}
195
221
  onPress={overflowOnPress}
196
222
  />
@@ -202,23 +228,25 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
202
228
  function OverflowCircle({
203
229
  count,
204
230
  cellStyle,
205
- negativeMargin,
231
+ marginLeft,
232
+ isRow,
206
233
  textStyle,
207
234
  onPress,
208
235
  }: {
209
236
  count: number;
210
237
  cellStyle: ViewStyle;
211
- negativeMargin: number;
238
+ marginLeft: number;
239
+ isRow: boolean;
212
240
  textStyle: { color: string; fontSize: number; fontWeight: '600' };
213
241
  onPress?: () => void;
214
242
  }) {
215
243
  const theme = useTheme();
216
- // The count circle is anchored at the lowest stacking order so it tucks behind
217
- // the last avatar's ring. The margin lives on the outer element to keep layout
218
- // aligned with the overlap.
244
+ // In stack mode the count circle is anchored at the lowest stacking order so
245
+ // it tucks behind the last avatar's ring; row mode has no overlap. The margin
246
+ // lives on the outer element to keep layout aligned with the spacing.
219
247
  const wrapperStyle: ViewStyle = {
220
- ...(negativeMargin !== 0 && { marginLeft: negativeMargin }),
221
- zIndex: 0,
248
+ ...(marginLeft !== 0 && { marginLeft }),
249
+ ...(isRow ? {} : { zIndex: 0 }),
222
250
  };
223
251
 
224
252
  // A solid "+N" count circle: secondary-text-colored fill with white text,
@@ -28,6 +28,15 @@ export interface AvatarGroupItem {
28
28
  export interface AvatarGroupProps {
29
29
  /** Members to render, in stacking order (first item sits on top). */
30
30
  items: AvatarGroupItem[];
31
+ /**
32
+ * How the avatars are arranged.
33
+ * - `'stack'` (default): overlapping facepile with a thin separator ring, a
34
+ * trailing `+N` overflow chip, and the first item on top.
35
+ * - `'row'`: avatars placed adjacent with a positive `spacing` gap and NO
36
+ * separator ring — an "adjacent row" of icons (e.g. a top-tokens strip).
37
+ * Still collapses into the `+N` overflow chip at `max`.
38
+ */
39
+ layout?: 'stack' | 'row';
31
40
  /** Diameter of each avatar in pixels. */
32
41
  size?: number;
33
42
  /**
@@ -47,15 +56,28 @@ export interface AvatarGroupProps {
47
56
  total?: number;
48
57
  /**
49
58
  * Horizontal overlap between adjacent avatars in pixels. Defaults to ~1/3 of
50
- * `size`. Larger values pull avatars closer together.
59
+ * `size`. Larger values pull avatars closer together. `layout='stack'` only.
51
60
  */
52
61
  overlap?: number;
62
+ /**
63
+ * Horizontal gap between adjacent avatars in pixels when `layout='row'`.
64
+ * Defaults to ~1/3 of `size`. `layout='row'` only.
65
+ */
66
+ spacing?: number;
53
67
  /**
54
68
  * Color of the thin ring/border drawn around each avatar so overlapping
55
69
  * avatars separate cleanly. Defaults to the theme `background` color so the
56
70
  * avatars read as punched out of the surface behind them.
57
71
  */
58
72
  ringColor?: string;
73
+ /**
74
+ * When true, an item with no `uri` renders {@link Avatar}'s colored
75
+ * deterministic initial (derived from its name) instead of the neutral
76
+ * default placeholder. Use for decorative/icon rows (e.g. a top-tokens strip)
77
+ * where distinct colors read better than identical grey circles. Defaults to
78
+ * `false` to preserve the neutral facepile placeholder for avatar-less people.
79
+ */
80
+ showInitials?: boolean;
59
81
  /**
60
82
  * Per-avatar press handler. When provided, each avatar (and the overflow chip)
61
83
  * becomes pressable. When omitted, the group is purely presentational.
@@ -0,0 +1,70 @@
1
+ import React, { useState } from 'react';
2
+ import { View } from 'react-native';
3
+ import type { Meta, StoryObj } from '@storybook/react-vite';
4
+
5
+ import { CompositionBar } from './index';
6
+ import type { CompositionCategory } from './index';
7
+ import { BloomThemeProvider } from '../theme';
8
+
9
+ const CATEGORIES: CompositionCategory[] = [
10
+ { key: 'content', name: 'Content', amount: 320, color: '#6366F1' },
11
+ { key: 'social', name: 'Social', amount: 180, color: '#10B981' },
12
+ { key: 'trust', name: 'Trust', amount: 96, color: '#F59E0B' },
13
+ { key: 'physical', name: 'Physical', amount: 44, color: '#EF4444' },
14
+ ];
15
+
16
+ function Demo() {
17
+ const [selectedKey, setSelectedKey] = useState<string | null>(null);
18
+ return (
19
+ <CompositionBar
20
+ categories={CATEGORIES}
21
+ selectedKey={selectedKey}
22
+ onSelect={(key) => setSelectedKey((prev) => (prev === key ? null : key))}
23
+ hintLabel="Tap a segment to see its share"
24
+ />
25
+ );
26
+ }
27
+
28
+ const meta: Meta<typeof CompositionBar> = {
29
+ title: 'Data Display/CompositionBar',
30
+ component: CompositionBar,
31
+ };
32
+
33
+ export default meta;
34
+
35
+ type Story = StoryObj<typeof CompositionBar>;
36
+
37
+ export const Light: Story = {
38
+ render: () => (
39
+ <BloomThemeProvider mode="light">
40
+ <View style={{ padding: 24, width: 360 }}>
41
+ <Demo />
42
+ </View>
43
+ </BloomThemeProvider>
44
+ ),
45
+ };
46
+
47
+ export const Dark: Story = {
48
+ render: () => (
49
+ <BloomThemeProvider mode="dark">
50
+ <View style={{ padding: 24, width: 360, backgroundColor: '#000' }}>
51
+ <Demo />
52
+ </View>
53
+ </BloomThemeProvider>
54
+ ),
55
+ };
56
+
57
+ export const Empty: Story = {
58
+ render: () => (
59
+ <BloomThemeProvider mode="light">
60
+ <View style={{ padding: 24, width: 360 }}>
61
+ <CompositionBar
62
+ categories={[]}
63
+ selectedKey={null}
64
+ onSelect={() => {}}
65
+ hintLabel="No data yet"
66
+ />
67
+ </View>
68
+ </BloomThemeProvider>
69
+ ),
70
+ };
@@ -0,0 +1,161 @@
1
+ import React, { memo, useMemo } from 'react';
2
+ import { View, StyleSheet, Pressable } from 'react-native';
3
+
4
+ import { useTheme } from '../theme/use-theme';
5
+ import { Text } from '../typography';
6
+ import type { CompositionBarProps } from './types';
7
+
8
+ const BAR_HEIGHT = 44;
9
+ /** Fully-rounded capsule ends. */
10
+ const RADIUS_FULL = BAR_HEIGHT / 2;
11
+ /** Slight inner-corner rounding so segments read as one bar, not buttons. */
12
+ const RADIUS_INNER = 8;
13
+ /** Floor so a tiny category stays tappable/visible; flexbox renormalizes the rest. */
14
+ const MIN_SEGMENT_WIDTH = 18;
15
+
16
+ function defaultFormatReadout(points: number, percent: number): string {
17
+ return `${points} pts · ${percent}%`;
18
+ }
19
+
20
+ /**
21
+ * The distribution bar — the signature composition visual. A single thick
22
+ * capsule split into one colored block per category, each block's width
23
+ * proportional to that category's share of the total (never a progress bar —
24
+ * the segments always fill the full width). The first/last blocks carry the
25
+ * rounded capsule ends; inner blocks are only slightly rounded and separated by
26
+ * a hairline-thin background gap so they read as ONE bar. Tapping a block
27
+ * selects it (dimming the rest) and reveals a readout of its name, points, and
28
+ * percentage; selection is lifted so the matching list row highlights in sync.
29
+ */
30
+ const CompositionBarComponent: React.FC<CompositionBarProps> = ({
31
+ categories,
32
+ selectedKey,
33
+ onSelect,
34
+ hintLabel,
35
+ formatReadout = defaultFormatReadout,
36
+ style,
37
+ testID,
38
+ }) => {
39
+ const { colors } = useTheme();
40
+
41
+ const positive = useMemo(
42
+ () => categories.filter((category) => category.amount > 0),
43
+ [categories],
44
+ );
45
+ const total = useMemo(
46
+ () => positive.reduce((sum, category) => sum + category.amount, 0),
47
+ [positive],
48
+ );
49
+ const selected = useMemo(
50
+ () => (selectedKey ? positive.find((category) => category.key === selectedKey) ?? null : null),
51
+ [positive, selectedKey],
52
+ );
53
+
54
+ if (total <= 0) {
55
+ return (
56
+ <View
57
+ style={[styles.emptyTrack, { backgroundColor: colors.backgroundSecondary }, style]}
58
+ testID={testID}
59
+ />
60
+ );
61
+ }
62
+
63
+ const selectedPercent = selected ? Math.round((selected.amount / total) * 100) : 0;
64
+
65
+ return (
66
+ <View style={[styles.wrap, style]} testID={testID}>
67
+ <View style={styles.bar}>
68
+ {positive.map((category, index) => {
69
+ const isFirst = index === 0;
70
+ const isLast = index === positive.length - 1;
71
+ const dim = selected != null && category.key !== selectedKey;
72
+ return (
73
+ <Pressable
74
+ key={category.key}
75
+ onPress={() => onSelect(category.key)}
76
+ accessibilityRole="button"
77
+ accessibilityLabel={category.name}
78
+ accessibilityState={{ selected: category.key === selectedKey }}
79
+ style={[
80
+ styles.segment,
81
+ {
82
+ flexGrow: category.amount,
83
+ backgroundColor: category.color,
84
+ opacity: dim ? 0.35 : 1,
85
+ borderTopLeftRadius: isFirst ? RADIUS_FULL : RADIUS_INNER,
86
+ borderBottomLeftRadius: isFirst ? RADIUS_FULL : RADIUS_INNER,
87
+ borderTopRightRadius: isLast ? RADIUS_FULL : RADIUS_INNER,
88
+ borderBottomRightRadius: isLast ? RADIUS_FULL : RADIUS_INNER,
89
+ },
90
+ ]}
91
+ />
92
+ );
93
+ })}
94
+ </View>
95
+
96
+ {selected ? (
97
+ <View style={styles.readout}>
98
+ <View style={[styles.readoutDot, { backgroundColor: selected.color }]} />
99
+ <Text style={[styles.readoutName, { color: colors.text }]} numberOfLines={1}>
100
+ {selected.name}
101
+ </Text>
102
+ <Text style={[styles.readoutMeta, { color: colors.textSecondary }]} numberOfLines={1}>
103
+ {formatReadout(selected.amount, selectedPercent)}
104
+ </Text>
105
+ </View>
106
+ ) : (
107
+ <Text style={[styles.hint, { color: colors.textSecondary }]} numberOfLines={1}>
108
+ {hintLabel}
109
+ </Text>
110
+ )}
111
+ </View>
112
+ );
113
+ };
114
+
115
+ const styles = StyleSheet.create({
116
+ wrap: {
117
+ gap: 12,
118
+ },
119
+ bar: {
120
+ flexDirection: 'row',
121
+ height: BAR_HEIGHT,
122
+ gap: 3,
123
+ },
124
+ segment: {
125
+ flexBasis: 0,
126
+ minWidth: MIN_SEGMENT_WIDTH,
127
+ height: BAR_HEIGHT,
128
+ borderCurve: 'continuous',
129
+ },
130
+ emptyTrack: {
131
+ height: BAR_HEIGHT,
132
+ borderRadius: RADIUS_FULL,
133
+ borderCurve: 'continuous',
134
+ },
135
+ readout: {
136
+ flexDirection: 'row',
137
+ alignItems: 'center',
138
+ gap: 8,
139
+ },
140
+ readoutDot: {
141
+ width: 10,
142
+ height: 10,
143
+ borderRadius: 5,
144
+ },
145
+ readoutName: {
146
+ fontSize: 14,
147
+ fontWeight: '700',
148
+ letterSpacing: -0.2,
149
+ },
150
+ readoutMeta: {
151
+ flexShrink: 1,
152
+ fontSize: 13,
153
+ fontVariant: ['tabular-nums'],
154
+ },
155
+ hint: {
156
+ fontSize: 13,
157
+ },
158
+ });
159
+
160
+ export const CompositionBar = memo(CompositionBarComponent);
161
+ CompositionBar.displayName = 'CompositionBar';
@@ -0,0 +1,2 @@
1
+ export { CompositionBar } from './CompositionBar';
2
+ export type { CompositionBarProps, CompositionCategory } from './types';
@@ -0,0 +1,30 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+
3
+ /** One distribution-bar category. Percentages are computed internally. */
4
+ export interface CompositionCategory {
5
+ key: string;
6
+ name: string;
7
+ /** Non-negative magnitude; the segment width is its share of the total. */
8
+ amount: number;
9
+ color: string;
10
+ }
11
+
12
+ export interface CompositionBarProps {
13
+ /** Categories to render, in left→right order. Non-positive amounts are dropped. */
14
+ categories: CompositionCategory[];
15
+ /** The currently-selected category key, or null. */
16
+ selectedKey: string | null;
17
+ /** Called with a category key when a segment is pressed. */
18
+ onSelect: (key: string) => void;
19
+ /** Text shown below the bar when nothing is selected. */
20
+ hintLabel: string;
21
+ /**
22
+ * Formats the readout shown below the bar for the selected segment. Receives
23
+ * the raw amount (points) and the rounded share percentage (0–100).
24
+ * @default `(points, percent) => `${points} pts · ${percent}%``
25
+ */
26
+ formatReadout?: (points: number, percent: number) => string;
27
+ /** Container style override. */
28
+ style?: StyleProp<ViewStyle>;
29
+ testID?: string;
30
+ }
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import type { Meta, StoryObj } from '@storybook/react-vite';
4
+
5
+ import { DotGridMeter } from './index';
6
+ import { BloomThemeProvider } from '../theme';
7
+ import { Text } from '../typography';
8
+
9
+ function Demo() {
10
+ return (
11
+ <View style={{ gap: 16 }}>
12
+ <Text style={{ fontSize: 12, opacity: 0.6 }}>Token diversity</Text>
13
+ <DotGridMeter filled={34} total={50} columns={10} />
14
+ <Text style={{ fontSize: 12, opacity: 0.6 }}>Compact (accent)</Text>
15
+ <DotGridMeter filled={7} total={12} columns={6} dotSize={12} gap={8} filledColor="#8B5CF6" />
16
+ </View>
17
+ );
18
+ }
19
+
20
+ const meta: Meta<typeof DotGridMeter> = {
21
+ title: 'Data Display/DotGridMeter',
22
+ component: DotGridMeter,
23
+ };
24
+
25
+ export default meta;
26
+
27
+ type Story = StoryObj<typeof DotGridMeter>;
28
+
29
+ export const Light: Story = {
30
+ render: () => (
31
+ <BloomThemeProvider mode="light">
32
+ <View style={{ padding: 24 }}>
33
+ <Demo />
34
+ </View>
35
+ </BloomThemeProvider>
36
+ ),
37
+ };
38
+
39
+ export const Dark: Story = {
40
+ render: () => (
41
+ <BloomThemeProvider mode="dark">
42
+ <View style={{ padding: 24, backgroundColor: '#000' }}>
43
+ <Demo />
44
+ </View>
45
+ </BloomThemeProvider>
46
+ ),
47
+ };
@@ -0,0 +1,71 @@
1
+ import React, { memo, useMemo } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { useTheme } from '../theme/use-theme';
5
+ import type { DotGridMeterProps } from './types';
6
+
7
+ /**
8
+ * A data-driven grid of small circular dots — the "diversity meter" visual. The
9
+ * first `filled` dots use `filledColor`, the rest use `emptyColor`, laid out in
10
+ * a flex-wrapped grid of `columns` per row. Pure `<View>` composition (no SVG),
11
+ * so it renders identically on web and native.
12
+ */
13
+ const DotGridMeterComponent: React.FC<DotGridMeterProps> = ({
14
+ filled,
15
+ total,
16
+ columns = 10,
17
+ dotSize = 10,
18
+ gap = 6,
19
+ filledColor,
20
+ emptyColor,
21
+ style,
22
+ testID,
23
+ }) => {
24
+ const { colors } = useTheme();
25
+ const onColor = filledColor ?? colors.success;
26
+ const offColor = emptyColor ?? colors.backgroundSecondary;
27
+
28
+ const safeTotal = Math.max(0, Math.floor(total));
29
+ const safeFilled = Math.min(safeTotal, Math.max(0, Math.floor(filled)));
30
+ // A row can hold at most `columns` dots; a grid narrower than one full row
31
+ // should still wrap tightly, so the container width follows the smaller count.
32
+ const perRow = Math.max(1, Math.floor(columns));
33
+ const rowWidth = Math.min(safeTotal, perRow);
34
+
35
+ const dots = useMemo(
36
+ () => Array.from({ length: safeTotal }, (_, index) => index < safeFilled),
37
+ [safeTotal, safeFilled],
38
+ );
39
+
40
+ return (
41
+ <View
42
+ style={[
43
+ {
44
+ flexDirection: 'row',
45
+ flexWrap: 'wrap',
46
+ gap,
47
+ width: rowWidth * dotSize + Math.max(0, rowWidth - 1) * gap,
48
+ },
49
+ style,
50
+ ]}
51
+ accessibilityRole="progressbar"
52
+ accessibilityValue={{ min: 0, max: safeTotal, now: safeFilled }}
53
+ testID={testID}
54
+ >
55
+ {dots.map((isFilled, index) => (
56
+ <View
57
+ key={index}
58
+ style={{
59
+ width: dotSize,
60
+ height: dotSize,
61
+ borderRadius: dotSize / 2,
62
+ backgroundColor: isFilled ? onColor : offColor,
63
+ }}
64
+ />
65
+ ))}
66
+ </View>
67
+ );
68
+ };
69
+
70
+ export const DotGridMeter = memo(DotGridMeterComponent);
71
+ DotGridMeter.displayName = 'DotGridMeter';
@@ -0,0 +1,2 @@
1
+ export { DotGridMeter } from './DotGridMeter';
2
+ export type { DotGridMeterProps } from './types';