@oxyhq/bloom 0.29.5 → 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.
- package/lib/commonjs/activity-heatmap/ActivityHeatmap.js +304 -0
- package/lib/commonjs/activity-heatmap/ActivityHeatmap.js.map +1 -0
- package/lib/commonjs/activity-heatmap/bucketByDay.js +51 -0
- package/lib/commonjs/activity-heatmap/bucketByDay.js.map +1 -0
- package/lib/commonjs/activity-heatmap/index.js +20 -0
- package/lib/commonjs/activity-heatmap/index.js.map +1 -0
- package/lib/commonjs/activity-heatmap/types.js +6 -0
- package/lib/commonjs/activity-heatmap/types.js.map +1 -0
- package/lib/commonjs/avatar-group/AvatarGroupBase.js +42 -18
- package/lib/commonjs/avatar-group/AvatarGroupBase.js.map +1 -1
- package/lib/commonjs/composition-bar/CompositionBar.js +159 -0
- package/lib/commonjs/composition-bar/CompositionBar.js.map +1 -0
- package/lib/commonjs/composition-bar/index.js +13 -0
- package/lib/commonjs/composition-bar/index.js.map +1 -0
- package/lib/commonjs/composition-bar/types.js +6 -0
- package/lib/commonjs/composition-bar/types.js.map +1 -0
- package/lib/commonjs/dot-grid-meter/DotGridMeter.js +69 -0
- package/lib/commonjs/dot-grid-meter/DotGridMeter.js.map +1 -0
- package/lib/commonjs/dot-grid-meter/index.js +13 -0
- package/lib/commonjs/dot-grid-meter/index.js.map +1 -0
- package/lib/commonjs/dot-grid-meter/types.js +6 -0
- package/lib/commonjs/dot-grid-meter/types.js.map +1 -0
- package/lib/commonjs/index.js +59 -12
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +59 -12
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/profile-card/ProfileCard.js +301 -0
- package/lib/commonjs/profile-card/ProfileCard.js.map +1 -0
- package/lib/commonjs/profile-card/index.js +13 -0
- package/lib/commonjs/profile-card/index.js.map +1 -0
- package/lib/commonjs/profile-card/types.js +6 -0
- package/lib/commonjs/profile-card/types.js.map +1 -0
- package/lib/commonjs/stat-bar/StatBar.js +210 -0
- package/lib/commonjs/stat-bar/StatBar.js.map +1 -0
- package/lib/commonjs/stat-bar/index.js +13 -0
- package/lib/commonjs/stat-bar/index.js.map +1 -0
- package/lib/commonjs/stat-bar/types.js +6 -0
- package/lib/commonjs/stat-bar/types.js.map +1 -0
- package/lib/commonjs/typography/index.js +4 -5
- package/lib/commonjs/typography/index.js.map +1 -1
- package/lib/commonjs/typography/styled-text.js +12 -19
- package/lib/commonjs/typography/styled-text.js.map +1 -1
- package/lib/module/activity-heatmap/ActivityHeatmap.js +299 -0
- package/lib/module/activity-heatmap/ActivityHeatmap.js.map +1 -0
- package/lib/module/activity-heatmap/bucketByDay.js +47 -0
- package/lib/module/activity-heatmap/bucketByDay.js.map +1 -0
- package/lib/module/activity-heatmap/index.js +5 -0
- package/lib/module/activity-heatmap/index.js.map +1 -0
- package/lib/module/activity-heatmap/types.js +4 -0
- package/lib/module/activity-heatmap/types.js.map +1 -0
- package/lib/module/avatar-group/AvatarGroupBase.js +42 -18
- package/lib/module/avatar-group/AvatarGroupBase.js.map +1 -1
- package/lib/module/composition-bar/CompositionBar.js +154 -0
- package/lib/module/composition-bar/CompositionBar.js.map +1 -0
- package/lib/module/composition-bar/index.js +4 -0
- package/lib/module/composition-bar/index.js.map +1 -0
- package/lib/module/composition-bar/types.js +4 -0
- package/lib/module/composition-bar/types.js.map +1 -0
- package/lib/module/dot-grid-meter/DotGridMeter.js +64 -0
- package/lib/module/dot-grid-meter/DotGridMeter.js.map +1 -0
- package/lib/module/dot-grid-meter/index.js +4 -0
- package/lib/module/dot-grid-meter/index.js.map +1 -0
- package/lib/module/dot-grid-meter/types.js +4 -0
- package/lib/module/dot-grid-meter/types.js.map +1 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +6 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/profile-card/ProfileCard.js +296 -0
- package/lib/module/profile-card/ProfileCard.js.map +1 -0
- package/lib/module/profile-card/index.js +4 -0
- package/lib/module/profile-card/index.js.map +1 -0
- package/lib/module/profile-card/types.js +4 -0
- package/lib/module/profile-card/types.js.map +1 -0
- package/lib/module/stat-bar/StatBar.js +205 -0
- package/lib/module/stat-bar/StatBar.js.map +1 -0
- package/lib/module/stat-bar/index.js +4 -0
- package/lib/module/stat-bar/index.js.map +1 -0
- package/lib/module/stat-bar/types.js +4 -0
- package/lib/module/stat-bar/types.js.map +1 -0
- package/lib/module/typography/index.js +5 -6
- package/lib/module/typography/index.js.map +1 -1
- package/lib/module/typography/styled-text.js +12 -19
- package/lib/module/typography/styled-text.js.map +1 -1
- package/lib/typescript/commonjs/activity-heatmap/ActivityHeatmap.d.ts +4 -0
- package/lib/typescript/commonjs/activity-heatmap/ActivityHeatmap.d.ts.map +1 -0
- package/lib/typescript/commonjs/activity-heatmap/bucketByDay.d.ts +9 -0
- package/lib/typescript/commonjs/activity-heatmap/bucketByDay.d.ts.map +1 -0
- package/lib/typescript/commonjs/activity-heatmap/index.d.ts +4 -0
- package/lib/typescript/commonjs/activity-heatmap/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/activity-heatmap/types.d.ts +73 -0
- package/lib/typescript/commonjs/activity-heatmap/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar-group/types.d.ts +15 -1
- package/lib/typescript/commonjs/avatar-group/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/composition-bar/CompositionBar.d.ts +4 -0
- package/lib/typescript/commonjs/composition-bar/CompositionBar.d.ts.map +1 -0
- package/lib/typescript/commonjs/composition-bar/index.d.ts +3 -0
- package/lib/typescript/commonjs/composition-bar/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/composition-bar/types.d.ts +29 -0
- package/lib/typescript/commonjs/composition-bar/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/dot-grid-meter/DotGridMeter.d.ts +4 -0
- package/lib/typescript/commonjs/dot-grid-meter/DotGridMeter.d.ts.map +1 -0
- package/lib/typescript/commonjs/dot-grid-meter/index.d.ts +3 -0
- package/lib/typescript/commonjs/dot-grid-meter/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/dot-grid-meter/types.d.ts +36 -0
- package/lib/typescript/commonjs/dot-grid-meter/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +10 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +10 -0
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/profile-card/ProfileCard.d.ts +4 -0
- package/lib/typescript/commonjs/profile-card/ProfileCard.d.ts.map +1 -0
- package/lib/typescript/commonjs/profile-card/index.d.ts +3 -0
- package/lib/typescript/commonjs/profile-card/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/profile-card/types.d.ts +86 -0
- package/lib/typescript/commonjs/profile-card/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/stat-bar/StatBar.d.ts +4 -0
- package/lib/typescript/commonjs/stat-bar/StatBar.d.ts.map +1 -0
- package/lib/typescript/commonjs/stat-bar/index.d.ts +3 -0
- package/lib/typescript/commonjs/stat-bar/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/stat-bar/types.d.ts +62 -0
- package/lib/typescript/commonjs/stat-bar/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/typography/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/typography/styled-text.d.ts +1 -6
- package/lib/typescript/commonjs/typography/styled-text.d.ts.map +1 -1
- package/lib/typescript/module/activity-heatmap/ActivityHeatmap.d.ts +4 -0
- package/lib/typescript/module/activity-heatmap/ActivityHeatmap.d.ts.map +1 -0
- package/lib/typescript/module/activity-heatmap/bucketByDay.d.ts +9 -0
- package/lib/typescript/module/activity-heatmap/bucketByDay.d.ts.map +1 -0
- package/lib/typescript/module/activity-heatmap/index.d.ts +4 -0
- package/lib/typescript/module/activity-heatmap/index.d.ts.map +1 -0
- package/lib/typescript/module/activity-heatmap/types.d.ts +73 -0
- package/lib/typescript/module/activity-heatmap/types.d.ts.map +1 -0
- package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts.map +1 -1
- package/lib/typescript/module/avatar-group/types.d.ts +15 -1
- package/lib/typescript/module/avatar-group/types.d.ts.map +1 -1
- package/lib/typescript/module/composition-bar/CompositionBar.d.ts +4 -0
- package/lib/typescript/module/composition-bar/CompositionBar.d.ts.map +1 -0
- package/lib/typescript/module/composition-bar/index.d.ts +3 -0
- package/lib/typescript/module/composition-bar/index.d.ts.map +1 -0
- package/lib/typescript/module/composition-bar/types.d.ts +29 -0
- package/lib/typescript/module/composition-bar/types.d.ts.map +1 -0
- package/lib/typescript/module/dot-grid-meter/DotGridMeter.d.ts +4 -0
- package/lib/typescript/module/dot-grid-meter/DotGridMeter.d.ts.map +1 -0
- package/lib/typescript/module/dot-grid-meter/index.d.ts +3 -0
- package/lib/typescript/module/dot-grid-meter/index.d.ts.map +1 -0
- package/lib/typescript/module/dot-grid-meter/types.d.ts +36 -0
- package/lib/typescript/module/dot-grid-meter/types.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +10 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +10 -0
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/profile-card/ProfileCard.d.ts +4 -0
- package/lib/typescript/module/profile-card/ProfileCard.d.ts.map +1 -0
- package/lib/typescript/module/profile-card/index.d.ts +3 -0
- package/lib/typescript/module/profile-card/index.d.ts.map +1 -0
- package/lib/typescript/module/profile-card/types.d.ts +86 -0
- package/lib/typescript/module/profile-card/types.d.ts.map +1 -0
- package/lib/typescript/module/stat-bar/StatBar.d.ts +4 -0
- package/lib/typescript/module/stat-bar/StatBar.d.ts.map +1 -0
- package/lib/typescript/module/stat-bar/index.d.ts +3 -0
- package/lib/typescript/module/stat-bar/index.d.ts.map +1 -0
- package/lib/typescript/module/stat-bar/types.d.ts +62 -0
- package/lib/typescript/module/stat-bar/types.d.ts.map +1 -0
- package/lib/typescript/module/typography/index.d.ts.map +1 -1
- package/lib/typescript/module/typography/styled-text.d.ts +1 -6
- package/lib/typescript/module/typography/styled-text.d.ts.map +1 -1
- package/package.json +56 -1
- package/src/__tests__/StatWidgets.test.tsx +184 -0
- package/src/__tests__/public-api-contract.test.ts +15 -0
- package/src/activity-heatmap/ActivityHeatmap.stories.tsx +108 -0
- package/src/activity-heatmap/ActivityHeatmap.tsx +289 -0
- package/src/activity-heatmap/bucketByDay.ts +49 -0
- package/src/activity-heatmap/index.ts +3 -0
- package/src/activity-heatmap/types.ts +74 -0
- package/src/avatar-group/AvatarGroup.stories.tsx +14 -0
- package/src/avatar-group/AvatarGroupBase.tsx +39 -18
- package/src/avatar-group/types.ts +15 -1
- package/src/composition-bar/CompositionBar.stories.tsx +70 -0
- package/src/composition-bar/CompositionBar.tsx +161 -0
- package/src/composition-bar/index.ts +2 -0
- package/src/composition-bar/types.ts +30 -0
- package/src/dot-grid-meter/DotGridMeter.stories.tsx +47 -0
- package/src/dot-grid-meter/DotGridMeter.tsx +71 -0
- package/src/dot-grid-meter/index.ts +2 -0
- package/src/dot-grid-meter/types.ts +36 -0
- package/src/index.ts +24 -0
- package/src/index.web.ts +24 -0
- package/src/profile-card/ProfileCard.stories.tsx +210 -0
- package/src/profile-card/ProfileCard.tsx +303 -0
- package/src/profile-card/index.ts +9 -0
- package/src/profile-card/types.ts +86 -0
- package/src/stat-bar/StatBar.stories.tsx +61 -0
- package/src/stat-bar/StatBar.tsx +156 -0
- package/src/stat-bar/index.ts +7 -0
- package/src/stat-bar/types.ts +65 -0
- package/src/typography/index.tsx +4 -6
- package/src/typography/styled-text.ts +11 -28
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ActivityHeatmapDay } from './types';
|
|
2
|
+
|
|
3
|
+
const DATE_KEY_RE = /^(\d{4})-(\d{2})-(\d{2})/;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a date input to a `YYYY-MM-DD` key in UTC, or `null` if it can't be
|
|
7
|
+
* parsed. Strings already prefixed with an ISO date are sliced directly (no
|
|
8
|
+
* timezone shift); everything else is parsed and formatted in UTC.
|
|
9
|
+
*/
|
|
10
|
+
function toDateKey(value: string | Date): string | null {
|
|
11
|
+
if (value instanceof Date) {
|
|
12
|
+
const ts = value.getTime();
|
|
13
|
+
if (Number.isNaN(ts)) return null;
|
|
14
|
+
return formatUtcKey(ts);
|
|
15
|
+
}
|
|
16
|
+
if (DATE_KEY_RE.test(value)) return value.slice(0, 10);
|
|
17
|
+
const parsed = Date.parse(value);
|
|
18
|
+
if (Number.isNaN(parsed)) return null;
|
|
19
|
+
return formatUtcKey(parsed);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatUtcKey(ts: number): string {
|
|
23
|
+
const d = new Date(ts);
|
|
24
|
+
const year = d.getUTCFullYear();
|
|
25
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
26
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
27
|
+
return `${year}-${month}-${day}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Group arbitrary items by their `YYYY-MM-DD` day and count how many fall on
|
|
32
|
+
* each day. Returns entries sorted ascending by date — ready to hand to
|
|
33
|
+
* {@link ActivityHeatmap}'s `data` prop. Items whose date can't be parsed are
|
|
34
|
+
* skipped.
|
|
35
|
+
*/
|
|
36
|
+
export function bucketByDay<T>(
|
|
37
|
+
items: T[],
|
|
38
|
+
getDate: (item: T) => string | Date,
|
|
39
|
+
): ActivityHeatmapDay[] {
|
|
40
|
+
const counts = new Map<string, number>();
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
const key = toDateKey(getDate(item));
|
|
43
|
+
if (key === null) continue;
|
|
44
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
45
|
+
}
|
|
46
|
+
return [...counts.entries()]
|
|
47
|
+
.map(([date, count]) => ({ date, count }))
|
|
48
|
+
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
49
|
+
}
|
|
@@ -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,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
|
-
//
|
|
78
|
-
// Avatar is inset by the border on both sides
|
|
79
|
-
//
|
|
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
|
|
125
|
-
//
|
|
126
|
-
// overlap.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
224
|
+
marginLeft,
|
|
225
|
+
isRow,
|
|
206
226
|
textStyle,
|
|
207
227
|
onPress,
|
|
208
228
|
}: {
|
|
209
229
|
count: number;
|
|
210
230
|
cellStyle: ViewStyle;
|
|
211
|
-
|
|
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
|
-
//
|
|
217
|
-
// the last avatar's ring
|
|
218
|
-
// aligned with the
|
|
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
|
-
...(
|
|
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,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
|
+
};
|