@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.
Files changed (68) hide show
  1. package/package.json +27 -8
  2. package/src/accordion.tsx +146 -63
  3. package/src/action_menu.tsx +72 -0
  4. package/src/allocation_row.tsx +54 -0
  5. package/src/badge.tsx +40 -9
  6. package/src/breakdown.tsx +121 -0
  7. package/src/card.tsx +150 -0
  8. package/src/cell_select.tsx +3 -2
  9. package/src/chip_group.tsx +65 -0
  10. package/src/colors.ts +61 -0
  11. package/src/column_filter.tsx +9 -24
  12. package/src/completion_state.tsx +43 -0
  13. package/src/control_surface.ts +32 -0
  14. package/src/counter.tsx +58 -0
  15. package/src/date_range_filter_field.tsx +44 -12
  16. package/src/detail_row.tsx +45 -0
  17. package/src/dialog.tsx +0 -24
  18. package/src/download.ts +2 -1
  19. package/src/drawer.tsx +94 -2
  20. package/src/empty_state.tsx +37 -0
  21. package/src/file_badge.tsx +27 -4
  22. package/src/file_dropzone.tsx +188 -0
  23. package/src/file_picker.ts +45 -0
  24. package/src/filter_pill.tsx +106 -0
  25. package/src/floating_action_bar.tsx +57 -0
  26. package/src/fonts.css +10 -13
  27. package/src/format_money.ts +38 -0
  28. package/src/heatmap.tsx +153 -0
  29. package/src/icon.tsx +2 -0
  30. package/src/icon_button.tsx +16 -2
  31. package/src/index.css +4 -3
  32. package/src/info_popover.tsx +4 -6
  33. package/src/kpi_card.tsx +19 -6
  34. package/src/kpi_strip.tsx +89 -0
  35. package/src/line_chart.tsx +61 -34
  36. package/src/link_button.tsx +50 -0
  37. package/src/metric.tsx +21 -12
  38. package/src/pagination.tsx +5 -9
  39. package/src/peek.tsx +68 -0
  40. package/src/picker.tsx +13 -1
  41. package/src/picker_menu.tsx +8 -16
  42. package/src/pie_chart.tsx +29 -8
  43. package/src/pill_button.tsx +10 -8
  44. package/src/popover.tsx +14 -4
  45. package/src/pressable_highlight.tsx +10 -1
  46. package/src/pressable_row.tsx +91 -0
  47. package/src/progress_bar.tsx +47 -17
  48. package/src/radio_picker.tsx +20 -9
  49. package/src/range_slider.tsx +185 -0
  50. package/src/remainder_meter.tsx +48 -0
  51. package/src/ring_gauge.tsx +5 -5
  52. package/src/scan_field.tsx +58 -0
  53. package/src/search_input.tsx +12 -0
  54. package/src/sort_header.tsx +102 -0
  55. package/src/stacked_progress_bar.tsx +51 -16
  56. package/src/status_grid.tsx +187 -0
  57. package/src/step_list.tsx +128 -0
  58. package/src/step_progress.tsx +145 -0
  59. package/src/stepper.tsx +9 -4
  60. package/src/table.tsx +168 -112
  61. package/src/text.tsx +15 -0
  62. package/src/text_utils.ts +10 -0
  63. package/src/timeline.tsx +90 -57
  64. package/src/trend_footer.tsx +2 -2
  65. package/src/alert_row.tsx +0 -81
  66. package/src/table.web.tsx +0 -235
  67. package/src/table_picker.tsx +0 -305
  68. package/src/table_types.ts +0 -47
package/src/table.tsx CHANGED
@@ -1,125 +1,181 @@
1
- import { Fragment, useState } from "react";
2
- import { Pressable, ScrollView, View, StyleSheet } from "react-native";
3
- import { Text } from "@lotics/ui/text";
4
- import { Icon } from "@lotics/ui/icon";
5
- import { colors } from "@lotics/ui/colors";
6
- import type { Column, TableProps } from "./table_types";
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
- export type { SortDir, TableSort, Column, TableProps } from "./table_types";
9
-
10
- // Native table. RN primitives; a click-to-expand row is a Pressable whose
11
- // `interactive` cells are their own Pressables — RN's responder system grants the
12
- // press to the innermost responder, so an action cell never toggles the row (the
13
- // web DOM-nesting concern doesn't exist here). The detail panel renders below.
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
- const stickyHeader = [0];
16
- const CHEVRON_W = 44;
33
+ interface TableCtx {
34
+ columns: TableColumn[];
35
+ leading: number;
36
+ trailing: number;
37
+ }
38
+ const TableContext = createContext<TableCtx | null>(null);
17
39
 
