@lotics/ui 2.4.1 → 2.5.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/package.json +27 -8
- package/src/accordion.tsx +146 -63
- package/src/action_menu.tsx +72 -0
- package/src/allocation_row.tsx +54 -0
- package/src/badge.tsx +40 -9
- package/src/breakdown.tsx +121 -0
- package/src/card.tsx +150 -0
- package/src/cell_select.tsx +3 -2
- package/src/chip_group.tsx +65 -0
- package/src/colors.ts +61 -0
- package/src/column_filter.tsx +9 -24
- package/src/completion_state.tsx +43 -0
- package/src/control_surface.ts +32 -0
- package/src/counter.tsx +58 -0
- package/src/date_range_filter_field.tsx +44 -12
- package/src/detail_row.tsx +45 -0
- package/src/dialog.tsx +0 -24
- package/src/download.ts +2 -1
- package/src/drawer.tsx +94 -2
- package/src/empty_state.tsx +37 -0
- package/src/file_badge.tsx +27 -4
- package/src/file_dropzone.tsx +188 -0
- package/src/file_picker.ts +45 -0
- package/src/filter_pill.tsx +106 -0
- package/src/floating_action_bar.tsx +57 -0
- package/src/fonts.css +10 -13
- package/src/format_money.ts +38 -0
- package/src/heatmap.tsx +153 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +16 -2
- package/src/index.css +4 -3
- package/src/info_popover.tsx +4 -6
- package/src/kpi_card.tsx +19 -6
- package/src/kpi_strip.tsx +89 -0
- package/src/line_chart.tsx +61 -34
- package/src/link_button.tsx +50 -0
- package/src/metric.tsx +21 -12
- package/src/pagination.tsx +5 -9
- package/src/peek.tsx +68 -0
- package/src/picker.tsx +13 -1
- package/src/picker_menu.tsx +8 -16
- package/src/pie_chart.tsx +29 -8
- package/src/pill_button.tsx +10 -8
- package/src/popover.tsx +14 -4
- package/src/pressable_highlight.tsx +10 -1
- package/src/pressable_row.tsx +91 -0
- package/src/progress_bar.tsx +47 -17
- package/src/radio_picker.tsx +20 -9
- package/src/range_slider.tsx +185 -0
- package/src/remainder_meter.tsx +48 -0
- package/src/ring_gauge.tsx +5 -5
- package/src/scan_field.tsx +58 -0
- package/src/search_input.tsx +12 -0
- package/src/sort_header.tsx +102 -0
- package/src/stacked_progress_bar.tsx +51 -16
- package/src/status_grid.tsx +187 -0
- package/src/step_list.tsx +128 -0
- package/src/step_progress.tsx +145 -0
- package/src/stepper.tsx +9 -4
- package/src/table.tsx +168 -112
- package/src/text.tsx +15 -0
- package/src/text_utils.ts +10 -0
- package/src/timeline.tsx +90 -57
- package/src/trend_footer.tsx +2 -2
- package/src/alert_row.tsx +0 -81
- package/src/table.web.tsx +0 -235
- package/src/table_picker.tsx +0 -305
- package/src/table_types.ts +0 -47
package/src/table.tsx
CHANGED
|
@@ -1,125 +1,181 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
Children,
|
|
5
|
+
cloneElement,
|
|
6
|
+
isValidElement,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type ReactElement,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { StyleSheet, View, Pressable, type ViewStyle } from "react-native";
|
|
11
|
+
import { Text } from "./text";
|
|
12
|
+
import { Divider } from "./divider";
|
|
13
|
+
import { PressableRow } from "./pressable_row";
|
|
14
|
+
import { SortHeader, type SortState } from "./sort_header";
|
|
7
15
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
/**
|
|
17
|
+
* One column of a register — its width/flex/align/label/sortability defined ONCE,
|
|
18
|
+
* here, instead of being re-typed in the header band AND every row.
|
|
19
|
+
*/
|
|
20
|
+
export interface TableColumn {
|
|
21
|
+
/** Stable id — also the `sortKey` when `sortable`. */
|
|
22
|
+
key: string;
|
|
23
|
+
/** Header label (uppercase eyebrow). Omit for a control column (a trailing ⋯). */
|
|
24
|
+
label?: string;
|
|
25
|
+
/** Fixed width in px; omit for a flexible column. */
|
|
26
|
+
width?: number;
|
|
27
|
+
/** Flex grow when no `width` (default 1). */
|
|
28
|
+
flex?: number;
|
|
29
|
+
align?: "left" | "right";
|
|
30
|
+
sortable?: boolean;
|
|
31
|
+
}
|
|
14
32
|
|
|
15
|
-
|
|
16
|
-
|
|
33
|
+
interface TableCtx {
|
|
34
|
+
columns: TableColumn[];
|
|
35
|
+
leading: number;
|
|
36
|
+
trailing: number;
|
|
37
|
+
}
|
|
38
|
+
const TableContext = createContext<TableCtx | null>(null);
|
|
17
39
|
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
40
|
+
function colStyle(col: TableColumn): ViewStyle {
|
|
41
|
+
const base: ViewStyle =
|
|
42
|
+
col.width != null ? { width: col.width } : { flex: col.flex ?? 1, minWidth: 0 };
|
|
43
|
+
if (col.align === "right") base.alignItems = "flex-end";
|
|
44
|
+
return base;
|
|
45
|
+
}
|
|
23
46
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
47
|
+
export interface TableProps {
|
|
48
|
+
/** The single source of column layout — shared by the header AND every cell. */
|
|
49
|
+
columns: TableColumn[];
|
|
50
|
+
sort?: SortState | null;
|
|
51
|
+
/** Cycles the sort (none→asc→desc→none) — pair with `cycleSort` in the parent. */
|
|
52
|
+
onSort?: (key: string) => void;
|
|
53
|
+
/** Reserve a leading gutter (px) for rows that render a `leading` slot (a checkbox). */
|
|
54
|
+
leading?: number;
|
|
55
|
+
/** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
|
|
56
|
+
trailing?: number;
|
|
57
|
+
/** The `TableRow`s. */
|
|
58
|
+
children: ReactNode;
|
|
59
|
+
}
|
|
35
60
|
|
|
36
|
-
|
|
61
|
+
/**
|
|
62
|
+
* THE columnar register — define `columns` once and the header band + every row's
|
|
63
|
+
* cell widths come from it, so they can't drift (no hand-rolled `W` map, no
|
|
64
|
+
* `<View style={{width}}>` per cell). Renders a full-bleed eyebrow header (a
|
|
65
|
+
* sortable column becomes a `SortHeader`) and its `TableRow` children,
|
|
66
|
+
* `Divider`-separated. Compose `TableRow` / `TableCell` for the body. For a
|
|
67
|
+
* non-columnar list (entity piles, card stacks) use `PressableRow` directly.
|
|
68
|
+
*/
|
|
69
|
+
export function Table(props: TableProps) {
|
|
70
|
+
const { columns, sort, onSort, leading = 0, trailing = 0, children } = props;
|
|
71
|
+
const rows = Children.toArray(children).filter(isValidElement);
|
|
37
72
|
|
|
38
73
|
return (
|
|
39
|
-
<
|
|
40
|
-
<View style={styles.
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
{
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
size={14}
|
|
54
|
-
color={active ? colors.zinc[700] : colors.zinc[400]}
|
|
55
|
-
/>
|
|
56
|
-
) : null}
|
|
57
|
-
</View>
|
|
58
|
-
);
|
|
59
|
-
return sortable ? (
|
|
60
|
-
<Pressable key={col.key as string} accessibilityRole="button" accessibilityLabel={`Sắp xếp theo ${col.label}`} onPress={() => onSortChange?.(col.key)} style={cellStyle}>
|
|
61
|
-
{inner}
|
|
62
|
-
</Pressable>
|
|
63
|
-
) : (
|
|
64
|
-
<View key={col.key as string} style={cellStyle}>
|
|
65
|
-
{inner}
|
|
66
|
-
</View>
|
|
67
|
-
);
|
|
68
|
-
})}
|
|
69
|
-
{expandable ? <View style={{ width: CHEVRON_W }} /> : null}
|
|
70
|
-
</View>
|
|
71
|
-
{rows.filter((r): r is TRow => Boolean(r)).map((row, i) => {
|
|
72
|
-
const key = rowKey ? String(row[rowKey]) : String(i);
|
|
73
|
-
const isOpen = expanded.has(key);
|
|
74
|
-
const extra = rowStyle?.(row);
|
|
75
|
-
const cells = columns.map((col) => (
|
|
76
|
-
<View key={col.key as string} style={[cellWidth(col), styles.bodyCell, col.align === "right" ? styles.alignEnd : null]}>
|
|
77
|
-
{col.renderCell ? col.renderCell({ row, column: col }) : <Text numberOfLines={1}>{row[col.key] != null ? String(row[col.key]) : ""}</Text>}
|
|
74
|
+
<TableContext.Provider value={{ columns, leading, trailing }}>
|
|
75
|
+
<View style={styles.headerBand}>
|
|
76
|
+
{leading > 0 ? <View style={{ width: leading }} /> : null}
|
|
77
|
+
{columns.map((col) => (
|
|
78
|
+
<View key={col.key} style={colStyle(col)}>
|
|
79
|
+
{col.label ? (
|
|
80
|
+
col.sortable && onSort ? (
|
|
81
|
+
<SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} />
|
|
82
|
+
) : (
|
|
83
|
+
<Text size="xs" color="muted" transform="uppercase" numberOfLines={1}>
|
|
84
|
+
{col.label}
|
|
85
|
+
</Text>
|
|
86
|
+
)
|
|
87
|
+
) : null}
|
|
78
88
|
</View>
|
|
79
|
-
))
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
{cells}
|
|
90
|
-
<View style={styles.chevron}>
|
|
91
|
-
<Icon name={isOpen ? "chevron-up" : "chevron-down"} size={18} color={colors.zinc[400]} />
|
|
92
|
-
</View>
|
|
93
|
-
</Pressable>
|
|
94
|
-
) : onRowPress ? (
|
|
95
|
-
<Pressable
|
|
96
|
-
accessibilityRole="button"
|
|
97
|
-
onPress={() => onRowPress(row)}
|
|
98
|
-
style={({ pressed }) => [styles.bodyRow, pressed ? styles.rowPressed : null, extra]}
|
|
99
|
-
>
|
|
100
|
-
{cells}
|
|
101
|
-
</Pressable>
|
|
102
|
-
) : (
|
|
103
|
-
<View style={[styles.bodyRow, extra]}>{cells}</View>
|
|
104
|
-
)}
|
|
105
|
-
{expandable && isOpen ? <View style={styles.detail}>{renderDetail!(row)}</View> : null}
|
|
106
|
-
</Fragment>
|
|
107
|
-
);
|
|
108
|
-
})}
|
|
109
|
-
</ScrollView>
|
|
89
|
+
))}
|
|
90
|
+
{trailing > 0 ? <View style={{ width: trailing }} /> : null}
|
|
91
|
+
</View>
|
|
92
|
+
{rows.map((row, i) => (
|
|
93
|
+
<View key={(row as ReactElement).key ?? i}>
|
|
94
|
+
<Divider />
|
|
95
|
+
{row}
|
|
96
|
+
</View>
|
|
97
|
+
))}
|
|
98
|
+
</TableContext.Provider>
|
|
110
99
|
);
|
|
111
100
|
}
|
|
112
101
|
|
|
102
|
+
export interface TableRowProps {
|
|
103
|
+
/** Opens the record (the drawer). The door + the full-row surface both call it. */
|
|
104
|
+
onPress?: () => void;
|
|
105
|
+
selected?: boolean;
|
|
106
|
+
/** Accessible name for the door (the keyboard-focusable open target). */
|
|
107
|
+
accessibilityLabel?: string;
|
|
108
|
+
/** Outside the door, BEFORE the cells (a selection checkbox) — width = Table `leading`. */
|
|
109
|
+
leading?: ReactNode;
|
|
110
|
+
/** Outside the door, AFTER the cells (a ⋯ menu / a button) — width = Table `trailing`. */
|
|
111
|
+
trailing?: ReactNode;
|
|
112
|
+
/** Min row height. Default 52. */
|
|
113
|
+
minHeight?: number;
|
|
114
|
+
/** The `TableCell`s, one per column, in column order. */
|
|
115
|
+
children: ReactNode;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A register row: a full-bleed `PressableRow` (its hover wash spans the whole row,
|
|
120
|
+
* incl. the leading/trailing controls) wrapping the door — an inner
|
|
121
|
+
* `role="button"` `Pressable` (the keyboard-accessible open) around the cells —
|
|
122
|
+
* with optional `leading`/`trailing` slots OUTSIDE the door so their own presses
|
|
123
|
+
* win. Cells map to `Table`'s columns by position.
|
|
124
|
+
*/
|
|
125
|
+
export function TableRow(props: TableRowProps) {
|
|
126
|
+
const { onPress, selected, accessibilityLabel, leading, trailing, minHeight = 52, children } = props;
|
|
127
|
+
const ctx = useContext(TableContext);
|
|
128
|
+
if (!ctx) throw new Error("TableRow must be used within a Table");
|
|
129
|
+
|
|
130
|
+
const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[]).map(
|
|
131
|
+
(cell, i) => cloneElement(cell, { _column: ctx.columns[i] }),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<PressableRow onPress={onPress ?? noop} selected={selected} style={styles.row}>
|
|
136
|
+
{ctx.leading > 0 ? <View style={{ width: ctx.leading }}>{leading}</View> : null}
|
|
137
|
+
<Pressable
|
|
138
|
+
accessibilityRole={onPress ? "button" : undefined}
|
|
139
|
+
accessibilityLabel={accessibilityLabel}
|
|
140
|
+
onPress={onPress}
|
|
141
|
+
style={[styles.door, { minHeight }]}
|
|
142
|
+
>
|
|
143
|
+
{cells}
|
|
144
|
+
</Pressable>
|
|
145
|
+
{ctx.trailing > 0 ? <View style={{ width: ctx.trailing, alignItems: "flex-end" }}>{trailing}</View> : null}
|
|
146
|
+
</PressableRow>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface TableCellProps {
|
|
151
|
+
children: ReactNode;
|
|
152
|
+
/** @internal — injected by `TableRow` from the column at this cell's position. */
|
|
153
|
+
_column?: TableColumn;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One cell — its width/align come from the column `TableRow` injects by position. */
|
|
157
|
+
export function TableCell(props: TableCellProps) {
|
|
158
|
+
const { children, _column } = props;
|
|
159
|
+
return <View style={_column ? colStyle(_column) : undefined}>{children}</View>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const noop = () => {};
|
|
163
|
+
|
|
113
164
|
const styles = StyleSheet.create({
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
165
|
+
headerBand: {
|
|
166
|
+
paddingHorizontal: 20,
|
|
167
|
+
paddingVertical: 10,
|
|
168
|
+
flexDirection: "row",
|
|
169
|
+
alignItems: "center",
|
|
170
|
+
gap: 14,
|
|
171
|
+
},
|
|
172
|
+
row: {
|
|
173
|
+
gap: 14,
|
|
174
|
+
},
|
|
175
|
+
door: {
|
|
176
|
+
flex: 1,
|
|
177
|
+
flexDirection: "row",
|
|
178
|
+
alignItems: "center",
|
|
179
|
+
gap: 14,
|
|
180
|
+
},
|
|
125
181
|
});
|
package/src/text.tsx
CHANGED
|
@@ -20,6 +20,12 @@ export interface TextProps {
|
|
|
20
20
|
userSelect?: TextUserSelect;
|
|
21
21
|
transform?: TextTransform;
|
|
22
22
|
decoration?: TextDecorationLine;
|
|
23
|
+
/**
|
|
24
|
+
* Fixed-width (tabular) numerals. Set on every free-standing numeral —
|
|
25
|
+
* table cells, counts, money, dates — so digits align across rows and
|
|
26
|
+
* columns instead of jittering on proportional glyphs.
|
|
27
|
+
*/
|
|
28
|
+
tabular?: boolean;
|
|
23
29
|
style?: RNTextProps["style"];
|
|
24
30
|
onPress?: () => void;
|
|
25
31
|
/** ID used to target this element from `accessibilityLabelledBy` / `aria-describedby`. */
|
|
@@ -62,6 +68,7 @@ export function Text(props: TextProps) {
|
|
|
62
68
|
weight = "regular",
|
|
63
69
|
numberOfLines,
|
|
64
70
|
decoration,
|
|
71
|
+
tabular,
|
|
65
72
|
transform,
|
|
66
73
|
onPress,
|
|
67
74
|
style,
|
|
@@ -99,6 +106,7 @@ export function Text(props: TextProps) {
|
|
|
99
106
|
styles[userSelect],
|
|
100
107
|
styles[align],
|
|
101
108
|
decoration && styles[decoration],
|
|
109
|
+
tabular && styles.tabular,
|
|
102
110
|
transform && styles[transform],
|
|
103
111
|
style,
|
|
104
112
|
]}
|
|
@@ -186,9 +194,16 @@ const styles = StyleSheet.create({
|
|
|
186
194
|
userSelect: "none",
|
|
187
195
|
},
|
|
188
196
|
|
|
197
|
+
tabular: {
|
|
198
|
+
fontVariant: ["tabular-nums"],
|
|
199
|
+
},
|
|
200
|
+
|
|
189
201
|
// Text transform styles
|
|
190
202
|
uppercase: {
|
|
191
203
|
textTransform: "uppercase",
|
|
204
|
+
// Uppercase needs positive tracking to read as designed, not shouted —
|
|
205
|
+
// the standard micro-cap/eyebrow treatment (stat labels, column headers).
|
|
206
|
+
letterSpacing: 0.6,
|
|
192
207
|
},
|
|
193
208
|
lowercase: {
|
|
194
209
|
textTransform: "lowercase",
|
package/src/text_utils.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type TextColor =
|
|
|
6
6
|
| "muted"
|
|
7
7
|
| "inverted"
|
|
8
8
|
| "danger"
|
|
9
|
+
| "warning"
|
|
10
|
+
| "success"
|
|
9
11
|
| "zinc-900"
|
|
10
12
|
| "zinc-700"
|
|
11
13
|
| "zinc-500";
|
|
@@ -24,8 +26,16 @@ export function getTextColor(color?: TextColor): string {
|
|
|
24
26
|
return colors.zinc["500"];
|
|
25
27
|
case "inverted":
|
|
26
28
|
return colors.white;
|
|
29
|
+
// Valence set — the lightest status weight: a state WORD whose meaning is in
|
|
30
|
+
// the word, colored for reinforcement (never color-only). Red sits darker at
|
|
31
|
+
// 900; green/amber use 700 to stay recognizably their hue while clearing AA on
|
|
32
|
+
// white (600 would fail).
|
|
27
33
|
case "danger":
|
|
28
34
|
return colors.red["900"];
|
|
35
|
+
case "warning":
|
|
36
|
+
return colors.amber["700"];
|
|
37
|
+
case "success":
|
|
38
|
+
return colors.emerald["700"];
|
|
29
39
|
default:
|
|
30
40
|
return colors.zinc[900];
|
|
31
41
|
}
|
package/src/timeline.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { ReactNode, useCallback, useState } from "react";
|
|
|
2
2
|
import { StyleSheet, View } from "react-native";
|
|
3
3
|
import { ActivityIndicator } from "./activity_indicator";
|
|
4
4
|
import { AnimationFadeIn } from "./animation_fade_in";
|
|
5
|
-
import { colors } from "./colors";
|
|
5
|
+
import { colors, withAlpha } from "./colors";
|
|
6
6
|
import { Icon, type IconName } from "./icon";
|
|
7
7
|
import { PressableHighlight } from "./pressable_highlight";
|
|
8
8
|
import { Text } from "./text";
|
|
@@ -10,9 +10,14 @@ import { Text } from "./text";
|
|
|
10
10
|
export interface TimelineItem {
|
|
11
11
|
id: string;
|
|
12
12
|
icon: IconName;
|
|
13
|
+
/** Accent for the node — a palette hex (e.g. colors.blue[600]). The node
|
|
14
|
+
* renders a tinted disc of this color; the icon takes the full color. */
|
|
13
15
|
iconColor: string;
|
|
14
16
|
isLoading?: boolean;
|
|
15
17
|
label: string;
|
|
18
|
+
/** An always-visible sub-line under the label (a note, a detail). When set, the
|
|
19
|
+
* row top-aligns so `right` sits next to the label, not centred on the block. */
|
|
20
|
+
description?: string;
|
|
16
21
|
error?: string;
|
|
17
22
|
right?: ReactNode;
|
|
18
23
|
details?: ReactNode;
|
|
@@ -22,6 +27,12 @@ interface TimelineProps {
|
|
|
22
27
|
items: TimelineItem[];
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Vertical event log — tinted icon nodes on a hairline spine, one row per
|
|
32
|
+
* event. Rows are plain (no boxes — the spine provides the structure);
|
|
33
|
+
* a row becomes pressable only when it carries `details`, revealing them
|
|
34
|
+
* inline. For horizontal milestone progress use `Stepper`.
|
|
35
|
+
*/
|
|
25
36
|
export function Timeline(props: TimelineProps) {
|
|
26
37
|
const { items } = props;
|
|
27
38
|
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>({});
|
|
@@ -31,52 +42,56 @@ export function Timeline(props: TimelineProps) {
|
|
|
31
42
|
}, []);
|
|
32
43
|
|
|
33
44
|
return (
|
|
34
|
-
<View
|
|
45
|
+
<View>
|
|
35
46
|
{items.map((item, index) => {
|
|
36
47
|
const expanded = expandedIds[item.id] ?? false;
|
|
37
48
|
const hasDetails = !!item.details;
|
|
38
49
|
|
|
50
|
+
const row = (
|
|
51
|
+
<View style={[styles.row, item.description ? styles.rowTop : null]}>
|
|
52
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
53
|
+
<Text size="sm">{item.label}</Text>
|
|
54
|
+
{item.description ? (
|
|
55
|
+
<Text size="xs" color="muted">{item.description}</Text>
|
|
56
|
+
) : null}
|
|
57
|
+
{item.error ? (
|
|
58
|
+
<Text size="xs" color="danger" numberOfLines={1}>
|
|
59
|
+
{item.error}
|
|
60
|
+
</Text>
|
|
61
|
+
) : null}
|
|
62
|
+
</View>
|
|
63
|
+
{item.right}
|
|
64
|
+
{hasDetails ? (
|
|
65
|
+
<Icon name={expanded ? "chevron-up" : "chevron-down"} size={14} color={colors.zinc[400]} />
|
|
66
|
+
) : null}
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
|
|
39
70
|
return (
|
|
40
71
|
<View key={item.id} style={styles.itemContainer}>
|
|
41
|
-
<View style={styles.
|
|
72
|
+
<View style={styles.spineColumn}>
|
|
42
73
|
<AnimationFadeIn key={`${item.id}-${item.icon}`}>
|
|
43
|
-
|
|
74
|
+
{/* Tinted disc: a low-alpha wash of the accent behind a full-color icon. */}
|
|
75
|
+
<View style={[styles.node, { backgroundColor: withAlpha(item.iconColor, 0.1) }]}>
|
|
44
76
|
{item.isLoading ? (
|
|
45
|
-
<ActivityIndicator size={
|
|
77
|
+
<ActivityIndicator size={13} color={item.iconColor} />
|
|
46
78
|
) : (
|
|
47
|
-
<Icon name={item.icon} size={
|
|
79
|
+
<Icon name={item.icon} size={13} color={item.iconColor} />
|
|
48
80
|
)}
|
|
49
81
|
</View>
|
|
50
82
|
</AnimationFadeIn>
|
|
51
|
-
{index < items.length - 1 && <View style={styles.
|
|
83
|
+
{index < items.length - 1 && <View style={styles.spine} />}
|
|
52
84
|
</View>
|
|
53
85
|
|
|
54
86
|
<View style={styles.contentColumn}>
|
|
55
|
-
|
|
56
|
-
onPress={
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
{item.error && (
|
|
62
|
-
<Text size="xs" color="danger" numberOfLines={1}>
|
|
63
|
-
{item.error}
|
|
64
|
-
</Text>
|
|
65
|
-
)}
|
|
66
|
-
</View>
|
|
67
|
-
{item.right}
|
|
68
|
-
{hasDetails && (
|
|
69
|
-
<Icon
|
|
70
|
-
name={expanded ? "chevron-up" : "chevron-down"}
|
|
71
|
-
size={14}
|
|
72
|
-
color={colors.zinc[500]}
|
|
73
|
-
/>
|
|
74
|
-
)}
|
|
75
|
-
</PressableHighlight>
|
|
76
|
-
|
|
77
|
-
{expanded && item.details && (
|
|
78
|
-
<View style={styles.detailsContainer}>{item.details}</View>
|
|
87
|
+
{hasDetails ? (
|
|
88
|
+
<PressableHighlight onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
|
|
89
|
+
{row}
|
|
90
|
+
</PressableHighlight>
|
|
91
|
+
) : (
|
|
92
|
+
<View style={styles.plainRow}>{row}</View>
|
|
79
93
|
)}
|
|
94
|
+
{expanded && item.details ? <View style={styles.detailsContainer}>{item.details}</View> : null}
|
|
80
95
|
</View>
|
|
81
96
|
</View>
|
|
82
97
|
);
|
|
@@ -86,50 +101,68 @@ export function Timeline(props: TimelineProps) {
|
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
const styles = StyleSheet.create({
|
|
89
|
-
container: {
|
|
90
|
-
gap: 0,
|
|
91
|
-
},
|
|
92
104
|
itemContainer: {
|
|
93
105
|
flexDirection: "row",
|
|
94
|
-
gap:
|
|
106
|
+
gap: 12,
|
|
95
107
|
},
|
|
96
|
-
|
|
97
|
-
width:
|
|
108
|
+
spineColumn: {
|
|
109
|
+
width: 24,
|
|
98
110
|
alignItems: "center",
|
|
111
|
+
// Align the node with the FIRST line of content (not the centre of a
|
|
112
|
+
// multi-line block), so the spine reads as connected.
|
|
113
|
+
paddingTop: 2,
|
|
99
114
|
},
|
|
100
|
-
|
|
101
|
-
width:
|
|
102
|
-
height:
|
|
103
|
-
borderRadius:
|
|
104
|
-
borderWidth: 1,
|
|
115
|
+
node: {
|
|
116
|
+
width: 24,
|
|
117
|
+
height: 24,
|
|
118
|
+
borderRadius: 12,
|
|
105
119
|
justifyContent: "center",
|
|
106
120
|
alignItems: "center",
|
|
107
|
-
backgroundColor: colors.white,
|
|
108
121
|
},
|
|
109
|
-
|
|
110
|
-
width: 1,
|
|
122
|
+
spine: {
|
|
123
|
+
width: 1.5,
|
|
111
124
|
flex: 1,
|
|
112
|
-
minHeight:
|
|
113
|
-
|
|
114
|
-
|
|
125
|
+
minHeight: 12,
|
|
126
|
+
borderRadius: 1,
|
|
127
|
+
backgroundColor: colors.zinc[200],
|
|
128
|
+
marginVertical: 3,
|
|
115
129
|
},
|
|
116
130
|
contentColumn: {
|
|
117
131
|
flex: 1,
|
|
118
|
-
|
|
132
|
+
paddingBottom: 14,
|
|
119
133
|
},
|
|
120
|
-
|
|
134
|
+
row: {
|
|
121
135
|
flexDirection: "row",
|
|
122
136
|
alignItems: "center",
|
|
123
|
-
gap:
|
|
124
|
-
|
|
125
|
-
|
|
137
|
+
gap: 10,
|
|
138
|
+
flex: 1,
|
|
139
|
+
},
|
|
140
|
+
// With a description the row is taller than one line — top-align so the
|
|
141
|
+
// timestamp (`right`) sits beside the label instead of centred on the block.
|
|
142
|
+
rowTop: {
|
|
143
|
+
alignItems: "flex-start",
|
|
144
|
+
},
|
|
145
|
+
// The press target for expandable rows; pressable rows bleed the hover wash
|
|
146
|
+
// past the text column. paddingVertical pairs with the spineColumn paddingTop
|
|
147
|
+
// so the node lines up with the first line.
|
|
148
|
+
pressableRow: {
|
|
126
149
|
borderRadius: 8,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
150
|
+
paddingHorizontal: 8,
|
|
151
|
+
paddingVertical: 4,
|
|
152
|
+
marginHorizontal: -8,
|
|
153
|
+
minHeight: 28,
|
|
154
|
+
flexDirection: "row",
|
|
155
|
+
alignItems: "center",
|
|
156
|
+
},
|
|
157
|
+
plainRow: {
|
|
158
|
+
paddingVertical: 4,
|
|
159
|
+
minHeight: 28,
|
|
160
|
+
flexDirection: "row",
|
|
161
|
+
alignItems: "center",
|
|
130
162
|
},
|
|
131
163
|
detailsContainer: {
|
|
132
164
|
gap: 8,
|
|
133
|
-
|
|
165
|
+
paddingTop: 8,
|
|
166
|
+
paddingLeft: 2,
|
|
134
167
|
},
|
|
135
168
|
});
|
package/src/trend_footer.tsx
CHANGED
|
@@ -20,7 +20,7 @@ interface TrendFooterProps {
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Footer caption that pairs the shadcn TrendingUp icon with a directional
|
|
23
|
-
*
|
|
23
|
+
* sentence — the "Up X% vs last month" pattern at the
|
|
24
24
|
* bottom of every chart card on Stripe, Mercury, Linear.
|
|
25
25
|
*
|
|
26
26
|
* Goes inside `<SectionCard footer={...} />`. The Card adds the hairline
|
|
@@ -38,7 +38,7 @@ export function TrendFooter(props: TrendFooterProps) {
|
|
|
38
38
|
<View style={styles.row}>
|
|
39
39
|
<Icon size={16} color={color} />
|
|
40
40
|
<Text size="sm" weight="medium" style={{ color }}>
|
|
41
|
-
{up ? "
|
|
41
|
+
{up ? "Up" : "Down"} {Math.abs(value)}% {periodLabel}
|
|
42
42
|
</Text>
|
|
43
43
|
</View>
|
|
44
44
|
{detail && (
|