@lotics/ui 2.4.1 → 2.6.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 +28 -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/skeleton.tsx +47 -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/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
|
-
};
|
package/src/table_picker.tsx
DELETED
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
import { useCallback, useMemo } from "react";
|
|
2
|
-
import { StyleSheet, View } from "react-native";
|
|
3
|
-
import { colors } from "./colors";
|
|
4
|
-
import { Text } from "./text";
|
|
5
|
-
import { SearchInput } from "./search_input";
|
|
6
|
-
import { ActivityIndicator } from "./activity_indicator";
|
|
7
|
-
import { Dialog } from "./dialog";
|
|
8
|
-
import { Table } from "@lotics/ui/table";
|
|
9
|
-
import type { Column, TableSort } from "@lotics/ui/table_types";
|
|
10
|
-
import { Pagination } from "./pagination";
|
|
11
|
-
import { ColumnFilter, type FilterableColumn, type ColumnFilterValue } from "./column_filter";
|
|
12
|
-
|
|
13
|
-
/** A grid column the picker displays. Sorting is opt-in per column. */
|
|
14
|
-
export interface PickerColumn<TRow extends Record<string, unknown>> {
|
|
15
|
-
key: string;
|
|
16
|
-
label: string;
|
|
17
|
-
/** Fixed pixel width. Omit to flex. */
|
|
18
|
-
width?: number;
|
|
19
|
-
align?: "left" | "right";
|
|
20
|
-
/** Show a sortable header for this column. Requires `sort`/`onSortChange`. */
|
|
21
|
-
sortable?: boolean;
|
|
22
|
-
/** Cell content. Default: the row's value at `key` rendered as text. */
|
|
23
|
-
renderCell?: (row: TRow) => React.ReactNode;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Single-column sort state — the picker sorts by one column at a time. */
|
|
27
|
-
export interface TablePickerSort {
|
|
28
|
-
key: string;
|
|
29
|
-
order: "asc" | "desc";
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface TablePickerProps<TRow extends Record<string, unknown>> {
|
|
33
|
-
open: boolean;
|
|
34
|
-
onOpenChange: (open: boolean) => void;
|
|
35
|
-
/** Heading shown above the search box. */
|
|
36
|
-
title?: string;
|
|
37
|
-
|
|
38
|
-
columns: PickerColumn<TRow>[];
|
|
39
|
-
/** Rows of the current page (the consumer paginates). */
|
|
40
|
-
rows: TRow[];
|
|
41
|
-
/** The field holding each row's stable id — returned by selection and used to
|
|
42
|
-
* highlight the picked row (e.g. `"__source_record_id"`). */
|
|
43
|
-
rowKey: keyof TRow & string;
|
|
44
|
-
|
|
45
|
-
/** Currently selected row id, or null. Highlights the matching row. */
|
|
46
|
-
value: string | null;
|
|
47
|
-
/** Called with the picked row id. */
|
|
48
|
-
onValueChange: (id: string) => void;
|
|
49
|
-
/** Close the dialog after a pick. Default `true`. */
|
|
50
|
-
closeOnSelect?: boolean;
|
|
51
|
-
|
|
52
|
-
/** Controlled search term. The consumer owns it and re-queries. */
|
|
53
|
-
searchQuery: string;
|
|
54
|
-
onSearchChange: (query: string) => void;
|
|
55
|
-
searchPlaceholder?: string;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Controlled single-column sort. Omit `onSortChange` to disable sorting.
|
|
59
|
-
* Toggling a column cycles asc → desc → off.
|
|
60
|
-
*/
|
|
61
|
-
sort?: TablePickerSort | null;
|
|
62
|
-
onSortChange?: (sort: TablePickerSort | null) => void;
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Per-column filter pills. The consumer holds `filterValues` (keyed by column
|
|
66
|
-
* key) and maps them to query conditions via `columnFilterToConditions`.
|
|
67
|
-
*/
|
|
68
|
-
filters?: FilterableColumn[];
|
|
69
|
-
filterValues?: Record<string, ColumnFilterValue>;
|
|
70
|
-
onFilterChange?: (key: string, value: ColumnFilterValue | undefined) => void;
|
|
71
|
-
/** Accessible name for the per-filter clear control. Pass a translated string. */
|
|
72
|
-
clearLabel?: string;
|
|
73
|
-
|
|
74
|
-
/** Pagination (page-model — the consumer owns the page cursor). */
|
|
75
|
-
page: number;
|
|
76
|
-
pageSize: number;
|
|
77
|
-
/** Total rows across all pages, when known — drives "Page 1 of N". */
|
|
78
|
-
total?: number;
|
|
79
|
-
hasMore: boolean;
|
|
80
|
-
onPageChange: (page: number) => void;
|
|
81
|
-
|
|
82
|
-
/** A request is in flight. */
|
|
83
|
-
loading?: boolean;
|
|
84
|
-
/** Message when there are no rows and nothing is loading. */
|
|
85
|
-
emptyLabel?: string;
|
|
86
|
-
/** Max dialog width — a browse table needs more room than a form dialog.
|
|
87
|
-
* Default 1080. */
|
|
88
|
-
maxWidth?: number | `${number}%`;
|
|
89
|
-
testID?: string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Render an arbitrary cell value as a single line when no `renderCell` is given. */
|
|
93
|
-
function defaultCellText(value: unknown): string {
|
|
94
|
-
if (value == null) return "";
|
|
95
|
-
if (typeof value === "string") return value;
|
|
96
|
-
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
97
|
-
if (Array.isArray(value)) return value.map(defaultCellText).filter(Boolean).join(", ");
|
|
98
|
-
if (typeof value === "object") {
|
|
99
|
-
const o = value as { label?: unknown; display?: unknown; name?: unknown };
|
|
100
|
-
for (const k of [o.label, o.display, o.name]) {
|
|
101
|
-
if (typeof k === "string") return k;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return "";
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* A data-agnostic record-style picker: a modal holding a table the user can
|
|
109
|
-
* browse (numbered pages), search, sort, and filter, then pick one row. It knows
|
|
110
|
-
* nothing about records/tables/fields — the consumer supplies `columns` + `rows`
|
|
111
|
-
* (one page) and owns the search/sort/filter/page state (so the same component
|
|
112
|
-
* drives a server query, an in-memory list, or any other source). Compose it
|
|
113
|
-
* with a domain hook (e.g. an app's `usePaginatedQuery`) to build a record
|
|
114
|
-
* picker, or with any other row source for a different purpose.
|
|
115
|
-
*
|
|
116
|
-
* Single-select: clicking a row picks its id; the selected row is highlighted;
|
|
117
|
-
* `closeOnSelect` (default) dismisses the modal on pick.
|
|
118
|
-
*/
|
|
119
|
-
export function TablePicker<TRow extends Record<string, unknown>>(
|
|
120
|
-
props: TablePickerProps<TRow>,
|
|
121
|
-
) {
|
|
122
|
-
const {
|
|
123
|
-
open,
|
|
124
|
-
onOpenChange,
|
|
125
|
-
title,
|
|
126
|
-
columns,
|
|
127
|
-
rows,
|
|
128
|
-
rowKey,
|
|
129
|
-
value,
|
|
130
|
-
onValueChange,
|
|
131
|
-
closeOnSelect = true,
|
|
132
|
-
searchQuery,
|
|
133
|
-
onSearchChange,
|
|
134
|
-
searchPlaceholder,
|
|
135
|
-
sort = null,
|
|
136
|
-
onSortChange,
|
|
137
|
-
filters,
|
|
138
|
-
filterValues,
|
|
139
|
-
onFilterChange,
|
|
140
|
-
clearLabel,
|
|
141
|
-
page,
|
|
142
|
-
pageSize,
|
|
143
|
-
total,
|
|
144
|
-
hasMore,
|
|
145
|
-
onPageChange,
|
|
146
|
-
loading = false,
|
|
147
|
-
emptyLabel,
|
|
148
|
-
maxWidth = 1080,
|
|
149
|
-
testID,
|
|
150
|
-
} = props;
|
|
151
|
-
|
|
152
|
-
const handleSelect = useCallback(
|
|
153
|
-
(row: TRow) => {
|
|
154
|
-
onValueChange(String(row[rowKey]));
|
|
155
|
-
if (closeOnSelect) onOpenChange(false);
|
|
156
|
-
},
|
|
157
|
-
[onValueChange, rowKey, closeOnSelect, onOpenChange],
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
// Toggle this column's sort: asc → desc → off. The consumer holds the state.
|
|
161
|
-
const toggleSort = useCallback(
|
|
162
|
-
(key: string) => {
|
|
163
|
-
if (!onSortChange) return;
|
|
164
|
-
if (!sort || sort.key !== key) onSortChange({ key, order: "asc" });
|
|
165
|
-
else if (sort.order === "asc") onSortChange({ key, order: "desc" });
|
|
166
|
-
else onSortChange(null);
|
|
167
|
-
},
|
|
168
|
-
[sort, onSortChange],
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
const tableColumns = useMemo<Column<TRow>[]>(
|
|
172
|
-
() =>
|
|
173
|
-
columns.map((col) => ({
|
|
174
|
-
key: col.key,
|
|
175
|
-
label: col.label,
|
|
176
|
-
width: col.width,
|
|
177
|
-
align: col.align,
|
|
178
|
-
sortable: col.sortable && !!onSortChange,
|
|
179
|
-
renderCell: ({ row }: { row: TRow }) =>
|
|
180
|
-
col.renderCell ? (
|
|
181
|
-
col.renderCell(row)
|
|
182
|
-
) : (
|
|
183
|
-
<Text size="sm" color="zinc-900" numberOfLines={1}>
|
|
184
|
-
{defaultCellText(row[col.key])}
|
|
185
|
-
</Text>
|
|
186
|
-
),
|
|
187
|
-
})),
|
|
188
|
-
[columns, onSortChange],
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
const tableSort = useMemo<TableSort<TRow> | null>(
|
|
192
|
-
() => (sort ? { key: sort.key, dir: sort.order } : null),
|
|
193
|
-
[sort],
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
const rowStyle = useCallback(
|
|
197
|
-
(row: TRow) =>
|
|
198
|
-
value != null && String(row[rowKey]) === value ? { backgroundColor: colors.blue["50"] } : undefined,
|
|
199
|
-
[value, rowKey],
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
return (
|
|
203
|
-
<Dialog open={open} onOpenChange={onOpenChange} height="90%" maxWidth={maxWidth} testID={testID}>
|
|
204
|
-
<View style={styles.root}>
|
|
205
|
-
{title ? (
|
|
206
|
-
<Text size="lg" weight="semibold" color="zinc-900" style={styles.title}>
|
|
207
|
-
{title}
|
|
208
|
-
</Text>
|
|
209
|
-
) : null}
|
|
210
|
-
|
|
211
|
-
<SearchInput
|
|
212
|
-
testID="table-picker-search"
|
|
213
|
-
value={searchQuery}
|
|
214
|
-
onChangeText={onSearchChange}
|
|
215
|
-
placeholder={searchPlaceholder}
|
|
216
|
-
style={styles.search}
|
|
217
|
-
/>
|
|
218
|
-
|
|
219
|
-
{filters && filters.length > 0 ? (
|
|
220
|
-
<View style={styles.filterRow}>
|
|
221
|
-
{filters.map((f) => (
|
|
222
|
-
<ColumnFilter
|
|
223
|
-
key={f.key}
|
|
224
|
-
column={f}
|
|
225
|
-
value={filterValues?.[f.key]}
|
|
226
|
-
onChange={(v) => onFilterChange?.(f.key, v)}
|
|
227
|
-
clearLabel={clearLabel}
|
|
228
|
-
/>
|
|
229
|
-
))}
|
|
230
|
-
</View>
|
|
231
|
-
) : null}
|
|
232
|
-
|
|
233
|
-
<View style={styles.gridArea}>
|
|
234
|
-
{rows.length > 0 ? (
|
|
235
|
-
<Table<TRow>
|
|
236
|
-
columns={tableColumns}
|
|
237
|
-
rows={rows}
|
|
238
|
-
rowKey={rowKey}
|
|
239
|
-
sort={tableSort}
|
|
240
|
-
onSortChange={onSortChange ? (key) => toggleSort(key as string) : undefined}
|
|
241
|
-
onRowPress={handleSelect}
|
|
242
|
-
rowStyle={rowStyle}
|
|
243
|
-
/>
|
|
244
|
-
) : (
|
|
245
|
-
<View style={styles.center}>
|
|
246
|
-
{loading ? (
|
|
247
|
-
<ActivityIndicator />
|
|
248
|
-
) : (
|
|
249
|
-
<Text size="sm" color="zinc-500">
|
|
250
|
-
{emptyLabel ?? "No results"}
|
|
251
|
-
</Text>
|
|
252
|
-
)}
|
|
253
|
-
</View>
|
|
254
|
-
)}
|
|
255
|
-
</View>
|
|
256
|
-
|
|
257
|
-
<Pagination
|
|
258
|
-
page={page}
|
|
259
|
-
pageSize={pageSize}
|
|
260
|
-
rowCount={rows.length}
|
|
261
|
-
hasMore={hasMore}
|
|
262
|
-
total={total}
|
|
263
|
-
loading={loading}
|
|
264
|
-
onPageChange={onPageChange}
|
|
265
|
-
/>
|
|
266
|
-
</View>
|
|
267
|
-
</Dialog>
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const styles = StyleSheet.create({
|
|
272
|
-
root: {
|
|
273
|
-
flex: 1,
|
|
274
|
-
width: "100%",
|
|
275
|
-
gap: 12,
|
|
276
|
-
// Match the Dialog's own horizontal inset (the close button sits at 24) so
|
|
277
|
-
// the content isn't flush against the dialog edges.
|
|
278
|
-
paddingHorizontal: 24,
|
|
279
|
-
paddingBottom: 8,
|
|
280
|
-
},
|
|
281
|
-
title: {},
|
|
282
|
-
search: {
|
|
283
|
-
width: "100%",
|
|
284
|
-
},
|
|
285
|
-
filterRow: {
|
|
286
|
-
flexDirection: "row",
|
|
287
|
-
flexWrap: "wrap",
|
|
288
|
-
alignItems: "center",
|
|
289
|
-
gap: 8,
|
|
290
|
-
},
|
|
291
|
-
gridArea: {
|
|
292
|
-
flex: 1,
|
|
293
|
-
minHeight: 0,
|
|
294
|
-
borderWidth: 1,
|
|
295
|
-
borderColor: colors.border,
|
|
296
|
-
borderRadius: 12,
|
|
297
|
-
overflow: "hidden",
|
|
298
|
-
},
|
|
299
|
-
center: {
|
|
300
|
-
flex: 1,
|
|
301
|
-
alignItems: "center",
|
|
302
|
-
justifyContent: "center",
|
|
303
|
-
padding: 24,
|
|
304
|
-
},
|
|
305
|
-
});
|
package/src/table_types.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
|
-
import type { ViewStyle } from "react-native";
|
|
3
|
-
|
|
4
|
-
export type SortDir = "asc" | "desc";
|
|
5
|
-
|
|
6
|
-
export interface TableSort<TRow extends Record<string, unknown>> {
|
|
7
|
-
key: keyof TRow;
|
|
8
|
-
dir: SortDir;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface Column<TRow extends Record<string, unknown>> {
|
|
12
|
-
key: keyof TRow;
|
|
13
|
-
label: string;
|
|
14
|
-
width?: number;
|
|
15
|
-
/** Horizontal alignment of header + cell content. Default "left". */
|
|
16
|
-
align?: "left" | "right";
|
|
17
|
-
/** When true (and `onSortChange` is set), the header is pressable + shows a
|
|
18
|
-
* sort arrow when this column is the active `sort`. */
|
|
19
|
-
sortable?: boolean;
|
|
20
|
-
renderCell?: (params: { row: TRow; column: Column<TRow> }) => ReactNode;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface TableProps<TRow extends Record<string, unknown>> {
|
|
24
|
-
columns: Column<TRow>[];
|
|
25
|
-
rows: (TRow | null | false)[];
|
|
26
|
-
/** Stable per-row key. Required when `renderDetail` is set (expansion is
|
|
27
|
-
* tracked by this key, not row index, so it survives sort/filter). */
|
|
28
|
-
rowKey?: keyof TRow;
|
|
29
|
-
rowStyle?: (row: TRow) => ViewStyle | undefined;
|
|
30
|
-
/** Current sort. Pair with `onSortChange` to render sortable headers; the
|
|
31
|
-
* parent owns the actual sorting of `rows` — this only drives the indicator. */
|
|
32
|
-
sort?: TableSort<TRow> | null;
|
|
33
|
-
onSortChange?: (key: keyof TRow) => void;
|
|
34
|
-
/** Click-to-select: pressing a row (except its interactive controls) calls
|
|
35
|
-
* this — e.g. a picker that returns the chosen row. Mutually exclusive with
|
|
36
|
-
* `renderDetail` (a row either expands or selects); `renderDetail` wins if
|
|
37
|
-
* both are set. Pair with `rowStyle` to highlight the selected row. */
|
|
38
|
-
onRowPress?: (row: TRow) => void;
|
|
39
|
-
/** Render an inline detail panel, full-width below the row. When set, the
|
|
40
|
-
* whole row (except its interactive controls) is click-to-expand. The row
|
|
41
|
-
* detects controls by element/role, so cells need no special marking. */
|
|
42
|
-
renderDetail?: (row: TRow) => ReactNode;
|
|
43
|
-
/** Controlled set of expanded row keys. Omit for internal (uncontrolled) state. */
|
|
44
|
-
expandedKeys?: Set<string>;
|
|
45
|
-
/** Called when a row's expand state toggles (with its `rowKey` value). */
|
|
46
|
-
onToggleRow?: (key: string, row: TRow) => void;
|
|
47
|
-
}
|