18
- export function Table<TRow extends Record<string, unknown>>(props: TableProps<TRow>) {
19
- const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow, onRowPress } = props;
20
- const [internal, setInternal] = useState<Set<string>>(() => new Set());
21
- const expanded = expandedKeys ?? internal;
22
- const expandable = !!renderDetail;
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
- const toggle = (key: string, row: TRow) => {
25
- onToggleRow?.(key, row);
26
- if (expandedKeys === undefined) {
27
- setInternal((prev) => {
28
- const next = new Set(prev);
29
- if (next.has(key)) next.delete(key);
30
- else next.add(key);
31
- return next;
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
- const cellWidth = (col: Column<TRow>) => (col.width ? { width: col.width } : styles.flexColumn);
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
- <ScrollView style={styles.container} stickyHeaderIndices={stickyHeader}>
40
- <View style={styles.headerRow}>
41
- {columns.map((col) => {
42
- const sortable = col.sortable && !!onSortChange;
43
- const active = sort?.key === col.key;
44
- const cellStyle = [cellWidth(col), styles.headerCell, col.align === "right" ? styles.alignEnd : null];
45
- const inner = (
46
- <View style={styles.headerInner}>
47
- <Text size="xs" weight="medium" color="muted" transform="uppercase" numberOfLines={1} userSelect="none">
48
- {col.label}
49
- </Text>
50
- {sortable ? (
51
- <Icon
52
- name={active ? (sort?.dir === "asc" ? "chevron-up" : "chevron-down") : "chevrons-up-down"}
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
- return (
81
- <Fragment key={key}>
82
- {expandable ? (
83
- <Pressable
84
- accessibilityRole="button"
85
- accessibilityState={{ expanded: isOpen }}
86
- onPress={() => toggle(key, row)}
87
- style={({ pressed }) => [styles.bodyRow, pressed ? styles.rowPressed : null, extra]}
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
- container: { flex: 1, marginHorizontal: -8 },
115
- headerRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, backgroundColor: colors.white },
116
- headerCell: { minHeight: 40, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 10 },
117
- headerInner: { flexDirection: "row", alignItems: "center", gap: 4 },
118
- bodyRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, alignItems: "stretch", backgroundColor: colors.white },
119
- rowPressed: { backgroundColor: colors.zinc[50] },
120
- bodyCell: { minHeight: 44, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 12 },
121
- chevron: { width: CHEVRON_W, alignItems: "center", justifyContent: "center" },
122
- detail: { paddingHorizontal: 16, paddingTop: 4, paddingBottom: 18, backgroundColor: colors.zinc[50], borderBottomWidth: 1, borderBottomColor: colors.border },
123
- alignEnd: { alignItems: "flex-end" },
124
- flexColumn: { flex: 1 },
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 style={styles.container}>
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.timelineColumn}>
72
+ <View style={styles.spineColumn}>
42
73
  <AnimationFadeIn key={`${item.id}-${item.icon}`}>
43
- <View style={[styles.iconContainer, { borderColor: item.iconColor }]}>
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={12} color={item.iconColor} />
77
+ <ActivityIndicator size={13} color={item.iconColor} />
46
78
  ) : (
47
- <Icon name={item.icon} size={12} color={item.iconColor} />
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.line} />}
83
+ {index < items.length - 1 && <View style={styles.spine} />}
52
84
  </View>
53
85
 
54
86
  <View style={styles.contentColumn}>
55
- <PressableHighlight
56
- onPress={hasDetails ? () => toggleItem(item.id) : undefined}
57
- style={styles.itemHeader}
58
- >
59
- <View style={{ flex: 1 }}>
60
- <Text size="sm">{item.label}</Text>
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: 8,
106
+ gap: 12,
95
107
  },
96
- timelineColumn: {
97
- width: 16,
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
- iconContainer: {
101
- width: 16,
102
- height: 16,
103
- borderRadius: 8,
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
- line: {
110
- width: 1,
122
+ spine: {
123
+ width: 1.5,
111
124
  flex: 1,
112
- minHeight: 24,
113
- backgroundColor: colors.zinc[300],
114
- marginTop: 4,
125
+ minHeight: 12,
126
+ borderRadius: 1,
127
+ backgroundColor: colors.zinc[200],
128
+ marginVertical: 3,
115
129
  },
116
130
  contentColumn: {
117
131
  flex: 1,
118
- gap: 8,
132
+ paddingBottom: 14,
119
133
  },
120
- itemHeader: {
134
+ row: {
121
135
  flexDirection: "row",
122
136
  alignItems: "center",
123
- gap: 8,
124
- borderWidth: 1,
125
- borderColor: colors.zinc[200],
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
- backgroundColor: colors.white,
128
- paddingHorizontal: 10,
129
- paddingVertical: 8,
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
- paddingLeft: 4,
165
+ paddingTop: 8,
166
+ paddingLeft: 2,
134
167
  },
135
168
  });
@@ -20,7 +20,7 @@ interface TrendFooterProps {
20
20
 
21
21
  /**
22
22
  * Footer caption that pairs the shadcn TrendingUp icon with a directional
23
- * Vietnamese sentence — the "Tăng X% so với tháng trước" pattern at the
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 ? "Tăng" : "Giảm"} {Math.abs(value)}% {periodLabel}
41
+ {up ? "Up" : "Down"} {Math.abs(value)}% {periodLabel}
42
42
  </Text>
43
43
  </View>
44
44
  {detail && (