@oxyhq/bloom 0.29.6 → 0.30.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 (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 +42 -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 +301 -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 +210 -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 +42 -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 +296 -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 +205 -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 +15 -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 +15 -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 +39 -18
  165. package/src/avatar-group/types.ts +15 -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 +303 -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 +156 -0
  182. package/src/stat-bar/index.ts +7 -0
  183. package/src/stat-bar/types.ts +65 -0
@@ -41,11 +41,13 @@ 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,
50
52
  onPressItem,
51
53
  style,
@@ -53,6 +55,7 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
53
55
  hoverHandlers,
54
56
  }) => {
55
57
  const theme = useTheme();
58
+ const isRow = layout === 'row';
56
59
 
57
60
  // The ring is the thin separator drawn between overlapping avatars. It
58
61
  // defaults to the page background so avatars read as cleanly punched out of
@@ -65,6 +68,10 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
65
68
  // 1px border on each side, so the negative margin absorbs both borders to
66
69
  // keep the visual overlap equal to `effectiveOverlap`.
67
70
  const negativeMargin = -(effectiveOverlap + RING_WIDTH * 2);
71
+ // Row layout: a positive gap between adjacent avatars (no overlap, no ring).
72
+ const rowGap = spacing ?? Math.round(size * DEFAULT_OVERLAP_RATIO);
73
+ // Horizontal offset applied to every avatar after the first.
74
+ const itemMargin = isRow ? rowGap : negativeMargin;
68
75
 
69
76
  const shown = useMemo(
70
77
  () => items.slice(0, Math.max(0, max)),
@@ -74,11 +81,20 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
74
81
  const realTotal = total ?? items.length;
75
82
  const overflow = Math.max(0, realTotal - shown.length);
76
83
 
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;
84
+ // Stack: each cell is a circular, clipped container with a 1px ring border,
85
+ // and the inner Avatar is inset by the border on both sides. Row: no ring, so
86
+ // the Avatar fills the full cell.
87
+ const innerSize = isRow ? size : size - RING_WIDTH * 2;
81
88
  const cellStyle = useMemo((): ViewStyle => {
89
+ if (isRow) {
90
+ return {
91
+ width: size,
92
+ height: size,
93
+ borderRadius: size / 2,
94
+ alignItems: 'center',
95
+ justifyContent: 'center',
96
+ };
97
+ }
82
98
  return {
83
99
  width: size,
84
100
  height: size,
@@ -89,7 +105,7 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
89
105
  alignItems: 'center',
90
106
  justifyContent: 'center',
91
107
  };
92
- }, [size, ring]);
108
+ }, [isRow, size, ring]);
93
109
 
