@lotics/ui 2.4.0 → 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/avatar.web.tsx +102 -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/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 && (
|
package/src/alert_row.tsx
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
2
|
-
import { View, StyleSheet } from "react-native";
|
|
3
|
-
import { Text } from "./text";
|
|
4
|
-
import { Metric, type MetricSize, type MetricTone } from "./metric";
|
|
5
|
-
import { colors } from "./colors";
|
|
6
|
-
import { SPACE } from "./spacing";
|
|
7
|
-
|
|
8
|
-
interface AlertRowProps {
|
|
9
|
-
/** Leading icon (lucide-react `<AlertCircle />`, `<CheckCircle2 />`, etc.).
|
|
10
|
-
* Sized 18px in the host. Caller controls color so different severities
|
|
11
|
-
* can use different palettes. */
|
|
12
|
-
icon: ReactNode;
|
|
13
|
-
label: string;
|
|
14
|
-
/** The count or quantity. `null` while loading → renders as `emptyLabel`. */
|
|
15
|
-
count: number | string | null;
|
|
16
|
-
/** Small text below the count — unit ("Hồ sơ"), amount caption
|
|
17
|
-
* ("12.000.000 đ"), or short hint. */
|
|
18
|
-
hint?: string;
|
|
19
|
-
/** Number size. Default `lg` keeps the row scannable; `md` for denser
|
|
20
|
-
* lists where many rows compete. */
|
|
21
|
-
size?: MetricSize;
|
|
22
|
-
/** Auto-set: non-zero count = danger (red), zero = default. Override for
|
|
23
|
-
* status semantics (e.g., when down is good). */
|
|
24
|
-
tone?: MetricTone;
|
|
25
|
-
/** Suppress the bottom hairline. Set on the last row of a list. */
|
|
26
|
-
last?: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* One row of a prioritized action list — icon + label on the left, count
|
|
31
|
-
* + hint on the right. The Mercury / Linear "needs attention" pattern.
|
|
32
|
-
*
|
|
33
|
-
* Layout enforces two-column rhythm: left side flex-grows with the label,
|
|
34
|
-
* right side is fixed-width and right-aligned. Without enforcement, rows
|
|
35
|
-
* drift into "label centered, count floating left of right edge" which
|
|
36
|
-
* makes the list unscannable.
|
|
37
|
-
*/
|
|
38
|
-
export function AlertRow(props: AlertRowProps) {
|
|
39
|
-
const { icon, label, count, hint, size = "lg", tone, last } = props;
|
|
40
|
-
const hasIssue = typeof count === "number" && count > 0;
|
|
41
|
-
const resolvedTone: MetricTone = tone ?? (hasIssue ? "danger" : "default");
|
|
42
|
-
return (
|
|
43
|
-
<View style={[styles.row, !last && styles.divider]}>
|
|
44
|
-
<View style={styles.leftCol}>
|
|
45
|
-
{icon}
|
|
46
|
-
<Text size="sm">{label}</Text>
|
|
47
|
-
</View>
|
|
48
|
-
<View style={styles.rightCol}>
|
|
49
|
-
<Metric value={count} size={size} tone={resolvedTone} />
|
|
50
|
-
{hint && (
|
|
51
|
-
<Text size="xs" color="muted">
|
|
52
|
-
{hint}
|
|
53
|
-
</Text>
|
|
54
|
-
)}
|
|
55
|
-
</View>
|
|
56
|
-
</View>
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const styles = StyleSheet.create({
|
|
61
|
-
row: {
|
|
62
|
-
flexDirection: "row",
|
|
63
|
-
alignItems: "center",
|
|
64
|
-
justifyContent: "space-between",
|
|
65
|
-
paddingVertical: SPACE.md,
|
|
66
|
-
},
|
|
67
|
-
divider: {
|
|
68
|
-
borderBottomWidth: 1,
|
|
69
|
-
borderBottomColor: colors.zinc[100],
|
|
70
|
-
},
|
|
71
|
-
leftCol: {
|
|
72
|
-
flexDirection: "row",
|
|
73
|
-
alignItems: "center",
|
|
74
|
-
gap: SPACE.md,
|
|
75
|
-
flex: 1,
|
|
76
|
-
},
|
|
77
|
-
rightCol: {
|
|
78
|
-
alignItems: "flex-end",
|
|
79
|
-
minWidth: 100,
|
|
80
|
-
},
|
|
81
|
-
});
|
package/src/table.web.tsx
DELETED
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
import { CSSProperties, Fragment, useState } from "react";
|
|
2
|
-
import { Text } from "./text";
|
|
3
|
-
import { Icon } from "./icon";
|
|
4
|
-
import { colors } from "./colors";
|
|
5
|
-
import type { Column, TableProps } from "./table_types";
|
|
6
|
-
|
|
7
|
-
export type { SortDir, TableSort, Column, TableProps } from "./table_types";
|
|
8
|
-
|
|
9
|
-
// Web table. Built from raw <div>s (not RN Pressable) so a click-to-expand row
|
|
10
|
-
// can legally contain interactive controls: the row is a <div role="row">, never
|
|
11
|
-
// a <button>. A row click toggles expansion EXCEPT when it lands on an actual
|
|
12
|
-
// interactive element — those keep their own behaviour. This is the web mirror of
|
|
13
|
-
// RN's responder model (the innermost control wins): only the control suppresses
|
|
14
|
-
// the toggle, so empty space anywhere in the row — including around a control in a
|
|
15
|
-
// wide action column — still expands. No stopPropagation, no nested <button>, no
|
|
16
|
-
// absolute overlay, no dead zones. The detail panel renders full-width below.
|
|
17
|
-
|
|
18
|
-
const CHEVRON_W = 44;
|
|
19
|
-
|
|
20
|
-
// Clicks landing on (or inside) one of these keep their own behaviour instead of
|
|
21
|
-
// toggling the row — the standard interactive HTML tags + ARIA interactive roles,
|
|
22
|
-
// plus an explicit [data-interactive] escape hatch for a non-element control.
|
|
23
|
-
const INTERACTIVE_SELECTOR =
|
|
24
|
-
'a[href], button, input, select, textarea, label, summary, [role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="radio"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="tab"], [role="slider"], [role="spinbutton"], [contenteditable="true"], [data-interactive]';
|
|
25
|
-
|
|
26
|
-
function colWidth<TRow extends Record<string, unknown>>(col: Column<TRow>): CSSProperties {
|
|
27
|
-
return col.width ? { width: col.width, flexShrink: 0 } : { flex: 1, minWidth: 0 };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function Table<TRow extends Record<string, unknown>>(props: TableProps<TRow>) {
|
|
31
|
-
const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow, onRowPress } = props;
|
|
32
|
-
const [internal, setInternal] = useState<Set<string>>(() => new Set());
|
|
33
|
-
const [hoverKey, setHoverKey] = useState<string | null>(null);
|
|
34
|
-
const expanded = expandedKeys ?? internal;
|
|
35
|
-
const expandable = !!renderDetail;
|
|
36
|
-
// A row reacts to clicks for one of two reasons: expansion (renderDetail) or
|
|
37
|
-
// selection (onRowPress). Expansion wins if both are set.
|
|
38
|
-
const pressable = expandable || !!onRowPress;
|
|
39
|
-
|
|
40
|
-
const toggle = (key: string, row: TRow) => {
|
|
41
|
-
onToggleRow?.(key, row);
|
|
42
|
-
if (expandedKeys === undefined) {
|
|
43
|
-
setInternal((prev) => {
|
|
44
|
-
const next = new Set(prev);
|
|
45
|
-
if (next.has(key)) next.delete(key);
|
|
46
|
-
else next.add(key);
|
|
47
|
-
return next;
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const visibleRows = rows.filter((r): r is TRow => Boolean(r));
|
|
53
|
-
|
|
54
|
-
return (
|
|
55
|
-
<div style={containerStyle} role="table">
|
|
56
|
-
{/* Header */}
|
|
57
|
-
<div style={headerRowStyle} role="row">
|
|
58
|
-
{columns.map((col) => {
|
|
59
|
-
const sortable = col.sortable && !!onSortChange;
|
|
60
|
-
const active = sort?.key === col.key;
|
|
61
|
-
const style: CSSProperties = {
|
|
62
|
-
...headerCellStyle,
|
|
63
|
-
...colWidth(col),
|
|
64
|
-
justifyContent: col.align === "right" ? "flex-end" : "flex-start",
|
|
65
|
-
cursor: sortable ? "pointer" : "default",
|
|
66
|
-
};
|
|
67
|
-
const inner = (
|
|
68
|
-
<>
|
|
69
|
-
<Text size="xs" weight="medium" color="muted" numberOfLines={1} userSelect="none" transform="uppercase">
|
|
70
|
-
{col.label}
|
|
71
|
-
</Text>
|
|
72
|
-
{sortable ? (
|
|
73
|
-
<Icon
|
|
74
|
-
name={active ? (sort?.dir === "asc" ? "chevron-up" : "chevron-down") : "chevrons-up-down"}
|
|
75
|
-
size={14}
|
|
76
|
-
color={active ? colors.zinc[700] : colors.zinc[400]}
|
|
77
|
-
/>
|
|
78
|
-
) : null}
|
|
79
|
-
</>
|
|
80
|
-
);
|
|
81
|
-
return sortable ? (
|
|
82
|
-
<div
|
|
83
|
-
key={col.key as string}
|
|
84
|
-
role="columnheader"
|
|
85
|
-
aria-sort={active ? (sort?.dir === "asc" ? "ascending" : "descending") : undefined}
|
|
86
|
-
tabIndex={0}
|
|
87
|
-
onClick={() => onSortChange?.(col.key)}
|
|
88
|
-
onKeyDown={(e) => {
|
|
89
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
90
|
-
e.preventDefault();
|
|
91
|
-
onSortChange?.(col.key);
|
|
92
|
-
}
|
|
93
|
-
}}
|
|
94
|
-
style={style}
|
|
95
|
-
>
|
|
96
|
-
{inner}
|
|
97
|
-
</div>
|
|
98
|
-
) : (
|
|
99
|
-
<div key={col.key as string} role="columnheader" style={style}>
|
|
100
|
-
{inner}
|
|
101
|
-
</div>
|
|
102
|
-
);
|
|
103
|
-
})}
|
|
104
|
-
{expandable ? <div style={{ width: CHEVRON_W, flexShrink: 0 }} /> : null}
|
|
105
|
-
</div>
|
|
106
|
-
|
|
107
|
-
{/* Body */}
|
|
108
|
-
{visibleRows.map((row, i) => {
|
|
109
|
-
const key = rowKey ? String(row[rowKey]) : String(i);
|
|
110
|
-
const isOpen = expanded.has(key);
|
|
111
|
-
const extra = (rowStyle?.(row) as CSSProperties | undefined) ?? undefined;
|
|
112
|
-
const rowStyleFinal: CSSProperties = {
|
|
113
|
-
...bodyRowStyle,
|
|
114
|
-
cursor: pressable ? "pointer" : "default",
|
|
115
|
-
background: hoverKey === key && pressable ? colors.zinc[50] : colors.white,
|
|
116
|
-
...extra,
|
|
117
|
-
};
|
|
118
|
-
return (
|
|
119
|
-
<Fragment key={key}>
|
|
120
|
-
<div
|
|
121
|
-
role="row"
|
|
122
|
-
onClick={
|
|
123
|
-
pressable
|
|
124
|
-
? (e: React.MouseEvent) => {
|
|
125
|
-
// Whole-row click acts (expand or select), EXCEPT when it lands on an
|
|
126
|
-
// actual interactive element — those keep their own behaviour. The
|
|
127
|
-
// disclosure chevron is the keyboard/AT affordance for expansion.
|
|
128
|
-
if ((e.target as HTMLElement).closest(INTERACTIVE_SELECTOR)) return;
|
|
129
|
-
if (expandable) toggle(key, row);
|
|
130
|
-
else onRowPress?.(row);
|
|
131
|
-
}
|
|
132
|
-
: undefined
|
|
133
|
-
}
|
|
134
|
-
onMouseEnter={pressable ? () => setHoverKey(key) : undefined}
|
|
135
|
-
onMouseLeave={pressable ? () => setHoverKey((k) => (k === key ? null : k)) : undefined}
|
|
136
|
-
style={rowStyleFinal}
|
|
137
|
-
>
|
|
138
|
-
{columns.map((col) => (
|
|
139
|
-
<div
|
|
140
|
-
key={col.key as string}
|
|
141
|
-
role="cell"
|
|
142
|
-
style={{
|
|
143
|
-
...bodyCellStyle,
|
|
144
|
-
...colWidth(col),
|
|
145
|
-
alignItems: col.align === "right" ? "flex-end" : "flex-start",
|
|
146
|
-
}}
|
|
147
|
-
>
|
|
148
|
-
{col.renderCell ? (
|
|
149
|
-
col.renderCell({ row, column: col })
|
|
150
|
-
) : (
|
|
151
|
-
<Text numberOfLines={1}>{row[col.key] != null ? String(row[col.key]) : ""}</Text>
|
|
152
|
-
)}
|
|
153
|
-
</div>
|
|
154
|
-
))}
|
|
155
|
-
{expandable ? (
|
|
156
|
-
<button
|
|
157
|
-
type="button"
|
|
158
|
-
data-interactive
|
|
159
|
-
aria-expanded={isOpen}
|
|
160
|
-
aria-label={isOpen ? "Collapse row" : "Expand row"}
|
|
161
|
-
onClick={() => toggle(key, row)}
|
|
162
|
-
style={chevronButtonStyle}
|
|
163
|
-
>
|
|
164
|
-
<Icon name={isOpen ? "chevron-up" : "chevron-down"} size={18} color={colors.zinc[400]} />
|
|
165
|
-
</button>
|
|
166
|
-
) : null}
|
|
167
|
-
</div>
|
|
168
|
-
{expandable && isOpen ? (
|
|
169
|
-
<div role="row" style={detailRowStyle}>
|
|
170
|
-
<div role="cell" style={detailCellStyle}>
|
|
171
|
-
{renderDetail!(row)}
|
|
172
|
-
</div>
|
|
173
|
-
</div>
|
|
174
|
-
) : null}
|
|
175
|
-
</Fragment>
|
|
176
|
-
);
|
|
177
|
-
})}
|
|
178
|
-
</div>
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// `maxHeight: 100%` is a no-op in a content-sized parent (a card grows to its
|
|
183
|
-
// rows) but caps the table to a bounded parent (a modal's flex region), so
|
|
184
|
-
// `overflow: auto` then scrolls and the sticky header engages.
|
|
185
|
-
const containerStyle: CSSProperties = { width: "100%", maxHeight: "100%", overflow: "auto" };
|
|
186
|
-
const headerRowStyle: CSSProperties = {
|
|
187
|
-
display: "flex",
|
|
188
|
-
position: "sticky",
|
|
189
|
-
top: 0,
|
|
190
|
-
zIndex: 1,
|
|
191
|
-
background: colors.white,
|
|
192
|
-
borderBottom: `1px solid ${colors.border}`,
|
|
193
|
-
};
|
|
194
|
-
const headerCellStyle: CSSProperties = {
|
|
195
|
-
minHeight: 40,
|
|
196
|
-
display: "flex",
|
|
197
|
-
flexDirection: "row",
|
|
198
|
-
alignItems: "center",
|
|
199
|
-
gap: 4,
|
|
200
|
-
padding: "10px 12px",
|
|
201
|
-
boxSizing: "border-box",
|
|
202
|
-
};
|
|
203
|
-
const bodyRowStyle: CSSProperties = {
|
|
204
|
-
display: "flex",
|
|
205
|
-
alignItems: "stretch",
|
|
206
|
-
borderBottom: `1px solid ${colors.border}`,
|
|
207
|
-
};
|
|
208
|
-
const bodyCellStyle: CSSProperties = {
|
|
209
|
-
minHeight: 44,
|
|
210
|
-
display: "flex",
|
|
211
|
-
flexDirection: "column",
|
|
212
|
-
justifyContent: "center",
|
|
213
|
-
padding: 12,
|
|
214
|
-
boxSizing: "border-box",
|
|
215
|
-
};
|
|
216
|
-
const chevronButtonStyle: CSSProperties = {
|
|
217
|
-
width: CHEVRON_W,
|
|
218
|
-
flexShrink: 0,
|
|
219
|
-
display: "flex",
|
|
220
|
-
alignItems: "center",
|
|
221
|
-
justifyContent: "center",
|
|
222
|
-
background: "transparent",
|
|
223
|
-
border: "none",
|
|
224
|
-
padding: 0,
|
|
225
|
-
cursor: "pointer",
|
|
226
|
-
};
|
|
227
|
-
const detailRowStyle: CSSProperties = {
|
|
228
|
-
background: colors.zinc[50],
|
|
229
|
-
borderBottom: `1px solid ${colors.border}`,
|
|
230
|
-
};
|
|
231
|
-
const detailCellStyle: CSSProperties = {
|
|
232
|
-
padding: "4px 16px 18px",
|
|
233
|
-
width: "100%",
|
|
234
|
-
boxSizing: "border-box",
|
|
235
|
-
};
|