@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.
Files changed (69) hide show
  1. package/package.json +28 -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/skeleton.tsx +47 -0
  55. package/src/sort_header.tsx +102 -0
  56. package/src/stacked_progress_bar.tsx +51 -16
  57. package/src/status_grid.tsx +187 -0
  58. package/src/step_list.tsx +128 -0
  59. package/src/step_progress.tsx +145 -0
  60. package/src/stepper.tsx +9 -4
  61. package/src/table.tsx +168 -112
  62. package/src/text.tsx +15 -0
  63. package/src/text_utils.ts +10 -0
  64. package/src/timeline.tsx +90 -57
  65. package/src/trend_footer.tsx +2 -2
  66. package/src/alert_row.tsx +0 -81
  67. package/src/table.web.tsx +0 -235
  68. package/src/table_picker.tsx +0 -305
  69. 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
- };
@@ -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
- });
@@ -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
- }