94
110
  const overflowTextStyle = useMemo(
95
111
  () => ({
@@ -121,12 +137,14 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
121
137
  ? `${name ?? item.username} (@${item.username})`
122
138
  : name;
123
139
 
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.
140
+ // The horizontal margin and stacking order live on the outermost row
141
+ // element so layout and hit-testing stay aligned with the visual
142
+ // overlap. In stack mode earlier siblings render on top of later ones
143
+ // (descending zIndex); in row mode there is no overlap so stacking
144
+ // order is irrelevant.
127
145
  const wrapperStyle: ViewStyle = {
128
- ...(index > 0 && { marginLeft: negativeMargin }),
129
- zIndex: shown.length - index,
146
+ ...(index > 0 && { marginLeft: itemMargin }),
147
+ ...(isRow ? {} : { zIndex: shown.length - index }),
130
148
  };
131
149
 
132
150
  const cell = (
@@ -190,7 +208,8 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
190
208
  <OverflowCircle
191
209
  count={overflow}
192
210
  cellStyle={cellStyle}
193
- negativeMargin={shown.length > 0 ? negativeMargin : 0}
211
+ marginLeft={shown.length > 0 ? itemMargin : 0}
212
+ isRow={isRow}
194
213
  textStyle={overflowTextStyle}
195
214
  onPress={overflowOnPress}
196
215
  />
@@ -202,23 +221,25 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
202
221
  function OverflowCircle({
203
222
  count,
204
223
  cellStyle,
205
- negativeMargin,
224
+ marginLeft,
225
+ isRow,
206
226
  textStyle,
207
227
  onPress,
208
228
  }: {
209
229
  count: number;
210
230
  cellStyle: ViewStyle;
211
- negativeMargin: number;
231
+ marginLeft: number;
232
+ isRow: boolean;
212
233
  textStyle: { color: string; fontSize: number; fontWeight: '600' };
213
234
  onPress?: () => void;
214
235
  }) {
215
236
  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.
237
+ // In stack mode the count circle is anchored at the lowest stacking order so
238
+ // it tucks behind the last avatar's ring; row mode has no overlap. The margin
239
+ // lives on the outer element to keep layout aligned with the spacing.
219
240
  const wrapperStyle: ViewStyle = {
220
- ...(negativeMargin !== 0 && { marginLeft: negativeMargin }),
221
- zIndex: 0,
241
+ ...(marginLeft !== 0 && { marginLeft }),
242
+ ...(isRow ? {} : { zIndex: 0 }),
222
243
  };
223
244
 
224
245
  // 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,9 +56,14 @@ 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
@@ -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';
@@ -0,0 +1,36 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+
3
+ export interface DotGridMeterProps {
4
+ /** Number of filled dots (clamped to `[0, total]`). */
5
+ filled: number;
6
+ /** Total number of dots in the grid. */
7
+ total: number;
8
+ /**
9
+ * Dots per row before wrapping.
10
+ * @default 10
11
+ */
12
+ columns?: number;
13
+ /**
14
+ * Diameter of each dot in px.
15
+ * @default 10
16
+ */
17
+ dotSize?: number;
18
+ /**
19
+ * Gap between dots in px (both axes).
20
+ * @default 6
21
+ */
22
+ gap?: number;
23
+ /**
24
+ * Color of a filled dot.
25
+ * @default theme.colors.success
26
+ */
27
+ filledColor?: string;
28
+ /**
29
+ * Color of an empty dot.
30
+ * @default theme.colors.backgroundSecondary
31
+ */
32
+ emptyColor?: string;
33
+ /** Container style override. */
34
+ style?: StyleProp<ViewStyle>;
35
+ testID?: string;
36
+ }
package/src/index.ts CHANGED
@@ -152,6 +152,30 @@ export * from './accordion';
152
152
  export { LinkPreviewCard } from './link-preview';
153
153
  export type { LinkPreviewCardProps } from './link-preview';
154
154
 
155
+ // Stat / profile widgets
156
+ export { CompositionBar } from './composition-bar';
157
+ export type { CompositionBarProps, CompositionCategory } from './composition-bar';
158
+ export { DotGridMeter } from './dot-grid-meter';
159
+ export type { DotGridMeterProps } from './dot-grid-meter';
160
+ export { StatBar } from './stat-bar';
161
+ export type {
162
+ StatBarProps,
163
+ StatBarProgressProps,
164
+ StatBarSplitProps,
165
+ StatBarVariant,
166
+ } from './stat-bar';
167
+ export { ActivityHeatmap, bucketByDay } from './activity-heatmap';
168
+ export type { ActivityHeatmapProps, ActivityHeatmapDay } from './activity-heatmap';
169
+ export { ProfileCard } from './profile-card';
170
+ export type {
171
+ ProfileCardProps,
172
+ ProfileCardLayout,
173
+ ProfileCardVariant,
174
+ ProfileCardAvatar,
175
+ ProfileCardMetric,
176
+ ProfileCardFooter,
177
+ } from './profile-card';
178
+
155
179
  // Settings / Grouped list
156
180
  export * from './settings-list';
157
181
  export { Item } from './item';
package/src/index.web.ts CHANGED
@@ -157,6 +157,30 @@ export * from './accordion';
157
157
  export { LinkPreviewCard } from './link-preview';
158
158
  export type { LinkPreviewCardProps } from './link-preview';
159
159
 
160
+ // Stat / profile widgets
161
+ export { CompositionBar } from './composition-bar';
162
+ export type { CompositionBarProps, CompositionCategory } from './composition-bar';
163
+ export { DotGridMeter } from './dot-grid-meter';
164
+ export type { DotGridMeterProps } from './dot-grid-meter';
165
+ export { StatBar } from './stat-bar';
166
+ export type {
167
+ StatBarProps,
168
+ StatBarProgressProps,
169
+ StatBarSplitProps,
170
+ StatBarVariant,
171
+ } from './stat-bar';
172
+ export { ActivityHeatmap, bucketByDay } from './activity-heatmap';
173
+ export type { ActivityHeatmapProps, ActivityHeatmapDay } from './activity-heatmap';
174
+ export { ProfileCard } from './profile-card';
175
+ export type {
176
+ ProfileCardProps,
177
+ ProfileCardLayout,
178
+ ProfileCardVariant,
179
+ ProfileCardAvatar,
180
+ ProfileCardMetric,
181
+ ProfileCardFooter,
182
+ } from './profile-card';
183
+
160
184
  // Settings / Grouped list
161
185
  export * from './settings-list';
162
186
  export { Item } from './item';